) {
e.preventDefault();
@@ -138,6 +144,7 @@ function BookingForm({ data, privacyUrl }: { data: EmbedData; privacyUrl: string
// The embed has no time picker — the API requires a timeSlot, and
// 'all-day' is the honest default (server collapses it internally).
timeSlot: "all-day",
+ ...(locale ? { locale } : {}),
turnstileToken: fd.get("cf-turnstile-response") || undefined,
}),
});
@@ -230,6 +237,15 @@ function BookingForm({ data, privacyUrl }: { data: EmbedData; privacyUrl: string
+
+
+
+
{m.booking_privacy_shared_notice({ name: data.inspectorName })}
{privacyUrl && <> {m.booking_privacy_see_our()} {m.booking_link_privacy_policy()} .>}
diff --git a/app/routes/public/booking.test.tsx b/app/routes/public/booking.test.tsx
index ab4ba4f2c..aabcb3f41 100644
--- a/app/routes/public/booking.test.tsx
+++ b/app/routes/public/booking.test.tsx
@@ -7,7 +7,7 @@
*
* An anonymous visitor must see exactly what they saw before.
*/
-import { describe, it, expect, beforeEach } from "vitest";
+import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, fireEvent, waitFor, screen } from "@testing-library/react";
import { createRoutesStub } from "react-router";
@@ -26,13 +26,15 @@ const PROFILE = {
function renderBooking(opts: {
agentBooking?: { agentName: string; tenantId: string } | null;
onAction?: (form: FormData) => unknown;
+ /** Profile fields to override — e.g. a tenant with no Turnstile site key. */
+ profile?: Partial;
}) {
const Stub = createRoutesStub([
{
path: "/book/:tenant",
Component: BookingPage,
loader: () => ({
- profile: PROFILE,
+ profile: { ...PROFILE, ...opts.profile },
preselected: null,
error: null,
tenant: "acme",
@@ -112,6 +114,97 @@ describe("BookingPage — prefill", () => {
});
});
+describe("BookingPage — language preference", () => {
+ beforeEach(() => localStorage.clear());
+
+ /**
+ * Capture what the page POSTs to the public booking endpoint. The schedule
+ * step also GETs /api/public/slots for the holiday advisory, so the stub has
+ * to answer that too rather than swallow every request.
+ */
+ function captureBookingPosts(posted: unknown[]) {
+ return vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
+ if (String(url).includes("/api/public/book")) {
+ posted.push(JSON.parse(String((init as RequestInit).body)));
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
+ }
+ return new Response(JSON.stringify({ data: {} }), { status: 200 });
+ });
+ }
+
+ /** Walk the wizard to the schedule step, where the contact block lives. */
+ async function toScheduleStep() {
+ fireEvent.change(await screen.findByPlaceholderText(/123 Main St/i), {
+ target: { value: "1 A St" },
+ });
+ fireEvent.click(await screen.findByText("Continue"));
+ fireEvent.click(await screen.findByText("Full Inspection"));
+ fireEvent.click(await screen.findByText("Continue"));
+ }
+
+ it("sends the chosen language with an anonymous booking, and nothing when unanswered", async () => {
+ const posted: unknown[] = [];
+ const fetchSpy = captureBookingPosts(posted);
+
+ try {
+ // A tenant with no Turnstile site key: the challenge widget cannot load
+ // in happy-dom, and Turnstile itself is exercised elsewhere. Nothing here
+ // bypasses it — the server still enforces whenever a secret is set.
+ renderBooking({ agentBooking: null, profile: { turnstileSiteKey: "" } });
+ await toScheduleStep();
+
+ const date = document.querySelector("input[type='date']") as HTMLInputElement;
+ fireEvent.change(date, { target: { value: "2026-09-01" } });
+ fireEvent.change(screen.getByPlaceholderText("Jane Doe"), { target: { value: "Sarah Buyer" } });
+ fireEvent.change(screen.getByPlaceholderText("jane@example.com"), { target: { value: "sarah@example.com" } });
+ fireEvent.click(screen.getByRole("radio", { name: /español/i }));
+ fireEvent.click(await screen.findByText("Continue"));
+ fireEvent.click(await screen.findByText("Request Inspection"));
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ expect((posted[0] as { locale?: string }).locale).toBe("es-419");
+ } finally {
+ fetchSpy.mockRestore();
+ }
+ });
+
+ it("omits the key entirely when the client never picked a language", async () => {
+ const posted: unknown[] = [];
+ const fetchSpy = captureBookingPosts(posted);
+
+ try {
+ // A tenant with no Turnstile site key: the challenge widget cannot load
+ // in happy-dom, and Turnstile itself is exercised elsewhere. Nothing here
+ // bypasses it — the server still enforces whenever a secret is set.
+ renderBooking({ agentBooking: null, profile: { turnstileSiteKey: "" } });
+ await toScheduleStep();
+
+ const date = document.querySelector("input[type='date']") as HTMLInputElement;
+ fireEvent.change(date, { target: { value: "2026-09-01" } });
+ fireEvent.change(screen.getByPlaceholderText("Jane Doe"), { target: { value: "Sarah Buyer" } });
+ fireEvent.change(screen.getByPlaceholderText("jane@example.com"), { target: { value: "sarah@example.com" } });
+ fireEvent.click(await screen.findByText("Continue"));
+ fireEvent.click(await screen.findByText("Request Inspection"));
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ // The KEY must be absent, not null or "": a payload that always carries
+ // a locale is a payload in which every booking looks like a choice.
+ expect(Object.keys(posted[0] as object)).not.toContain("locale");
+ } finally {
+ fetchSpy.mockRestore();
+ }
+ });
+
+ it("does not ask an agent to pick a language for someone else's client", async () => {
+ renderBooking({ agentBooking: { agentName: "Jane Smith", tenantId: "t-1" } });
+ await screen.findByText(/on behalf of a client/i);
+ await toScheduleStep();
+ // The agent would be guessing, and a guess recorded as a stated preference
+ // corrupts the only measurement this field exists to produce.
+ expect(screen.queryByRole("radio", { name: /español/i })).toBeNull();
+ });
+});
+
describe("BookingPage — signed-in agent", () => {
it("says whose behalf the booking is on", async () => {
const { findByText } = renderBooking({
diff --git a/messages/en/booking.json b/messages/en/booking.json
index f9176e523..c122dc079 100644
--- a/messages/en/booking.json
+++ b/messages/en/booking.json
@@ -61,6 +61,7 @@
"booking_step_yourinfo_heading": "Your info",
"booking_step_yourinfo_subtitle": "How do we reach you with the report?",
"booking_field_fullname_label": "Full name",
+ "booking_field_language_label": "Preferred language",
"booking_confirm_submitted_heading": "Request Submitted",
"booking_confirm_details_heading": "Confirm details",
"booking_confirm_subtitle": "Review your booking before submitting.",
diff --git a/packages/shared-ui/src/Radio.tsx b/packages/shared-ui/src/Radio.tsx
index ff18291fe..c898a7da3 100644
--- a/packages/shared-ui/src/Radio.tsx
+++ b/packages/shared-ui/src/Radio.tsx
@@ -35,6 +35,11 @@ interface RadioGroupProps {
error?: string;
hint?: string;
className?: string;
+ /** Overrides the legend's typography for a surface with its own field-label
+ * idiom — the public booking form's uppercase micro-label, for one. Only the
+ * styling is overridable: the legend element itself always renders, because
+ * it is the group's accessible name. */
+ legendClassName?: string;
}
/**
@@ -52,12 +57,11 @@ export function RadioGroup({
error,
hint,
className = "",
+ legendClassName = "block text-xs font-bold text-ih-fg-2 mb-1",
}: RadioGroupProps) {
return (
- {legend && (
- {legend}
- )}
+ {legend && {legend} }
{options.map((o) => (
data.timeSlot !== 'custom' || !!data.customTime,
{ message: 'customTime is required when timeSlot is custom', path: ['customTime'] },
diff --git a/server/services/booking.service.ts b/server/services/booking.service.ts
index ef92f2c94..a7ab28e34 100644
--- a/server/services/booking.service.ts
+++ b/server/services/booking.service.ts
@@ -8,6 +8,7 @@ import { Errors } from '../lib/errors';
import { safeISODate } from '../lib/date';
import { logger } from '../lib/logger';
import { fireAutomation } from './inspection/shared';
+import { normalizeLocale } from '../lib/i18n/contact-locale';
import type { HonoConfig } from '../types/hono';
import type { PublicBookingSchema } from '../lib/validations/booking.schema';
import type { z } from '@hono/zod-openapi';
@@ -688,6 +689,11 @@ export class BookingService {
name: body.clientName,
email: body.clientEmail,
type: 'client',
+ // Reduced to a locale we actually have messages for, so a
+ // regional variant lands on its catalogue and anything we
+ // cannot speak is stored as NULL rather than as a promise
+ // we would break at send time.
+ locale: normalizeLocale(body.locale),
});
bookingClientContactId = clientContactId;
} catch (e) {
diff --git a/server/services/contact.service.ts b/server/services/contact.service.ts
index cc22a239c..99f7dc362 100644
--- a/server/services/contact.service.ts
+++ b/server/services/contact.service.ts
@@ -247,7 +247,7 @@ export class ContactService {
*/
async upsertClientContact(
tenantId: string,
- input: { name: string; email?: string; phone?: string; type: 'client' | 'agent' },
+ input: { name: string; email?: string; phone?: string; type: 'client' | 'agent'; locale?: string | null },
): Promise<{ id: string; created: boolean }> {
const db = this.getDrizzle();
const normalizedEmail = input.email ? input.email.toLowerCase().trim() : undefined;
@@ -268,13 +268,21 @@ export class ContactService {
if (existing) {
// Fill-forward: only update name/phone if currently null/empty.
- const updates: Partial<{ name: string; phone: string }> = {};
+ const updates: Partial<{ name: string; phone: string; locale: string }> = {};
if ((!existing.name || existing.name.trim() === '') && input.name) {
updates.name = input.name;
}
if ((!existing.phone || existing.phone.trim() === '') && input.phone) {
updates.phone = input.phone;
}
+ // Locale does NOT fill forward: the contact has just told us
+ // again, and the newer answer is the true one. Being written to
+ // in English after asking for Spanish is the failure this
+ // avoids. An omitted choice still never clears a stored one —
+ // silence is not a retraction.
+ if (input.locale && input.locale !== existing.locale) {
+ updates.locale = input.locale;
+ }
if (Object.keys(updates).length > 0) {
await db
.update(contacts)
@@ -300,6 +308,7 @@ export class ContactService {
phone: input.phone ?? null,
agency: null,
notes: null,
+ locale: input.locale ?? null,
createdAt: new Date(),
});
return { id, created: true };
diff --git a/tests/unit/bookings/booking-contact-upsert.spec.ts b/tests/unit/bookings/booking-contact-upsert.spec.ts
index f950200b2..8d8b58267 100644
--- a/tests/unit/bookings/booking-contact-upsert.spec.ts
+++ b/tests/unit/bookings/booking-contact-upsert.spec.ts
@@ -226,7 +226,86 @@ describe('POST /book — client contact upsert (#111 / IA-18)', () => {
expect(primary2?.contactId).toBe(contactId);
});
- // 3. Contact-upsert failure → booking still succeeds (200), inspection row
+ // 3. A stated language preference lands on the contact, and its ABSENCE
+ // stays absent. These two are one pair: the field exists so someone can
+ // count who asked for another language, and that count only means
+ // anything if a booking that said nothing is distinguishable from one
+ // that chose English.
+ it('stores the language the client chose on their contact', async () => {
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody({ locale: 'es-419' }), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ expect(row?.locale).toBe('es-419');
+ });
+
+ it('never clears a stored language when a later booking says nothing', async () => {
+ // Silence is not a retraction — only a NEW answer replaces the old one.
+ const { app } = buildApp(db, booking, contact);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '08:00', locale: 'es-419' }), FAKE_ENV, FAKE_EXEC_CTX);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '10:00' }), FAKE_ENV, FAKE_EXEC_CTX);
+
+ const { eq, and } = await import('drizzle-orm');
+ const rows = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.type, 'client'))).all();
+ expect(rows.length).toBe(1);
+ expect(rows[0].locale).toBe('es-419');
+ });
+
+ it('leaves the locale NULL when the client did not choose one', async () => {
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody(), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ // NULL means "fall through to the tenant default", never "English".
+ expect(row?.locale).toBeNull();
+ });
+
+ it('stores a regional variant as the catalogue we can actually speak', async () => {
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody({ locale: 'es-MX' }), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ expect(row?.locale).toBe('es-419');
+ });
+
+ it('accepts a booking in a language we do not speak, and stores no promise', async () => {
+ // Rejecting the booking would be the worse failure by far: the request
+ // is for an inspection, not for a translation.
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody({ locale: 'fr-FR' }), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ expect(row?.locale).toBeNull();
+ });
+
+ it('lets a returning client change their mind about the language', async () => {
+ // Unlike name and phone, which fill forward, the newer answer wins:
+ // being written to in English after asking for Spanish is the failure.
+ const { app } = buildApp(db, booking, contact);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '08:00', locale: 'en' }), FAKE_ENV, FAKE_EXEC_CTX);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '10:00', locale: 'es-419' }), FAKE_ENV, FAKE_EXEC_CTX);
+
+ const { eq, and } = await import('drizzle-orm');
+ const rows = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.type, 'client'))).all();
+ expect(rows.length).toBe(1);
+ expect(rows[0].locale).toBe('es-419');
+ });
+
+ // 4. Contact-upsert failure → booking still succeeds (200), inspection row
// exists with NO primary client linked (inspection_people write never
// ran since there is no contact id to link), and a warn was logged.
it('does not fail the booking when contact upsert throws (non-fatal)', async () => {
From 58cf9e4069c00413a000bb3a4e1963524a84f81b Mon Sep 17 00:00:00 2001
From: important-new
Date: Mon, 3 Aug 2026 23:48:57 +0800
Subject: [PATCH 006/111] feat(contacts): let staff set and correct a contact's
language
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Collection so far only happens where the client speaks for themselves, on
the public booking form. That leaves no way to act on the commonest
correction of all — the client said it on the phone, or picked wrong — and
no way to undo a mistake once it is stored. The contact form is where a
record gets fixed, so this is where the field belongs.
Three states, same as the booking form: not set / English / Espanol. "Not
set" is an option here rather than merely the initial state, because a
correction path that cannot get back to "no stated preference" is not a
correction path; it is first, and nothing is pre-selected, because a
pre-selected English would turn every contact anyone ever edits into a
stated preference and a stated preference is the only thing the column is
evidence of.
The control is the shape the profile locale picker already has — a select
whose first option is the empty one — over the option labels the booking
form and the settings pickers share, and over the same list of tags the
server accepts. One vocabulary for one choice.
Server side, the thing to get right is what an update MEANS by silence:
- UpdateContactSchema is CreateContactSchema.partial(), and zod's
.partial() KEEPS a .default(). A defaulted field therefore arrives on
every request whether the caller sent it or not, and the handler writes
it. `locale` carries no default for exactly that reason, and the spec
asserts the ABSENCE OF THE KEY in what reaches the service rather than
its value — a default of null would pass a value check and still be a
silent overwrite of a real choice.
- An explicit null must still clear it, so the handler tests `'locale' in
raw` rather than `!== undefined`, as it already does for every other
nullable field.
- The service reduces whatever it is handed through the resolver's own
normalizer before writing, so es-MX lands on es-419 and a language we
have no messages for is stored as NULL. Every stored value is one
resolveContactLocale would hand back; anything else is a promise broken
at send time.
The API keeps taking a free BCP-47 tag rather than an enum, matching the
booking payload.
---
app/components/contacts/ContactModal.test.tsx | 71 +++++++++
app/components/contacts/ContactModal.tsx | 27 ++++
app/components/contacts/contacts-helpers.ts | 3 +
app/lib/forms/contacts.schema.ts | 6 +
app/routes/contacts.tsx | 7 +-
messages/en/contacts.json | 2 +
server/api/contacts.ts | 11 +-
server/lib/validations/contact.schema.ts | 14 ++
server/services/contact.service.ts | 15 +-
.../contacts/contact-locale-write.spec.ts | 141 ++++++++++++++++++
10 files changed, 292 insertions(+), 5 deletions(-)
create mode 100644 app/components/contacts/ContactModal.test.tsx
create mode 100644 tests/unit/contacts/contact-locale-write.spec.ts
diff --git a/app/components/contacts/ContactModal.test.tsx b/app/components/contacts/ContactModal.test.tsx
new file mode 100644
index 000000000..0059788a3
--- /dev/null
+++ b/app/components/contacts/ContactModal.test.tsx
@@ -0,0 +1,71 @@
+// @vitest-environment happy-dom
+/**
+ * Staff have to be able to CORRECT a contact's language, which is a stronger
+ * requirement than being able to set one: the booking form can leave the choice
+ * unanswered forever, but this form is the only place a wrong answer gets
+ * undone. So "Not set" is an option here rather than merely the initial state,
+ * and it is first — a pre-selected English would turn every contact anyone ever
+ * edits into a stated preference, and a stated preference is the only thing the
+ * column is evidence of.
+ */
+import { describe, it, expect } from "vitest";
+import { render, within } from "@testing-library/react";
+import { createRoutesStub } from "react-router";
+
+import { ContactModal } from "./ContactModal";
+import type { Contact } from "./contacts-helpers";
+
+const BASE: Contact = {
+ id: "c1",
+ name: "Tomas Beck",
+ email: "tomas@example.com",
+ phone: "",
+ type: "client",
+ agency: "",
+};
+
+function renderModal(contact: Contact | null) {
+ const Stub = createRoutesStub([
+ {
+ path: "/contacts",
+ Component: () => {}} contact={contact} />,
+ },
+ ]);
+ return render( );
+}
+
+describe("ContactModal — preferred language", () => {
+ it("offers 'not set' first, so a language can be taken back off", async () => {
+ const { findByLabelText } = renderModal({ ...BASE, locale: "es-419" });
+ const select = (await findByLabelText("Preferred language")) as HTMLSelectElement;
+
+ const [first, ...rest] = Array.from(select.options);
+ expect(first.value).toBe("");
+ // Every other option carries a real tag — nothing else is a way out.
+ expect(rest.map((o) => o.value)).toEqual(["en", "es-419"]);
+ });
+
+ it("shows what the contact already asked for", async () => {
+ const { findByLabelText } = renderModal({ ...BASE, locale: "es-419" });
+ const select = (await findByLabelText("Preferred language")) as HTMLSelectElement;
+
+ expect(select.value).toBe("es-419");
+ expect(within(select).getByRole("option", { selected: true })).toHaveTextContent(
+ // Same label the booking form and the profile picker use — one
+ // vocabulary for one choice.
+ "Español (Latinoamérica)",
+ );
+ });
+
+ it("sits on 'not set' for a contact who has never said", async () => {
+ const { findByLabelText } = renderModal({ ...BASE, locale: null });
+ const select = (await findByLabelText("Preferred language")) as HTMLSelectElement;
+ expect(select.value).toBe("");
+ });
+
+ it("sits on 'not set' for a brand-new contact", async () => {
+ const { findByLabelText } = renderModal(null);
+ const select = (await findByLabelText("Preferred language")) as HTMLSelectElement;
+ expect(select.value).toBe("");
+ });
+});
diff --git a/app/components/contacts/ContactModal.tsx b/app/components/contacts/ContactModal.tsx
index 52f013a5d..c3186fc3f 100644
--- a/app/components/contacts/ContactModal.tsx
+++ b/app/components/contacts/ContactModal.tsx
@@ -3,6 +3,8 @@ import { useForm, type SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod/v4";
import { makeAddContactSchema } from "~/lib/forms/contacts.schema";
import { Modal, Button } from "@core/shared-ui";
+import { SUPPORTED_CONTACT_LOCALES } from "../../../server/lib/i18n/contact-locale";
+import { localeLabel } from "~/lib/locales";
import { m } from "~/paraglide/messages";
import type { Contact } from "./contacts-helpers";
@@ -146,6 +148,31 @@ export function ContactModal({
/>
+ {/* The language to address this person in. Staff set it as a
+ CORRECTION — the client said so on the phone, or picked wrong on
+ the booking form — so "Not set" has to be reachable again, and is
+ the first option rather than a pre-selected English: a stored
+ value is a stated preference, and only a stated preference is
+ evidence anyone wants another language.
+
+ Same three-state shape and the same option labels as the profile
+ picker in Settings, from one table (`app/lib/locales.ts`), over
+ the same list of tags the server accepts. */}
+
+ {m.contacts_field_language()}
+
+ {m.contacts_modal_language_unset_option()}
+ {SUPPORTED_CONTACT_LOCALES.map((tag) => (
+ {localeLabel(tag)}
+ ))}
+
+
+
{form.errors && (
{form.errors[0]}
diff --git a/app/components/contacts/contacts-helpers.ts b/app/components/contacts/contacts-helpers.ts
index 786380973..d1d2af87e 100644
--- a/app/components/contacts/contacts-helpers.ts
+++ b/app/components/contacts/contacts-helpers.ts
@@ -27,6 +27,9 @@ export interface Contact {
phone: string;
type: string;
agency: string;
+ /** BCP-47 tag the contact asked to be addressed in; null/absent means they
+ * have not said, which is NOT the same as English. */
+ locale?: string | null;
inspectionCount?: number;
referralCount?: number;
}
diff --git a/app/lib/forms/contacts.schema.ts b/app/lib/forms/contacts.schema.ts
index 5bfed7a00..cf65f0c70 100644
--- a/app/lib/forms/contacts.schema.ts
+++ b/app/lib/forms/contacts.schema.ts
@@ -19,6 +19,8 @@ import { m } from "~/paraglide/messages";
* - email — optional; empty string coerced to undefined so the API receives null
* - phone — optional free-text (tel input)
* - agency — optional free-text
+ * - locale — optional; "" is the real "not set" state, kept as-is here and
+ * turned into an explicit null by the action
*/
export function makeAddContactSchema() {
return z.object({
@@ -31,5 +33,9 @@ export function makeAddContactSchema() {
.or(z.literal("").transform(() => undefined)),
phone: z.string().optional(),
agency: z.string().optional(),
+ // No `.default()`, deliberately: "" and absent both have to survive to the
+ // action so it can send an explicit null. A default here would make every
+ // save look like a stated preference.
+ locale: z.string().optional(),
});
}
diff --git a/app/routes/contacts.tsx b/app/routes/contacts.tsx
index 95580c2f5..558fb408a 100644
--- a/app/routes/contacts.tsx
+++ b/app/routes/contacts.tsx
@@ -74,13 +74,18 @@ export async function action({ request, context }: Route.ActionArgs) {
if (submission.status !== "success") {
return submission.reply();
}
- const { type, name, email, phone, agency } = submission.value;
+ const { type, name, email, phone, agency, locale } = submission.value;
const body = {
name,
email: email ?? null,
phone: phone || null,
agency: agency || null,
type,
+ // The modal always renders the whole record, so an empty selection here
+ // is a deliberate "not set" and has to travel as an explicit null —
+ // omitting the key would leave a stored preference in place and make the
+ // control look broken. The API only clears when the key is present.
+ locale: locale || null,
};
const res = id
? await api.contacts[":id"].$put({ param: { id }, json: body })
diff --git a/messages/en/contacts.json b/messages/en/contacts.json
index 6a72e5bf9..f9d8324d8 100644
--- a/messages/en/contacts.json
+++ b/messages/en/contacts.json
@@ -22,7 +22,9 @@
"contacts_modal_email_placeholder": "jane@realty.com",
"contacts_modal_phone_placeholder": "(555) 123-4567",
"contacts_modal_agency_placeholder": "Sunrise Realty",
+ "contacts_modal_language_unset_option": "Not set",
"contacts_field_email": "Email",
+ "contacts_field_language": "Preferred language",
"contacts_field_phone": "Phone",
"contacts_field_agency": "Agency",
"contacts_field_notes": "Notes",
diff --git a/server/api/contacts.ts b/server/api/contacts.ts
index ca72359e1..98c747bd6 100644
--- a/server/api/contacts.ts
+++ b/server/api/contacts.ts
@@ -251,14 +251,21 @@ const contactRoutes = createApiRouter()
const tenantId = c.get('tenantId');
const { id } = c.req.valid('param');
const raw = c.req.valid('json');
- // Strip undefined keys to satisfy exactOptionalPropertyTypes
- const data: Partial<{ type: ContactType; name: string; email: string | null; phone: string | null; agency: string | null; notes: string | null }> = {};
+ // Strip undefined keys to satisfy exactOptionalPropertyTypes.
+ //
+ // `'x' in raw` rather than `raw.x !== undefined` for every nullable
+ // field: an explicit null is how a caller CLEARS one, and testing for
+ // undefined would silently discard the clear. `locale` needs that most
+ // — putting a contact back to "no stated preference" is the whole
+ // correction path.
+ const data: Partial<{ type: ContactType; name: string; email: string | null; phone: string | null; agency: string | null; notes: string | null; locale: string | null }> = {};
if (raw.type !== undefined) data.type = raw.type;
if (raw.name !== undefined) data.name = raw.name;
if ('email' in raw) data.email = raw.email ?? null;
if ('phone' in raw) data.phone = raw.phone ?? null;
if ('agency' in raw) data.agency = raw.agency ?? null;
if ('notes' in raw) data.notes = raw.notes ?? null;
+ if ('locale' in raw) data.locale = raw.locale ?? null;
const contact = await c.var.services.contact.updateContact(id as string, tenantId, data);
if (c.env.QBO_CLIENT_ID) {
c.executionCtx.waitUntil(
diff --git a/server/lib/validations/contact.schema.ts b/server/lib/validations/contact.schema.ts
index 6a6cbf634..298ad930f 100644
--- a/server/lib/validations/contact.schema.ts
+++ b/server/lib/validations/contact.schema.ts
@@ -8,6 +8,19 @@ export const CreateContactSchema = z.object({
phone: z.string().max(30).optional().nullable().openapi({ example: '(555) 987-6543' }).describe('TODO describe phone field for the OpenInspection MCP integration'),
agency: z.string().max(100).optional().nullable().openapi({ example: 'Sunrise Realty' }).describe('TODO describe agency field for the OpenInspection MCP integration'),
notes: z.string().max(500).optional().nullable().describe('TODO describe notes field for the OpenInspection MCP integration'),
+ // The language this contact asked to be addressed in. NULL is an ABSENCE
+ // of a stated preference, never English — see `contacts.locale` in the
+ // schema for why that distinction is the point of the column.
+ //
+ // Deliberately NO `.default()`. `UpdateContactSchema` below is
+ // `.partial()`, and `.partial()` KEEPS a default: a PATCH that never
+ // mentions `locale` would then arrive carrying one, and the handler would
+ // write it over a stored choice. Nullable so staff can put a contact back
+ // to "not set" — a correction path needs a way back.
+ //
+ // A free BCP-47 tag rather than an enum, matching the booking payload; the
+ // service reduces it to a locale we have messages for, or to NULL.
+ locale: z.string().trim().min(2).max(35).optional().nullable().openapi({ example: 'es-419' }).describe("Contact's preferred language as a BCP-47 tag; reduced server-side to a supported locale, or stored as null when unsupported."),
}).openapi('CreateContact');
export const UpdateContactSchema = CreateContactSchema.partial().openapi('UpdateContact');
@@ -21,6 +34,7 @@ export const ContactResponseSchema = z.object({
phone: z.string().nullable().describe('TODO describe phone field for the OpenInspection MCP integration'),
agency: z.string().nullable().describe('TODO describe agency field for the OpenInspection MCP integration'),
notes: z.string().nullable().describe('TODO describe notes field for the OpenInspection MCP integration'),
+ locale: z.string().nullable().describe("Contact's stated language preference (BCP-47), or null when they have not said."),
createdAt: z.string().describe('TODO describe createdAt field for the OpenInspection MCP integration'),
inspectionCount: z.number().optional().describe('TODO describe inspectionCount field for the OpenInspection MCP integration'),
referralCount: z.number().optional().describe('Inspections where this contact is the tenant buyer_agent (referrals sent).'),
diff --git a/server/services/contact.service.ts b/server/services/contact.service.ts
index 99f7dc362..1bd676625 100644
--- a/server/services/contact.service.ts
+++ b/server/services/contact.service.ts
@@ -6,6 +6,7 @@ import { Errors } from '../lib/errors';
import { buildContactDetail } from './contact-detail';
import { escapeLikePattern } from '../lib/db/like-escape';
import { safeISODate } from '../lib/date';
+import { normalizeLocale } from '../lib/i18n/contact-locale';
import { tenantConfigs } from '../lib/db/schema';
import { logger } from '../lib/logger';
@@ -86,13 +87,18 @@ export class ContactService {
return buildContactDetail(this.getDrizzle(), id, tenantId);
}
- async createContact(tenantId: string, data: { type: ContactType; name: string; email?: string | null | undefined; phone?: string | null | undefined; agency?: string | null | undefined; notes?: string | null | undefined; createdByUserId?: string | null | undefined }) {
+ async createContact(tenantId: string, data: { type: ContactType; name: string; email?: string | null | undefined; phone?: string | null | undefined; agency?: string | null | undefined; notes?: string | null | undefined; locale?: string | null | undefined; createdByUserId?: string | null | undefined }) {
const db = this.getDrizzle();
const normalized = {
email: data.email ?? null,
phone: data.phone ?? null,
agency: data.agency ?? null,
notes: data.notes ?? null,
+ // Reduced to a locale the catalogue actually covers, so what is
+ // stored is always something resolveContactLocale would hand back;
+ // anything else becomes NULL rather than a promise broken at send
+ // time. Same reduction the booking path applies.
+ locale: normalizeLocale(data.locale),
// A1 auto-link uses this to populate agent_tenant_links.invited_by_user_id
// when the agent later signs up with the same email — keeps the
// /agent-inspectors card pointing at the actual inviting inspector.
@@ -103,7 +109,7 @@ export class ContactService {
return { ...row, createdAt: safeISODate(row.createdAt), inspectionCount: 0 };
}
- async updateContact(id: string, tenantId: string, data: Partial<{ type: ContactType; name: string; email: string | null; phone: string | null; agency: string | null; notes: string | null }>) {
+ async updateContact(id: string, tenantId: string, data: Partial<{ type: ContactType; name: string; email: string | null; phone: string | null; agency: string | null; notes: string | null; locale: string | null }>) {
const db = this.getDrizzle();
const existing = await db.select().from(contacts).where(and(eq(contacts.id, id), eq(contacts.tenantId, tenantId))).get();
if (!existing) throw Errors.NotFound('Contact not found');
@@ -115,6 +121,11 @@ export class ContactService {
// change an agent's email, archive the record and add a new one.
const patch = { ...data };
if (existing.type === 'agent' && 'email' in patch) delete patch.email;
+ // Only when the caller actually said something about it: an absent key
+ // must leave a stored preference alone, while an explicit null is a
+ // correction back to "not stated". Normalizing on the way in keeps the
+ // column's contract — every stored value is one the resolver returns.
+ if ('locale' in patch) patch.locale = normalizeLocale(patch.locale);
await db.update(contacts).set(patch).where(and(eq(contacts.id, id), eq(contacts.tenantId, tenantId)));
return { ...existing, ...patch, createdAt: safeISODate(existing.createdAt) };
}
diff --git a/tests/unit/contacts/contact-locale-write.spec.ts b/tests/unit/contacts/contact-locale-write.spec.ts
new file mode 100644
index 000000000..4fb94f4fc
--- /dev/null
+++ b/tests/unit/contacts/contact-locale-write.spec.ts
@@ -0,0 +1,141 @@
+/**
+ * Staff can set a contact's language, and can take it back off.
+ *
+ * Two things have to hold at once, and they pull in opposite directions:
+ *
+ * - An update that never MENTIONS `locale` must leave a stored preference
+ * alone. `UpdateContactSchema` is `CreateContactSchema.partial()`, and zod's
+ * `.partial()` KEEPS a `.default()` — so a field with a default arrives on
+ * every request whether or not the caller sent it, and the handler writes it
+ * over whatever was there. This repo has lost label data exactly that way.
+ * The assertion below is therefore on the ABSENCE OF THE KEY in the object
+ * handed to the service, not on its value: a default of `null` would pass a
+ * value check and still be a silent overwrite of a real choice.
+ *
+ * - An explicit `null` must clear it. Staff set this as a correction — the
+ * client said so on the phone, or mis-clicked on the booking form — and a
+ * correction path that cannot get back to "not stated" is not a correction
+ * path. That is why the handler tests `'locale' in raw` rather than
+ * `!== undefined`.
+ *
+ * And whatever is stored has to be a locale `resolveContactLocale` would hand
+ * back, or NULL. A stored `fr-FR` is a promise broken at send time.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import contactRoutes from '../../../server/api/contacts';
+import { ContactService } from '../../../server/services/contact.service';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import type { HonoConfig } from '../../../server/types/hono';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const CONTACT = '00000000-0000-0000-0000-0000000000c1';
+
+function contactsApp(services: Record
) {
+ const app = new OpenAPIHono();
+ app.use('*', async (c, next) => {
+ c.set('userRole', 'owner');
+ c.set('user', { sub: 'u1' } as never);
+ c.set('tenantId', TENANT);
+ c.set('services', services as never);
+ await next();
+ });
+ app.route('/api/contacts', contactRoutes);
+ return app;
+}
+
+async function put(updateContact: ReturnType, body: unknown) {
+ return contactsApp({ contact: { updateContact } }).request(
+ `/api/contacts/${CONTACT}`,
+ { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
+ {},
+ );
+}
+
+describe('PUT /api/contacts/{id} — what reaches the service', () => {
+ it('omits locale entirely when the caller never sent it', async () => {
+ const updateContact = vi.fn().mockResolvedValue({ id: CONTACT });
+ const res = await put(updateContact, { name: 'Jane' });
+
+ expect(res.status).toBe(200);
+ const data = updateContact.mock.calls[0][2] as Record;
+ // The key, not the value. A schema default would put `locale` here
+ // holding something plausible, and the write would land.
+ expect(Object.hasOwn(data, 'locale')).toBe(false);
+ });
+
+ it('passes a chosen locale through', async () => {
+ const updateContact = vi.fn().mockResolvedValue({ id: CONTACT });
+ const res = await put(updateContact, { name: 'Jane', locale: 'es-419' });
+
+ expect(res.status).toBe(200);
+ expect(updateContact.mock.calls[0][2]).toMatchObject({ locale: 'es-419' });
+ });
+
+ it('passes an explicit null through, so "not set" is reachable again', async () => {
+ const updateContact = vi.fn().mockResolvedValue({ id: CONTACT });
+ const res = await put(updateContact, { name: 'Jane', locale: null });
+
+ expect(res.status).toBe(200);
+ const data = updateContact.mock.calls[0][2] as Record;
+ expect(Object.hasOwn(data, 'locale')).toBe(true);
+ expect(data.locale).toBeNull();
+ });
+});
+
+describe('ContactService locale writes', () => {
+ let svc: ContactService;
+ let testDb: BetterSQLite3Database;
+
+ const storedLocale = async () =>
+ (await testDb.select().from(schema.contacts).all())[0]?.locale ?? null;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ testDb = fixture.db;
+ await setupSchema(fixture.sqlite);
+ await testDb.insert(schema.tenants).values({
+ id: TENANT, name: 'A', slug: 'a', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await testDb.insert(schema.contacts).values({
+ id: CONTACT, tenantId: TENANT, type: 'client', name: 'Jane',
+ email: 'jane@test.com', locale: 'es-419', createdAt: new Date(),
+ });
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb);
+ svc = new ContactService({} as D1Database);
+ });
+
+ it('leaves a stored preference alone when the patch does not mention it', async () => {
+ await svc.updateContact(CONTACT, TENANT, { name: 'Jane Smith' });
+ expect(await storedLocale()).toBe('es-419');
+ });
+
+ it('clears it on an explicit null', async () => {
+ await svc.updateContact(CONTACT, TENANT, { locale: null });
+ expect(await storedLocale()).toBeNull();
+ });
+
+ it('reduces a regional variant to the catalogue we have', async () => {
+ await svc.updateContact(CONTACT, TENANT, { locale: 'es-MX' });
+ expect(await storedLocale()).toBe('es-419');
+ });
+
+ it('stores null rather than a language we cannot speak', async () => {
+ await svc.updateContact(CONTACT, TENANT, { locale: 'fr-FR' });
+ expect(await storedLocale()).toBeNull();
+ });
+
+ it('normalizes on create too', async () => {
+ const created = await svc.createContact(TENANT, { type: 'client', name: 'Bob', locale: 'es-MX' });
+ expect(created.locale).toBe('es-419');
+
+ const unset = await svc.createContact(TENANT, { type: 'client', name: 'Ann' });
+ expect(unset.locale).toBeNull();
+ });
+});
From f2b561f97e6a14cc17ab873be0158119a2aba50a Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 00:10:55 +0800
Subject: [PATCH 007/111] docs(i18n): make the contact-language demand signal
readable, and honest
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`contacts.locale` exists to decide whether the rest of the multilingual work is
worth building, so the deciding number needs a definition someone can look up —
and one that cannot be quoted without what it cannot see.
docs/developers/multilingual-demand-signal.md fixes the decision rule BEFORE
there is data (a threshold chosen afterwards is not a threshold) and pins the
denominator, which the rule alone left open. It gives four queries rather than
one: the composition of stated languages, the answer rate that qualifies it,
the size of the blind spot, and a seed-data check — identical per-tenant counts
and a single created_at day are fixtures, not demand.
The blind spot is structural. The question is only asked where a client speaks
for themselves; the agent-on-behalf booking path carries no locale field, and
that is deliberate, because an agent's guess recorded as a client's stated
preference is exactly what would poison this measurement. Every such client
therefore sits in '(not stated)' whatever they speak, so the signal undercounts
by however much of a deployment's volume is agent-booked. Query C measures the
floor of that gap, and the caveat is written inside every SQL block so a
copy-paste cannot leave it behind.
tests/unit/contacts/demand-signal-queries.spec.ts executes each block in the
doc against a database built from the real migrations and asserts the counts on
an adversarial fixture: an archived Spanish speaker, an agent contact with a
locale, a stated `en` that is a choice and a NULL that is an absence. A renamed
column now breaks a test instead of the monthly report, and a query that loses
its undercount comment fails the suite.
---
docs/developers/multilingual-demand-signal.md | 169 +++++++++++++++++
docs/getting-started.md | 1 +
.../contacts/demand-signal-queries.spec.ts | 174 ++++++++++++++++++
3 files changed, 344 insertions(+)
create mode 100644 docs/developers/multilingual-demand-signal.md
create mode 100644 tests/unit/contacts/demand-signal-queries.spec.ts
diff --git a/docs/developers/multilingual-demand-signal.md b/docs/developers/multilingual-demand-signal.md
new file mode 100644
index 000000000..9a39d719f
--- /dev/null
+++ b/docs/developers/multilingual-demand-signal.md
@@ -0,0 +1,169 @@
+# Multilingual demand signal
+
+`contacts.locale` exists to answer one question: **do clients actually ask to be
+addressed in another language, and how often?** Everything else the column
+enables — notification rendering, report courtesy translation — is only worth
+building if the answer is yes. So the column has to be readable as a number, by
+someone who was not there when it was added.
+
+This note is that number's definition. It is written *before* there is data, on
+purpose: a threshold picked after seeing the result is not a threshold.
+
+## What a value means
+
+`locale` is nullable and BCP-47, reduced to a language the message catalogue
+actually covers (`server/lib/i18n/contact-locale.ts`).
+
+- **A stored value is a choice.** Nothing is pre-selected on the booking form,
+ so even a stored `en` means somebody was asked and answered "English".
+- **NULL is an absence, not English.** It never means "prefers English"; it
+ means no preference was recorded, for any of several reasons — see
+ [What the number cannot see](#what-the-number-cannot-see).
+
+That asymmetry is the whole reason the signal works, and it is also the reason
+`(not stated)` must never be folded into the English bucket when the number is
+quoted.
+
+## The decision rule
+
+Fixed in advance, so the data cannot move it:
+
+> **If fewer than ~2% of live client contacts have stated a non-English
+> preference after two full months of collection, report courtesy-translation
+> work is not justified.**
+
+The denominator is **all live client contacts**, not just the ones who answered.
+That makes the test deliberately one-sided:
+
+- Crossing 2% is **sufficient** evidence of demand — the never-asked rows only
+ dilute the ratio, so a number that clears the bar clears it despite them.
+- Failing to cross 2% is **not** proof of absence while the answer rate
+ (query B) is low. It means either there is no demand or nobody was asked, and
+ those two are not distinguishable from this column alone.
+
+Say which of the two you are looking at when you report the result.
+
+## The queries
+
+Run all four together. Query A on its own is a percentage with no error bar,
+which is worse than no number at all.
+
+### A. Composition — what was stated
+
+```sql
+-- Query A — multilingual demand signal, by stated language. Run monthly.
+-- Undercount: only clients who booked themselves are ever asked this
+-- question; agent-placed bookings store no language at all and land in
+-- '(not stated)'. See docs/developers/multilingual-demand-signal.md.
+SELECT COALESCE(locale, '(not stated)') AS stated_language,
+ COUNT(*) AS contacts
+FROM contacts
+WHERE archived_at IS NULL
+ AND type = 'client'
+GROUP BY 1
+ORDER BY contacts DESC;
+```
+
+### B. Answer rate — how much of the book was ever asked
+
+```sql
+-- Query B — how much of the client book has an answer at all.
+-- Undercount: agent-placed bookings never offer the question, so a low
+-- `stated` here is partly a collection gap, not only a preference for English.
+SELECT COUNT(*) AS live_clients,
+ SUM(CASE WHEN locale IS NOT NULL THEN 1 ELSE 0 END) AS stated,
+ SUM(CASE WHEN locale IS NOT NULL AND locale NOT LIKE 'en%'
+ THEN 1 ELSE 0 END) AS stated_non_english
+FROM contacts
+WHERE archived_at IS NULL
+ AND type = 'client';
+```
+
+`stated_non_english / live_clients` is the ratio the decision rule tests.
+`stated / live_clients` is how much you should trust it.
+
+### C. The blind spot, measured
+
+```sql
+-- Query C — clients whose booking was placed for them, who therefore were
+-- never asked.
+-- Undercount: this is the measurable floor of it. Every row here sits in
+-- '(not stated)' in query A whatever the client actually speaks.
+SELECT COUNT(DISTINCT ip.contact_id) AS agent_booked_clients
+FROM inspection_people ip
+JOIN inspections i
+ ON i.id = ip.inspection_id AND i.tenant_id = ip.tenant_id
+JOIN contact_role_profiles crp
+ ON crp.id = ip.role_profile_id AND crp.tenant_id = ip.tenant_id
+JOIN contacts c
+ ON c.id = ip.contact_id AND c.tenant_id = ip.tenant_id
+WHERE i.concierge_status IS NOT NULL
+ AND crp.kind = 'client'
+ AND c.archived_at IS NULL;
+```
+
+A floor, not the whole gap: staff-created and imported contacts were not asked
+either, and they are not identifiable in SQL.
+
+### D. Is this usage, or is it fixtures?
+
+```sql
+-- Query D — seed-data check. Identical counts across tenants, or every row
+-- created inside one day, means demo fixtures rather than clients.
+-- Undercount: the same collection gap applies per tenant; read with query C.
+SELECT tenant_id,
+ COUNT(*) AS live_clients,
+ SUM(CASE WHEN locale IS NOT NULL THEN 1 ELSE 0 END) AS stated,
+ MIN(created_at) AS first_created_ms,
+ MAX(created_at) AS last_created_ms,
+ COUNT(DISTINCT created_at / 86400000) AS distinct_days
+FROM contacts
+WHERE archived_at IS NULL
+ AND type = 'client'
+GROUP BY tenant_id
+ORDER BY live_clients DESC;
+```
+
+Seeded rows are not demand. Equal `live_clients` across every tenant, or
+`distinct_days = 1`, means you are reading a fixture set — exclude those tenants
+before quoting anything from A or B.
+
+## What the number cannot see
+
+**The language question is only asked where the client speaks for themselves.**
+It is on the public booking surfaces, and on the staff contact form as a
+correction. It is deliberately **absent from the agent-on-behalf booking flow**,
+whose request carries no `locale` field at all.
+
+That absence is a choice, not an oversight: an agent's *guess* recorded as a
+client's *stated* preference would corrupt exactly the measurement this column
+exists to produce. The cost of that choice is that the signal **undercounts by
+however much of a deployment's volume is booked by agents** — query C sizes it.
+
+So `(not stated)` is at least three populations mixed together:
+
+| In `(not stated)` | Asked? |
+|---|---|
+| Booked themselves, skipped the question | yes — a real "no preference" |
+| Booked by an agent | no — the form has no such field (query C counts these) |
+| Created by staff, or imported | no — unless staff filled it in |
+
+Never report `(not stated)` as "prefers English", and never quote A's percentage
+without B's answer rate and C's blind spot beside it.
+
+## Running it
+
+```bash
+# Local D1
+npx wrangler d1 execute --local \
+ --command "SELECT COALESCE(locale,'(not stated)') AS stated_language, COUNT(*) AS contacts FROM contacts WHERE archived_at IS NULL AND type='client' GROUP BY 1 ORDER BY contacts DESC"
+```
+
+Add `--remote` instead of `--local` to read production; these are all read-only
+`SELECT`s. Right after the column ships the expected result is a single
+`(not stated)` row — that is the correct baseline, and confirms the query runs
+before anyone needs the answer.
+
+Every SQL block above is executed against the real migrated schema by
+`tests/unit/contacts/demand-signal-queries.spec.ts`, so a column rename breaks
+the test rather than the monthly report.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 436aacf67..312b021c7 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -120,6 +120,7 @@ Browser → single Worker (Hono entry):
| [`04_database_schema.md`](developers/04_database_schema.md) | D1 schema overview |
| [`05_testing.md`](developers/05_testing.md) | E2E and unit test guide |
| [`06_inspection_workflow.md`](developers/06_inspection_workflow.md) | Inspection engine internals |
+| [`multilingual-demand-signal.md`](developers/multilingual-demand-signal.md) | Reading `contacts.locale` as a number, and what it cannot see |
---
diff --git a/tests/unit/contacts/demand-signal-queries.spec.ts b/tests/unit/contacts/demand-signal-queries.spec.ts
new file mode 100644
index 000000000..a73b496ef
--- /dev/null
+++ b/tests/unit/contacts/demand-signal-queries.spec.ts
@@ -0,0 +1,174 @@
+/**
+ * The multilingual demand signal is a NUMBER SOMEONE DECIDES ON, so the SQL
+ * that produces it is treated as code: it lives in
+ * `docs/developers/multilingual-demand-signal.md`, and this spec runs every
+ * query in that document against a database built from the real migrations.
+ *
+ * Two failures are worth catching mechanically, and neither one is visible to
+ * a reader of the doc:
+ *
+ * 1. **Drift.** A renamed column leaves the queries syntactically fine and
+ * operationally dead — discovered on the day someone needs the answer,
+ * which is the one day it cannot be fixed retroactively. Executing the
+ * blocks here turns that into a red test at rename time. (Repo convention:
+ * a "must stay in sync" comment becomes an assertion, not prose.)
+ *
+ * 2. **A number quoted without its caveat.** The question is only asked where
+ * a client speaks for themselves; the agent-on-behalf booking path carries
+ * no `locale` at all, so those clients sit in `(not stated)` whatever they
+ * speak. A bare percentage is therefore a lie, and the caveat has to travel
+ * with the SQL rather than sit two screens above it — anything copied out
+ * of the doc must carry it. Hence the `Undercount:` assertion below.
+ *
+ * The fixture is deliberately adversarial about the buckets that get conflated:
+ * an archived Spanish speaker, an agent contact with a locale, a stated `en`
+ * (a choice, not an absence) and a NULL (an absence, not English).
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { readFileSync } from 'node:fs';
+import * as path from 'node:path';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+
+const DOC = path.resolve(
+ __dirname,
+ '../../../docs/developers/multilingual-demand-signal.md',
+);
+
+/** Every fenced ```sql block in the doc, keyed by its `-- Query X` marker. */
+function sqlBlocks(): Map {
+ const markdown = readFileSync(DOC, 'utf8');
+ const blocks = new Map();
+ for (const match of markdown.matchAll(/```sql\n([\s\S]*?)```/g)) {
+ const sql = match[1];
+ const label = /--\s*Query\s+([A-Z])\b/.exec(sql)?.[1];
+ expect(label, `every SQL block must open with a "-- Query X" marker:\n${sql}`).toBeTruthy();
+ blocks.set(label as string, sql);
+ }
+ return blocks;
+}
+
+const T1 = '00000000-0000-0000-0000-000000000a01';
+const T2 = '00000000-0000-0000-0000-000000000a02';
+const CLIENT_ROLE = 'role-client';
+const AGENT_ROLE = 'role-agent';
+
+let sqlite: ReturnType['sqlite'];
+let blocks: Map;
+
+/** Run a doc query and return its rows. */
+function run(label: string): Record[] {
+ const sql = blocks.get(label);
+ if (!sql) throw new Error(`doc has no "-- Query ${label}" block`);
+ return sqlite.prepare(sql).all() as Record[];
+}
+
+beforeAll(async () => {
+ const fixture = createTestDb();
+ sqlite = fixture.sqlite;
+ await setupSchema(sqlite);
+ blocks = sqlBlocks();
+
+ const db = fixture.db;
+ const day = 86_400_000;
+ const now = new Date('2026-08-01T12:00:00Z').getTime();
+
+ await db.insert(schema.tenants).values([
+ { id: T1, name: 'Tenant One', slug: 't1', createdAt: new Date(now) },
+ { id: T2, name: 'Tenant Two', slug: 't2', createdAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.contacts).values([
+ // Tenant 1 — the population the decision rule is about.
+ { id: 'c-es', tenantId: T1, type: 'client', name: 'Stated Spanish', locale: 'es-419', createdAt: new Date(now) },
+ { id: 'c-en', tenantId: T1, type: 'client', name: 'Stated English', locale: 'en', createdAt: new Date(now + day) },
+ { id: 'c-null', tenantId: T1, type: 'client', name: 'Never Asked', locale: null, createdAt: new Date(now + 2 * day) },
+ // Archived: retired, and must not inflate a live count.
+ { id: 'c-gone', tenantId: T1, type: 'client', name: 'Archived Spanish', locale: 'es-419', createdAt: new Date(now), archivedAt: new Date(now + day) },
+ // An AGENT with a stated locale: real data, but not client demand.
+ { id: 'c-agent', tenantId: T1, type: 'agent', name: 'Agent With Locale', locale: 'es-419', createdAt: new Date(now) },
+ // Tenant 2 — proves the per-tenant breakdown does not merge tenants.
+ { id: 'c-t2', tenantId: T2, type: 'client', name: 'Other Tenant', locale: null, createdAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.contactRoleProfiles).values([
+ { id: CLIENT_ROLE, tenantId: T1, key: 'client', label: 'Client', kind: 'client', createdAt: new Date(now), updatedAt: new Date(now) },
+ { id: AGENT_ROLE, tenantId: T1, key: 'buyer_agent', label: "Buyer's Agent", kind: 'agent', createdAt: new Date(now), updatedAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.inspections).values([
+ // Placed BY an agent for the client: concierge_status is set.
+ { id: 'i-concierge', tenantId: T1, propertyAddress: '1 Agent Way', date: '2026-08-10', createdAt: new Date(now), conciergeStatus: 'awaiting_client' },
+ // Booked by the client themselves: no concierge status.
+ { id: 'i-self', tenantId: T1, propertyAddress: '2 Self Street', date: '2026-08-11', createdAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.inspectionPeople).values([
+ { id: 'p1', tenantId: T1, inspectionId: 'i-concierge', contactId: 'c-null', roleProfileId: CLIENT_ROLE, createdAt: new Date(now) },
+ // The referring agent on the same concierge inspection: not a client,
+ // so it must not be counted as one who was never asked.
+ { id: 'p2', tenantId: T1, inspectionId: 'i-concierge', contactId: 'c-agent', roleProfileId: AGENT_ROLE, createdAt: new Date(now) },
+ { id: 'p3', tenantId: T1, inspectionId: 'i-self', contactId: 'c-es', roleProfileId: CLIENT_ROLE, createdAt: new Date(now) },
+ ]);
+});
+
+describe('multilingual demand signal — the documented queries', () => {
+ it('publishes at least the four labelled queries', () => {
+ expect([...blocks.keys()].sort()).toEqual(['A', 'B', 'C', 'D']);
+ });
+
+ it('carries the agent-booked undercount inside every query, so a copy-paste cannot lose it', () => {
+ for (const [label, sql] of blocks) {
+ expect(sql, `Query ${label} must state the undercount in its own comment header`)
+ .toMatch(/Undercount:/);
+ }
+ });
+
+ it('A: counts stated languages, keeping "(not stated)" out of the English bucket', () => {
+ // Archived and non-client rows are excluded; a stated `en` stands on
+ // its own because it is an answer, not an absence. Both tenants are in
+ // scope — this is the operator's whole-database view.
+ // Compared as a set: `en` and `es-419` tie on count here, and SQLite
+ // does not promise an order between tied rows.
+ const byLanguage = Object.fromEntries(
+ run('A').map((row) => [row.stated_language, row.contacts]),
+ );
+ expect(byLanguage).toEqual({ '(not stated)': 2, 'es-419': 1, en: 1 });
+ });
+
+ it('B: reports the ratio the decision rule tests, and the answer rate that qualifies it', () => {
+ // live_clients spans both tenants: this is the operator's whole-database
+ // view, and query D is the per-tenant split.
+ expect(run('B')).toEqual([
+ { live_clients: 4, stated: 2, stated_non_english: 1 },
+ ]);
+ });
+
+ it('C: sizes the blind spot — clients booked for them, never asked', () => {
+ // c-null only. c-es was on a self-booked inspection; c-agent is on the
+ // concierge one but is not a client.
+ expect(run('C')).toEqual([{ agent_booked_clients: 1 }]);
+ });
+
+ it('D: separates tenants, and exposes the created_at clustering that marks seed data', () => {
+ const rows = run('D');
+ expect(rows).toEqual([
+ {
+ tenant_id: T1,
+ live_clients: 3,
+ stated: 2,
+ first_created_ms: expect.any(Number),
+ last_created_ms: expect.any(Number),
+ distinct_days: 3,
+ },
+ {
+ tenant_id: T2,
+ live_clients: 1,
+ stated: 0,
+ first_created_ms: expect.any(Number),
+ last_created_ms: expect.any(Number),
+ distinct_days: 1,
+ },
+ ]);
+ });
+});
From d912f4672cfc7161c208473cc381a14f52b16e89 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 00:26:28 +0800
Subject: [PATCH 008/111] fix(invoices): stop discarding the balance QuickBooks
sends
qbo/invoice-sync.ts already computes the partial-payment branch and passes the
balance, and all three adapters dropped it as _balance because markPartial had
nowhere to put it. The 'partial' status is derived, rendered in the client hub
and the report-lock notice, and until now could not say what was still owed.
Stores the amount PAID rather than QuickBooks' remaining Balance: our
amountCents is the authoritative total, so remaining is derived against it and
does not drift when either side edits the invoice. markPaid and markRefunded
both clear it, so the amount can never contradict the derived status.
The dollar-to-cent conversion happens once, in applyInvoiceStatusFromQBO where
the QBO shape is still in view, so MarkPartialFn now carries cents already paid
and no adapter repeats the arithmetic. Each side is rounded to its own exact
cent before subtracting -- truncating the float difference loses a cent on
ordinary amounts (a $100 invoice with $18.15 owed yields 8184.999999999999).
Migration 0032 is a plain ADD COLUMN; invoices is FK-referenced, so the column
goes at the end of the table definition to keep drizzle from rebuilding it.
Refs #273.
---
migrations/0032_free_matthew_murdock.sql | 1 +
migrations/meta/0032_snapshot.json | 10309 ++++++++++++++++++
migrations/meta/_journal.json | 7 +
server/api/qbo-webhook.ts | 2 +-
server/api/qbo.ts | 2 +-
server/lib/db/schema/invoice.ts | 12 +
server/scheduled.ts | 2 +-
server/services/invoice.service.ts | 24 +-
server/services/qbo/api-base.ts | 8 +-
server/services/qbo/invoice-sync.ts | 9 +-
tests/unit/invoices/partial-balance.spec.ts | 169 +
11 files changed, 10536 insertions(+), 9 deletions(-)
create mode 100644 migrations/0032_free_matthew_murdock.sql
create mode 100644 migrations/meta/0032_snapshot.json
create mode 100644 tests/unit/invoices/partial-balance.spec.ts
diff --git a/migrations/0032_free_matthew_murdock.sql b/migrations/0032_free_matthew_murdock.sql
new file mode 100644
index 000000000..1deef7791
--- /dev/null
+++ b/migrations/0032_free_matthew_murdock.sql
@@ -0,0 +1 @@
+ALTER TABLE `invoices` ADD `amount_paid_cents` integer;
\ No newline at end of file
diff --git a/migrations/meta/0032_snapshot.json b/migrations/meta/0032_snapshot.json
new file mode 100644
index 000000000..38e7fad75
--- /dev/null
+++ b/migrations/meta/0032_snapshot.json
@@ -0,0 +1,10309 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "9c0a6763-baa0-4523-bf75-d5b2854fca74",
+ "prevId": "904b8784-fa10-4966-8644-63991a42856e",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index de5dc9741..1f6e9ef40 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -225,6 +225,13 @@
"when": 1785767291432,
"tag": "0031_gigantic_swarm",
"breakpoints": true
+ },
+ {
+ "idx": 32,
+ "version": "6",
+ "when": 1785773942431,
+ "tag": "0032_free_matthew_murdock",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/api/qbo-webhook.ts b/server/api/qbo-webhook.ts
index 10b488a7c..374abaffc 100644
--- a/server/api/qbo-webhook.ts
+++ b/server/api/qbo-webhook.ts
@@ -24,7 +24,7 @@ api.post('/', async (c) => {
rawBody,
headerSig,
(invoiceId, tenantId) => invoiceSvc.markPaid(invoiceId, tenantId, 'qbo'),
- (invoiceId, _balance, tenantId) => invoiceSvc.markPartial(invoiceId, tenantId, 'qbo'),
+ (invoiceId, amountPaidCents, tenantId) => invoiceSvc.markPartial(invoiceId, tenantId, 'qbo', amountPaidCents),
).then(({ valid }) => {
if (!valid) logger.info('QBO webhook: signature mismatch — discarded');
}).catch(e => {
diff --git a/server/api/qbo.ts b/server/api/qbo.ts
index c6e90605a..61516b68d 100644
--- a/server/api/qbo.ts
+++ b/server/api/qbo.ts
@@ -129,7 +129,7 @@ api.post('/sync', async (c) => {
svc.runCDCSync(
tenantId,
(invoiceId, tid) => invoiceSvc.markPaid(invoiceId, tid, 'qbo'),
- (invoiceId, _balance, tid) => invoiceSvc.markPartial(invoiceId, tid, 'qbo'),
+ (invoiceId, amountPaidCents, tid) => invoiceSvc.markPartial(invoiceId, tid, 'qbo', amountPaidCents),
),
);
return c.json({ success: true, data: { message: 'Sync started' } });
diff --git a/server/lib/db/schema/invoice.ts b/server/lib/db/schema/invoice.ts
index 75e573c66..3ef77fbf0 100644
--- a/server/lib/db/schema/invoice.ts
+++ b/server/lib/db/schema/invoice.ts
@@ -36,6 +36,18 @@ export const invoices = sqliteTable('invoices', {
// the metadata that keeps a historical record self-describing, so a later tenant
// currency change never re-labels a paid invoice. Appended at table end.
currency: text('currency').notNull().default('USD'),
+ // How much has actually been received on a partially-paid invoice. NULL on
+ // draft/sent/paid/void — only a 'partial' invoice carries one, and it is
+ // cleared whenever the invoice reaches paid or is refunded so the amount can
+ // never contradict the status derived from paidAt/partialPaidAt.
+ //
+ // Stores the amount PAID, not a remaining balance: amountCents above is the
+ // authoritative total (money authority chain, tier 1), so remaining is
+ // derived as amountCents - amountPaidCents. Persisting an external system's
+ // remaining balance would state a remainder computed against THAT system's
+ // total, which drifts from ours the first time either side edits the
+ // invoice. Appended at table end. See #273.
+ amountPaidCents: integer('amount_paid_cents'),
}, (t) => [
index('idx_invoices_tenant').on(t.tenantId),
index('idx_invoices_inspection').on(t.inspectionId),
diff --git a/server/scheduled.ts b/server/scheduled.ts
index d99b35b2d..f238b1ccc 100644
--- a/server/scheduled.ts
+++ b/server/scheduled.ts
@@ -82,7 +82,7 @@ async function runQBOCDC(env: ScheduledEnv): Promise {
const { processed } = await svc.runCDCSync(
conn.tenantId,
(invoiceId, tid) => invoiceSvc.markPaid(invoiceId, tid, 'qbo'),
- (invoiceId, _bal, tid) => invoiceSvc.markPartial(invoiceId, tid, 'qbo'),
+ (invoiceId, amountPaidCents, tid) => invoiceSvc.markPartial(invoiceId, tid, 'qbo', amountPaidCents),
);
if (processed > 0) logger.info('[cron:qbo] CDC processed invoices', { tenantId: conn.tenantId, processed });
} catch (e) {
diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts
index 2145ea264..7b85af779 100644
--- a/server/services/invoice.service.ts
+++ b/server/services/invoice.service.ts
@@ -146,16 +146,32 @@ export class InvoiceService {
await db.update(invoices).set({
paidAt: new Date(),
partialPaidAt: null,
+ // Paid in full leaves no residual partial amount; a stale value here
+ // would let a paid invoice report an outstanding balance.
+ amountPaidCents: null,
// Record how it was paid; keep any existing value if the caller omits one.
paymentMethod: method ?? existing.paymentMethod ?? null,
}).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
void source; // consumed by route handler to decide QBO sync
}
- async markPartial(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi'): Promise {
+ /**
+ * Record that an invoice is partially paid. `amountPaidCents` is what has
+ * actually been RECEIVED, in integer cents; remaining is derived by the
+ * caller as `amountCents - amountPaidCents` because the invoice total is
+ * the money authority, not any external system's view of it.
+ *
+ * Omitting the amount means "partial, amount unknown" and clears any
+ * previously captured figure — a number left over from an earlier sync is
+ * not evidence of what is owed now.
+ */
+ async markPartial(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', amountPaidCents?: number): Promise {
const db = this.getDrizzle();
- await db.update(invoices).set({ partialPaidAt: new Date(), paidAt: null })
- .where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
+ await db.update(invoices).set({
+ partialPaidAt: new Date(),
+ paidAt: null,
+ amountPaidCents: amountPaidCents ?? null,
+ }).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
void source;
}
@@ -163,7 +179,7 @@ export class InvoiceService {
const db = this.getDrizzle();
const existing = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))).get();
if (!existing) throw Errors.NotFound('Invoice not found');
- await db.update(invoices).set({ paidAt: null, partialPaidAt: null })
+ await db.update(invoices).set({ paidAt: null, partialPaidAt: null, amountPaidCents: null })
.where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
await this.syncInspectionPaymentGate(existing.inspectionId, tenantId);
}
diff --git a/server/services/qbo/api-base.ts b/server/services/qbo/api-base.ts
index 856ef4f96..28a1b81fb 100644
--- a/server/services/qbo/api-base.ts
+++ b/server/services/qbo/api-base.ts
@@ -29,7 +29,13 @@ export type QBOToken = {
export type InvoiceSummary = { Id: string; SyncToken: string; Balance: number; TotalAmt: number };
export type MarkPaidFn = (invoiceId: string, tenantId: string) => Promise;
-export type MarkPartialFn = (invoiceId: string, balance: number, tenantId: string) => Promise;
+/**
+ * Second argument is the amount already RECEIVED, in integer cents — not the
+ * remaining balance and not dollars. QuickBooks reports a remainder in dollars;
+ * `applyInvoiceStatusFromQBO` converts it once, so no adapter has to know the
+ * QBO shape or repeat the arithmetic. See #273.
+ */
+export type MarkPartialFn = (invoiceId: string, amountPaidCents: number, tenantId: string) => Promise;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructor = new (...args: any[]) => T;
diff --git a/server/services/qbo/invoice-sync.ts b/server/services/qbo/invoice-sync.ts
index 7dad89ef8..fba49b64f 100644
--- a/server/services/qbo/invoice-sync.ts
+++ b/server/services/qbo/invoice-sync.ts
@@ -60,7 +60,14 @@ export function withInvoiceSync>(Base:
if (inv.Balance === 0) {
await markPaid(mapped.oiId, tenantId);
} else if (inv.Balance < inv.TotalAmt) {
- await markPartial(mapped.oiId, inv.Balance, tenantId);
+ // QuickBooks amounts are dollars (see the reverse mapping in
+ // upsertInvoice, `Amount: amountCents / 100`). Round each side to
+ // its own exact cent value before subtracting: a bare float
+ // multiply on the difference produces off-by-one-cent amounts
+ // that are impossible to explain to a customer. This is the ONLY
+ // place the conversion happens — adapters receive cents.
+ const amountPaidCents = Math.round(inv.TotalAmt * 100) - Math.round(inv.Balance * 100);
+ await markPartial(mapped.oiId, amountPaidCents, tenantId);
}
return true;
}
diff --git a/tests/unit/invoices/partial-balance.spec.ts b/tests/unit/invoices/partial-balance.spec.ts
new file mode 100644
index 000000000..af74c0bbd
--- /dev/null
+++ b/tests/unit/invoices/partial-balance.spec.ts
@@ -0,0 +1,169 @@
+/**
+ * Partial-payment capture (OI #273, bug half).
+ *
+ * QuickBooks returns `{ Balance, TotalAmt }` and the invoice sync already
+ * decides the 'partial' branch from them — but every adapter dropped the
+ * number, so a partially-paid invoice could say THAT something was paid and
+ * never HOW MUCH. These specs pin the three things that are easy to get wrong
+ * once the amount is actually stored:
+ *
+ * 1. We persist the amount PAID, never QuickBooks' remaining Balance. Our
+ * `invoices.amountCents` is the authoritative total (money authority
+ * chain), so remaining must be derived against it; storing QBO's Balance
+ * would state a remainder computed against QuickBooks' total, which drifts
+ * the first time either side edits the invoice.
+ * 2. QBO speaks dollars. The dollar-to-cent conversion rounds, because a bare
+ * float multiply yields off-by-one-cent balances nobody can explain.
+ * 3. Paid-in-full and refunded both clear it, so the amount can never
+ * contradict the status derived from `paidAt` / `partialPaidAt`.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { eq } from 'drizzle-orm';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { InvoiceService } from '../../../server/services/invoice.service';
+import { QBOServiceBase } from '../../../server/services/qbo/api-base';
+import { withInvoiceSync } from '../../../server/services/qbo/invoice-sync';
+import type { InvoiceSummary } from '../../../server/services/qbo/api-base';
+
+class TestQBOService extends withInvoiceSync(QBOServiceBase) {}
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const INV_ID = 'inv-aaaaaaaa-0000-0000-0000-000000000001';
+const QBO_ID = 'q1';
+
+let db: BetterSQLite3Database;
+let qbo: TestQBOService;
+let invoiceSvc: InvoiceService;
+
+beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+
+ qbo = new TestQBOService({} as D1Database, 'cid', 'csec', 'whsec', 'secret32chars_aaaaaaaaaaaaaaaa');
+ invoiceSvc = new InvoiceService({} as D1Database);
+
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.qboEntityMap).values({
+ id: 'map-1', tenantId: TENANT, oiType: 'invoice', oiId: INV_ID,
+ qboType: 'Invoice', qboId: QBO_ID, qboSyncToken: '1', syncedAt: new Date(),
+ });
+});
+
+async function seedInvoice(amountCents: number) {
+ await db.insert(schema.invoices).values({
+ id: INV_ID, tenantId: TENANT, inspectionId: null, amountCents,
+ lineItems: [{ description: 'Inspection', amountCents }],
+ sentAt: new Date(), createdAt: new Date(), currency: 'USD',
+ });
+}
+
+async function getInvoice() {
+ const row = await db.select().from(schema.invoices).where(eq(schema.invoices.id, INV_ID)).get();
+ if (!row) throw new Error('invoice not seeded');
+ return row;
+}
+
+/** Remaining is DERIVED, never stored — the invoice total is the authority. */
+async function remainingCents() {
+ const inv = await getInvoice();
+ return inv.amountCents - (inv.amountPaidCents ?? 0);
+}
+
+/**
+ * The production wiring, verbatim: the QBO shape reaches
+ * `applyInvoiceStatusFromQBO`, which converts dollars to cents once, and the
+ * adapter hands the already-converted amount straight to the service.
+ */
+async function syncFromQbo(inv: Omit & { SyncToken?: string }) {
+ await qbo['applyInvoiceStatusFromQBO'](
+ TENANT,
+ { SyncToken: '1', ...inv },
+ (invoiceId, tenantId) => invoiceSvc.markPaid(invoiceId, tenantId, 'qbo'),
+ (invoiceId, amountPaidCents, tenantId) =>
+ invoiceSvc.markPartial(invoiceId, tenantId, 'qbo', amountPaidCents),
+ );
+}
+
+describe('QBO partial payment — capturing the amount', () => {
+ it('records how much was paid when QBO reports a partial payment', async () => {
+ // QBO speaks dollars: a $450 invoice with $200 still owed.
+ await seedInvoice(45000);
+ await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
+
+ const inv = await getInvoice();
+ expect(inv.amountPaidCents).toBe(25000);
+ expect(inv.partialPaidAt).not.toBeNull();
+ });
+
+ it('derives remaining from OUR amount, not the QuickBooks total', async () => {
+ // The invoice was edited in OI to $500 after QBO last saw $450. Remaining
+ // must follow the authoritative record, which is ours.
+ await seedInvoice(50000);
+ await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
+
+ expect(await remainingCents()).toBe(50000 - 25000);
+ });
+
+ it('rounds cents rather than truncating', async () => {
+ // A $100 invoice with $18.15 still owed. Chosen because it DISCRIMINATES:
+ // the naive float difference is 8184.999999999999, so truncating loses a
+ // cent and reports $81.84 received against $81.85 actually paid. Most
+ // dollar pairs divide evenly and would pass either way.
+ await seedInvoice(10000);
+ await syncFromQbo({ Id: QBO_ID, Balance: 18.15, TotalAmt: 100 });
+
+ expect((await getInvoice()).amountPaidCents).toBe(8185);
+ expect(await remainingCents()).toBe(1815);
+ });
+
+ it('hands the adapter cents already paid, not the dollar balance QBO sent', async () => {
+ await seedInvoice(45000);
+ const markPartial = vi.fn().mockResolvedValue(undefined);
+ await qbo['applyInvoiceStatusFromQBO'](
+ TENANT,
+ { Id: QBO_ID, SyncToken: '1', Balance: 200, TotalAmt: 450 },
+ vi.fn().mockResolvedValue(undefined),
+ markPartial,
+ );
+ expect(markPartial).toHaveBeenCalledWith(INV_ID, 25000, TENANT);
+ });
+
+ it('clears the paid amount when the invoice is paid in full', async () => {
+ await seedInvoice(45000);
+ await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
+ await invoiceSvc.markPaid(INV_ID, TENANT, 'qbo');
+
+ const inv = await getInvoice();
+ expect(inv.partialPaidAt).toBeNull();
+ expect(inv.amountPaidCents).toBeNull(); // no stale residue on a paid invoice
+ });
+
+ it('clears it on refund too', async () => {
+ await seedInvoice(45000);
+ await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
+ await invoiceSvc.markRefunded(INV_ID, TENANT);
+
+ expect((await getInvoice()).amountPaidCents).toBeNull();
+ });
+
+ it('leaves no stale amount when a later partial sync cannot say how much', async () => {
+ // markPartial without an amount means "partial, amount unknown" — it must
+ // not leave the previous figure standing as if it were current.
+ await seedInvoice(45000);
+ await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
+ await invoiceSvc.markPartial(INV_ID, TENANT, 'oi');
+
+ expect((await getInvoice()).amountPaidCents).toBeNull();
+ });
+});
From e149d4219e6a014bda85506bcfa9dad24184cbbb Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 01:05:35 +0800
Subject: [PATCH 009/111] feat(invoices): say what is still owed on a partially
paid invoice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The balance column landed with the previous commit and nothing read it. A
'partial' invoice could state that a payment had happened and not how much, so
neither the inspector nor the client could see the outstanding figure.
Remaining is derived, never stored: `amountCents - amountPaidCents` against our
own authoritative total, clamped at zero. An external system can report more
received than we billed after a divergent edit, and a negative balance shown to
a client reads as a refund nobody promised.
Two states, deliberately distinct. With a recorded amount the hub card and the
invoices table say "$200.00 remaining", in the invoice's own snapshot currency
rather than the viewer's default. With no recorded amount — rows written before
the column existed — the card says the amount was not recorded and shows no
figure at all. "$0.00 remaining" would be a false statement about money, and a
client would read it as nothing owed. A viewer without the `financial`
capability gets neither line: the balance is not unknown to the business, only
to that reader, so `redactMoney` drops the field and the card stays silent.
Three payload boundaries had to carry the new column, and one was dropping it
silently: the public pay endpoint parses through `PublicInvoiceBodySchema`
(IA-86), so an undeclared field is stripped one line before the payer's page —
the column was written, the row carried it, and nothing failed. The checkout
endpoint projects columns explicitly, and `InvoiceResponseSchema` describes the
invoices list. All three now declare it, each pinned by a test. The OpenAPI
snapshot reduces request schemas only, so no bump was needed.
The public pay page's balance and Stripe pay panel are deliberately unchanged:
the payment intent is minted from `amountCents`, and showing a reduced balance
beside a charge that is not reduced would state a price the payer would not be
charged. Charging the remainder is a payment-collection change, not a display
one.
File-size baseline bumped for four files already far over the cap
(1202/510/769/674) whose growth here is 1-7 lines of return type and column
projection — the return type cannot live anywhere but with its method. The
invoices route stayed under the cap by extracting the amount cell instead.
---
.../inspector-portal/InvoiceCard.tsx | 15 ++++-
app/components/invoices/InvoiceAmountCell.tsx | 46 +++++++++++++++
app/lib/hub-blocks.ts | 58 ++++++++++++++++++-
app/lib/inspection-hub.test.ts | 53 +++++++++++++++--
app/routes/inspector-portal.tsx | 1 +
app/routes/invoices.tsx | 5 +-
messages/en/labels.json | 2 +
scripts/file-size-baseline.json | 8 +--
server/api/bookings/agreement.ts | 6 +-
server/lib/validations/inspection/read.ts | 8 +++
server/lib/validations/invoice.schema.ts | 11 ++++
server/services/inspection.service.ts | 7 ++-
.../inspection/inspection-publish.service.ts | 9 ++-
tests/unit/billing/checkout-public.spec.ts | 8 ++-
.../public-invoice-field-projection.spec.ts | 17 +++++-
15 files changed, 235 insertions(+), 19 deletions(-)
create mode 100644 app/components/invoices/InvoiceAmountCell.tsx
diff --git a/app/components/inspector-portal/InvoiceCard.tsx b/app/components/inspector-portal/InvoiceCard.tsx
index bd511f4ee..ff0a36989 100644
--- a/app/components/inspector-portal/InvoiceCard.tsx
+++ b/app/components/inspector-portal/InvoiceCard.tsx
@@ -22,6 +22,7 @@ import type { action } from "~/routes/inspector-portal";
export function InvoiceCard({
pill,
amountCents,
+ currency,
paid,
sent,
payUrl,
@@ -31,9 +32,12 @@ export function InvoiceCard({
canManagePrice,
onRequestPayment,
}: {
- pill: { tone: PillTone; label: string };
+ /** `detail` carries the one-line money sentence a pill has no room for. */
+ pill: { tone: PillTone; label: string; detail?: string };
/** IA-95 — undefined when the caller lacks the `financial` capability. */
amountCents: number | undefined;
+ /** The invoice's own ISO 4217 snapshot; every figure on this card uses it. */
+ currency: string | undefined;
paid: boolean;
sent: boolean;
payUrl: string | null | undefined;
@@ -65,8 +69,15 @@ export function InvoiceCard({
invoice exists and its status, but not the figure. Saying so beats
rendering $0.00, which reads as "nothing owed". */}
- {amountCents === undefined ? m.inspections_hub_invoice_hidden() : formatCents(amountCents)}
+ {amountCents === undefined ? m.inspections_hub_invoice_hidden() : formatCents(amountCents, { currency })}
+ {/* A partially paid invoice's outstanding balance. The total above is
+ what was billed; this is what is still owed, and without it the
+ only thing the card could say about a partial payment was that one
+ had happened. Absent whenever the number is not knowable. */}
+ {pill.detail && (
+ {pill.detail}
+ )}
{hasServiceLines && (
{m.inspections_hub_invoice_from_services()}
)}
diff --git a/app/components/invoices/InvoiceAmountCell.tsx b/app/components/invoices/InvoiceAmountCell.tsx
new file mode 100644
index 000000000..47f537735
--- /dev/null
+++ b/app/components/invoices/InvoiceAmountCell.tsx
@@ -0,0 +1,46 @@
+import { formatCurrency } from "~/lib/format";
+import { remainingCents } from "~/lib/hub-blocks";
+import { m } from "~/paraglide/messages";
+
+/**
+ * The Amount column of the invoices table.
+ *
+ * The column states what was BILLED. On a partially paid invoice that is not
+ * what is owed, and the status pill beside it can only say "partial" — so the
+ * outstanding figure goes here, under the total.
+ *
+ * Both figures render in the invoice's OWN snapshot currency, never the viewer's
+ * live default: a historical record must not get re-labelled when the tenant
+ * switches currency, and two amounts in one cell disagreeing about their unit
+ * would be worse than showing one.
+ *
+ * The balance is omitted — not zeroed — whenever it is unknowable: money
+ * redacted for this viewer, or a partial with no recorded amount. See
+ * `remainingCents`.
+ */
+export function InvoiceAmountCell({
+ invoice,
+ currency: fallbackCurrency,
+ locale,
+}: {
+ /** Structural — any row carrying these four fields, so the table's own
+ * `InvoiceRow` type stays private to the route. */
+ invoice: { amountCents: number; amountPaidCents: number | null; status: string; currency: string };
+ /** Used only when the row carries no snapshot currency of its own. */
+ currency: string;
+ locale: string;
+}) {
+ const { amountCents, status } = invoice;
+ const currency = invoice.currency || fallbackCurrency;
+ const remaining = status === "partial" ? remainingCents(invoice) : null;
+ return (
+
+ {formatCurrency(amountCents, { locale, currency })}
+ {remaining !== null && (
+
+ {m.label_hub_invoice_remaining({ amount: formatCurrency(remaining, { locale, currency }) })}
+
+ )}
+
+ );
+}
diff --git a/app/lib/hub-blocks.ts b/app/lib/hub-blocks.ts
index 6c12000b4..ecd569ef6 100644
--- a/app/lib/hub-blocks.ts
+++ b/app/lib/hub-blocks.ts
@@ -37,10 +37,19 @@ export type PillTone =
| 'neutral'
| 'warning';
-/** A single derived status pill: a tone + a human-readable label. */
+/**
+ * A single derived status pill: a tone + a human-readable label, plus an
+ * OPTIONAL one-line detail the card may render beside it.
+ *
+ * `detail` exists because a pill has room for a state and not for a number, and
+ * "Partially paid" without a figure is the whole defect this field closes. It is
+ * absent — never an empty string — whenever the number is not knowable, so a
+ * caller cannot accidentally render a blank line where money should be.
+ */
interface BlockState {
tone: PillTone;
label: string;
+ detail?: string;
}
/** Derived states for the three action-bearing blocks. */
@@ -126,8 +135,25 @@ function deriveInvoice(hub: HubPayload): BlockState {
return { tone: 'neutral', label: m.label_hub_invoice_draft() };
case 'sent':
return { tone: 'monitor', label: m.label_hub_invoice_awaiting_payment() };
- case 'partial':
- return { tone: 'warning', label: m.label_hub_invoice_partially_paid() };
+ case 'partial': {
+ const label = m.label_hub_invoice_partially_paid();
+ // Money redacted for this viewer (IA-95): the card already says the
+ // figure is hidden, so adding a second line about it says nothing.
+ if (typeof inv.amountCents !== 'number') return { tone: 'warning', label };
+ const remaining = remainingCents(inv);
+ // Partial with no recorded amount. Naming the gap beats both silence
+ // (which reads as "the balance is the total") and a fabricated $0.00.
+ if (remaining === null) {
+ return { tone: 'warning', label, detail: m.label_hub_invoice_remaining_unknown() };
+ }
+ return {
+ tone: 'warning',
+ label,
+ detail: m.label_hub_invoice_remaining({
+ amount: formatCents(remaining, { currency: inv.currency }),
+ }),
+ };
+ }
case 'paid':
return { tone: 'sat', label: m.label_hub_invoice_paid() };
default:
@@ -235,6 +261,32 @@ export function invoiceFromParty(
/* Money formatting */
/* ------------------------------------------------------------------ */
+/**
+ * What is still owed on an invoice, in integer cents — or `null` when that
+ * cannot be stated.
+ *
+ * Two distinct reasons for `null`, both of which must silence the figure rather
+ * than substitute one:
+ * - the viewer lacks the `financial` capability, so `amountCents` was redacted
+ * out of the payload entirely (IA-95);
+ * - `amountPaidCents` is null — the invoice is partial but no amount was ever
+ * recorded (rows that predate the column, or a source that reported the
+ * status without a figure). Rendering "$0.00 remaining" there would be a
+ * false statement about money, and a client would read it as "nothing owed".
+ *
+ * Clamped at zero: an external system can report more received than our total
+ * after a divergent edit, and a negative balance shown to a client is never the
+ * right answer to that — it reads as a refund we are not promising.
+ */
+export function remainingCents(inv: {
+ amountCents?: number | undefined;
+ amountPaidCents?: number | null | undefined;
+}): number | null {
+ if (typeof inv.amountCents !== 'number') return null;
+ if (typeof inv.amountPaidCents !== 'number') return null;
+ return Math.max(0, inv.amountCents - inv.amountPaidCents);
+}
+
/** Format integer cents as a currency string, e.g. 50000 → "$500.00".
* locale/currency default to en-US/USD; callers pass the viewer values to localize. */
export function formatCents(
diff --git a/app/lib/inspection-hub.test.ts b/app/lib/inspection-hub.test.ts
index 6311a48fc..d781983ee 100644
--- a/app/lib/inspection-hub.test.ts
+++ b/app/lib/inspection-hub.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
// canPublish / isReportShipped are covered in hub-blocks.test.ts — one home per rule.
-import { deriveBlockStates, formatCents, type HubPayload } from '~/lib/hub-blocks';
+import { deriveBlockStates, formatCents, remainingCents, type HubPayload } from '~/lib/hub-blocks';
/**
* Issue #111 — pure block-state derivation for the `/inspections/:id` hub page.
@@ -118,9 +118,54 @@ describe('deriveBlockStates — invoice block', () => {
expect(s.invoice).toEqual({ tone: 'monitor', label: 'Awaiting payment' });
});
- it('partial invoice → warning / Partially paid', () => {
- const s = deriveBlockStates(hub({ invoice: { id: 'i', status: 'partial', amountCents: 1000, sentAt: '2026-01-01', paidAt: null, payUrl: null } }));
- expect(s.invoice).toEqual({ tone: 'warning', label: 'Partially paid' });
+ /**
+ * A partial invoice's whole point is the number: "Partially paid" without a
+ * figure tells an inspector nothing they could act on, and tells a client
+ * nothing about what they still owe. These four cases pin the only four
+ * things the card is allowed to say.
+ */
+ const partial = (over: Record) =>
+ deriveBlockStates(hub({
+ invoice: {
+ id: 'i', status: 'partial', amountCents: 45000, sentAt: '2026-01-01',
+ paidAt: null, payUrl: null, ...over,
+ } as HubPayload['invoice'],
+ })).invoice;
+
+ it('shows the outstanding balance on a partially paid invoice', () => {
+ const s = partial({ amountPaidCents: 25000 });
+ expect(s.tone).toBe('warning');
+ expect(s.label).toBe('Partially paid');
+ expect(s.detail).toContain('$200.00');
+ });
+
+ it('formats the balance in the invoice’s own currency, not the viewer’s default', () => {
+ const s = partial({ amountPaidCents: 25000, currency: 'EUR' });
+ expect(s.detail).toContain('€200.00');
+ expect(s.detail).not.toContain('$');
+ });
+
+ it('does not claim a balance when the amount paid is unknown', () => {
+ // Rows written before the column shipped carry partial_paid_at and no
+ // amount. Saying "$0.00 remaining" about them would be a false statement
+ // about money, so the card names the gap instead of inventing a figure.
+ const s = partial({ amountPaidCents: null });
+ expect(s.detail).not.toMatch(/\$/);
+ expect(s.detail).toBe('Amount received not recorded');
+ });
+
+ it('says nothing about the balance when money is redacted for this viewer', () => {
+ // IA-95 — no `financial` capability, so `amountCents` never arrived. The
+ // balance is not unknown to the business, only to this reader; claiming
+ // "not recorded" would be the wrong statement.
+ const s = partial({ amountCents: undefined, amountPaidCents: 25000 });
+ expect(s).toEqual({ tone: 'warning', label: 'Partially paid' });
+ });
+
+ it('never shows a negative balance when more was received than we billed', () => {
+ // Divergent edits on either side can leave the received figure above our
+ // total. A negative "remaining" reads as a refund we are not promising.
+ expect(remainingCents({ amountCents: 45000, amountPaidCents: 60000 })).toBe(0);
});
it('paid invoice → sat / Paid', () => {
diff --git a/app/routes/inspector-portal.tsx b/app/routes/inspector-portal.tsx
index d8f5a5d83..1b3c24d77 100644
--- a/app/routes/inspector-portal.tsx
+++ b/app/routes/inspector-portal.tsx
@@ -1037,6 +1037,7 @@ export default function InspectionHubPage() {
{invoice.clientName || "—"}
),
},
- { label: m.invoices_col_amount(), cell: (invoice) => {formatCurrency(invoice.amountCents, { locale, currency: invoice.currency || currency })} },
+ { label: m.invoices_col_amount(), cell: (invoice) => },
{ label: m.invoices_col_due(), cell: (invoice) => {invoice.dueDate ? formatDate(invoice.dueDate, { locale, timeZone: "UTC" }) : "—"} },
{
label: m.invoices_col_status(),
diff --git a/messages/en/labels.json b/messages/en/labels.json
index 87da70a12..897615bb3 100644
--- a/messages/en/labels.json
+++ b/messages/en/labels.json
@@ -52,6 +52,8 @@
"label_hub_invoice_draft": "Draft",
"label_hub_invoice_awaiting_payment": "Awaiting payment",
"label_hub_invoice_partially_paid": "Partially paid",
+ "label_hub_invoice_remaining": "{amount} remaining",
+ "label_hub_invoice_remaining_unknown": "Amount received not recorded",
"label_hub_invoice_paid": "Paid",
"label_hub_report_in_progress": "In Progress",
"label_hub_report_submitted": "Submitted",
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index d5b8582d1..56669c39d 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -1,6 +1,6 @@
{
"app/routes/inspection-edit.tsx": 2530,
- "app/routes/inspector-portal.tsx": 1202,
+ "app/routes/inspector-portal.tsx": 1203,
"server/services/inspection/inspection-core.service.ts": 1131,
"server/services/booking.service.ts": 972,
"server/services/inspection/inspection-report.service.ts": 952,
@@ -10,7 +10,7 @@
"server/api/sms.ts": 843,
"app/components/portal/sections/ReportView.tsx": 813,
"app/routes/settings-communication.tsx": 777,
- "server/services/inspection.service.ts": 769,
+ "server/services/inspection.service.ts": 774,
"server/api/admin/admin-settings.ts": 742,
"server/api/inspections/report-delivery.ts": 736,
"app/routes/settings-communication-templates.tsx": 731,
@@ -18,7 +18,7 @@
"app/routes/template-edit.tsx": 719,
"server/index.ts": 693,
"app/components/media-studio/PhotoAnnotator.tsx": 692,
- "server/services/inspection/inspection-publish.service.ts": 668,
+ "server/services/inspection/inspection-publish.service.ts": 675,
"app/hooks/usePhotoOps.ts": 661,
"server/lib/messaging/providers/telnyx-compliance.ts": 657,
"app/components/editor/ItemEditor.tsx": 637,
@@ -40,7 +40,7 @@
"server/services/portal-access.service.ts": 525,
"server/api/inspections/publish.ts": 520,
"app/components/settings/ManagedComplianceWizard.tsx": 514,
- "server/api/bookings/agreement.ts": 510,
+ "server/api/bookings/agreement.ts": 514,
"app/routes/settings-profile.tsx": 509,
"server/api/repair-builder.ts": 504,
"app/routes/inspection-edit/action.server.ts": 501,
diff --git a/server/api/bookings/agreement.ts b/server/api/bookings/agreement.ts
index 2dee45a6b..f87f4df10 100644
--- a/server/api/bookings/agreement.ts
+++ b/server/api/bookings/agreement.ts
@@ -90,6 +90,7 @@ const getCheckoutByTokenRoute = createRoute(withMcpMetadata({
invoice: z.object({
id: z.string(),
amountCents: z.number().int(),
+ amountPaidCents: z.number().int().nullable().describe('Cumulative amount received in cents; null when no figure was recorded'),
status: z.enum(['paid', 'partial', 'unpaid']),
}).nullable().describe('Latest invoice for the inspection, or null'),
payment: z.object({
@@ -276,6 +277,9 @@ const agreementRoutes = createApiRouter()
const invoiceRow = await db.select({
id: invoices.id,
amountCents: invoices.amountCents,
+ // Explicit projection: the `partial` status derived below carries no
+ // figure unless this column is named here.
+ amountPaidCents: invoices.amountPaidCents,
currency: invoices.currency,
paidAt: invoices.paidAt,
partialPaidAt: invoices.partialPaidAt,
@@ -338,7 +342,7 @@ const agreementRoutes = createApiRouter()
progress: { signed: signedCount, total: signers.length },
},
invoice: invoiceRow && invoiceStatus
- ? { id: invoiceRow.id, amountCents: invoiceRow.amountCents, currency: invoiceRow.currency, status: invoiceStatus }
+ ? { id: invoiceRow.id, amountCents: invoiceRow.amountCents, amountPaidCents: invoiceRow.amountPaidCents ?? null, currency: invoiceRow.currency, status: invoiceStatus }
: null,
payment: {
required: inspectionRow.paymentRequired === true,
diff --git a/server/lib/validations/inspection/read.ts b/server/lib/validations/inspection/read.ts
index 2991bf5d2..1ad351d7c 100644
--- a/server/lib/validations/inspection/read.ts
+++ b/server/lib/validations/inspection/read.ts
@@ -177,6 +177,14 @@ export const InspectionHubSchema = z.object({
id: z.string().describe('Invoice id'),
status: z.string().describe('draft | sent | partial | paid'),
amountCents: z.number().optional().describe('Invoice total in cents. ABSENT without the financial capability.'),
+ // Cumulative amount RECEIVED, not a remaining balance — the remainder is
+ // derived against our own authoritative total (`amountCents`), never against
+ // an external system's. Null when the invoice is partial but no figure was
+ // recorded, which the UI must render as "unknown" rather than as zero.
+ // ABSENT (not null) without the financial capability — `redactMoney` drops
+ // every `*Cents` key.
+ amountPaidCents: z.number().nullable().optional().describe('Cumulative amount received in cents; null when unrecorded. ABSENT without the financial capability.'),
+ currency: z.string().optional().describe("ISO 4217 currency snapshot the invoice was created in — what its figures must be formatted in, not the viewer's default."),
sentAt: z.string().nullable().describe('ISO sent timestamp'),
paidAt: z.string().nullable().describe('ISO paid timestamp'),
// IA-34 — the public pay page is token-gated, so a bare `/invoice/:id` is
diff --git a/server/lib/validations/invoice.schema.ts b/server/lib/validations/invoice.schema.ts
index e3cd89e46..566fbb7c2 100644
--- a/server/lib/validations/invoice.schema.ts
+++ b/server/lib/validations/invoice.schema.ts
@@ -45,6 +45,12 @@ export const InvoiceResponseSchema = z.object({
clientName: z.string().nullable().describe('TODO describe clientName field for the OpenInspection MCP integration'),
clientEmail: z.string().nullable().describe('TODO describe clientEmail field for the OpenInspection MCP integration'),
amountCents: z.number().describe('TODO describe amountCents field for the OpenInspection MCP integration'),
+ // Cumulative amount RECEIVED — not a remaining balance. What is still owed is
+ // `amountCents - amountPaidCents`, derived against our own authoritative
+ // total. Null means "partial, amount unknown" (rows that predate the column,
+ // or a partial the source system reported without a figure): a consumer must
+ // render that as unknown, never as a zero balance.
+ amountPaidCents: z.number().nullable().describe('Cumulative amount received in cents, or null when no figure was recorded'),
lineItems: z.array(LineItemSchema).describe('TODO describe lineItems field for the OpenInspection MCP integration'),
dueDate: z.string().nullable().describe('TODO describe dueDate field for the OpenInspection MCP integration'),
notes: z.string().nullable().describe('TODO describe notes field for the OpenInspection MCP integration'),
@@ -69,6 +75,11 @@ export const InvoiceResponseSchema = z.object({
export const PublicInvoiceBodySchema = z.object({
id: z.string(),
amountCents: z.number(),
+ // The payer's own record of what has already been received. Undeclared until
+ // now, and because the route PARSES the row through this schema (IA-86), zod
+ // stripped it — the pay page could not have shown a balance even though the
+ // column was populated. Nullable: "partial, amount unknown" is a real state.
+ amountPaidCents: z.number().nullable().optional(),
// Phase B — the invoice's snapshot currency (ISO 4217); the pay page renders
// this, not the tenant's live setting, so history stays self-describing.
currency: z.string().optional(),
diff --git a/server/services/inspection.service.ts b/server/services/inspection.service.ts
index 10d39a8ec..025004669 100644
--- a/server/services/inspection.service.ts
+++ b/server/services/inspection.service.ts
@@ -535,7 +535,12 @@ export class InspectionService {
signersTotal: number;
signersSigned: number;
}>;
- invoice: { id: string; status: string; amountCents: number; sentAt: string | null; paidAt: string | null } | null;
+ invoice: {
+ id: string; status: string; amountCents: number;
+ /** Cumulative amount received; null when partial with no recorded figure. */
+ amountPaidCents: number | null;
+ currency: string; sentAt: string | null; paidAt: string | null;
+ } | null;
publishReadiness: { ready: boolean; blockingCount: number };
} | null> {
return this.publish.getInspectionHub(inspectionId, tenantId, tenantSlug);
diff --git a/server/services/inspection/inspection-publish.service.ts b/server/services/inspection/inspection-publish.service.ts
index b899288d4..e2777bd67 100644
--- a/server/services/inspection/inspection-publish.service.ts
+++ b/server/services/inspection/inspection-publish.service.ts
@@ -318,7 +318,12 @@ export class InspectionPublishService extends InspectionSubService {
signersTotal: number;
signersSigned: number;
}>;
- invoice: { id: string; status: string; amountCents: number; sentAt: string | null; paidAt: string | null } | null;
+ invoice: {
+ id: string; status: string; amountCents: number;
+ /** Cumulative amount received; null when partial with no recorded figure. */
+ amountPaidCents: number | null;
+ currency: string; sentAt: string | null; paidAt: string | null;
+ } | null;
publishReadiness: { ready: boolean; blockingCount: number };
communication: { delivered: number; needsAttention: number; unread: number };
} | null> {
@@ -506,6 +511,8 @@ export class InspectionPublishService extends InspectionSubService {
id: invoice.id,
status: invoice.status,
amountCents: invoice.amountCents,
+ amountPaidCents: invoice.amountPaidCents ?? null,
+ currency: invoice.currency,
sentAt: invoice.sentAt,
paidAt: invoice.paidAt,
}
diff --git a/tests/unit/billing/checkout-public.spec.ts b/tests/unit/billing/checkout-public.spec.ts
index b176a3c67..aca3fbc6d 100644
--- a/tests/unit/billing/checkout-public.spec.ts
+++ b/tests/unit/billing/checkout-public.spec.ts
@@ -148,7 +148,13 @@ describe('GET /api/public/checkout/:token (Track I-a Task 7)', () => {
expect(d.envelope.progress).toEqual({ signed: 0, total: 1 });
// Phase B — the checkout payload now carries the invoice's snapshot
// currency (defaults to USD for this seed) so the pay UI renders it.
- expect(d.invoice).toEqual({ id: INV_ID, amountCents: 45000, currency: 'USD', status: expect.any(String) });
+ // `amountPaidCents` rides the SAME explicit projection: null here means
+ // nothing has been received, which is distinct from "partial, unknown"
+ // and from a zero the reader could mistake for a settled balance.
+ expect(d.invoice).toEqual({
+ id: INV_ID, amountCents: 45000, amountPaidCents: null,
+ currency: 'USD', status: expect.any(String),
+ });
expect(d.payment).toEqual({ required: true, paid: false });
expect(d.inspection).toEqual({ id: INSP_ID, propertyAddress: '1 Main St' });
expect(d.branding).toEqual({ companyName: 'Acme Inspections', primaryColor: '#ff5500' });
diff --git a/tests/unit/client-portal/public-invoice-field-projection.spec.ts b/tests/unit/client-portal/public-invoice-field-projection.spec.ts
index a637abfec..dd6c8b497 100644
--- a/tests/unit/client-portal/public-invoice-field-projection.spec.ts
+++ b/tests/unit/client-portal/public-invoice-field-projection.spec.ts
@@ -39,6 +39,7 @@ const FULL_ROW = {
clientName: 'Dana Buyer',
clientEmail: 'dana@example.com',
amountCents: 5000,
+ amountPaidCents: 2000,
lineItems: [{ description: 'Home inspection', amountCents: 5000 }],
dueDate: '2026-08-01',
notes: 'Client haggled; do not discount again.',
@@ -54,7 +55,7 @@ const FULL_ROW = {
};
const LEAKED = ['tenantId', 'contactId', 'notes', 'qboSyncStatus', 'paymentMethod', 'partialPaidAt', 'voidedAt', 'clientEmail', 'sentAt'] as const;
-const KEPT = ['id', 'amountCents', 'currency', 'status', 'createdAt', 'dueDate', 'clientName', 'lineItems', 'brand', 'tenantSlug'] as const;
+const KEPT = ['id', 'amountCents', 'amountPaidCents', 'currency', 'status', 'createdAt', 'dueDate', 'clientName', 'lineItems', 'brand', 'tenantSlug'] as const;
describe('IA-86 — public invoice response carries only declared fields', () => {
let testDb: BetterSQLite3Database;
@@ -112,6 +113,20 @@ describe('IA-86 — public invoice response carries only declared fields', () =>
expect(data.lineItems).toEqual([{ description: 'Home inspection', amountCents: 5000 }]);
});
+ /**
+ * The projection cuts both ways: it stops undeclared fields leaking, and it
+ * silently DROPS a field someone forgot to declare. `amountPaidCents` was
+ * added to `invoices` and populated by the QuickBooks sync while this schema
+ * said nothing about it — so the column was written, the row carried it, and
+ * zod removed it one line before the payer's page. Nothing failed; the value
+ * simply never arrived. Pin it, because the next money column will land the
+ * same way.
+ */
+ it('ships the amount already received, which the schema used to strip', async () => {
+ const { data } = await fetchInvoice();
+ expect(data.amountPaidCents).toBe(2000);
+ });
+
it('the raw text of the response contains no private note', async () => {
const res = await buildApp().request(`/api/public/inspections/${INSP}/invoice?token=${encodeURIComponent(token)}`);
expect(await res.text()).not.toContain('do not discount again');
From 33d7a622dd181907fa9772ebe7296beda0af7e62 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 01:28:09 +0800
Subject: [PATCH 010/111] docs(i18n): es-419 glossary before translating 4,249
keys
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One equivalent per canonical product term, decided once. Without it the same
noun gets four translations across 29 module files and the result reads as
machine output. Register: formal usted, sentence case for buttons.
Usted is chosen for reach, not politeness: es-419 spans voseo countries where
the tu imperative is audibly foreign, so usted is the only second person that
is correct across the whole region.
The glossary is machine-read rather than advisory. lint:i18n-glossary parses
its own tables and holds messages/es-419 to them:
- banned terms — only context-free wrong words are listed, so a hit is real
- consistency — character-identical English must have identical Spanish,
which is what stops Satisfactory reading two ways across
the five modules that use it
- placeholders — {name} tokens must survive translation; a dropped or
renamed one compiles to a different function signature
It also fails when it cannot trust its own inputs: markers gone, tables shrunk
below a floor, or a divergence entry naming a key that does not exist. A gate
that silently reads nothing passes everything.
The 15 login-pilot keys predate the glossary and were written in tu; they are
re-registered to usted here so the gate is green at rest rather than carrying a
standing exception.
---
app/lib/forms/auth.schema.test.ts | 3 +-
docs/developers/i18n-glossary.md | 261 ++++++++++++++++++++++++++++++
messages/es-419/auth.json | 6 +-
package.json | 5 +-
scripts/check-i18n-glossary.mjs | 211 ++++++++++++++++++++++++
5 files changed, 480 insertions(+), 6 deletions(-)
create mode 100644 docs/developers/i18n-glossary.md
create mode 100644 scripts/check-i18n-glossary.mjs
diff --git a/app/lib/forms/auth.schema.test.ts b/app/lib/forms/auth.schema.test.ts
index 2ff0778dd..539671180 100644
--- a/app/lib/forms/auth.schema.test.ts
+++ b/app/lib/forms/auth.schema.test.ts
@@ -61,7 +61,8 @@ describe('auth login i18n (Phase C pilot)', () => {
it('resolves login UI + interpolation messages in es-419', () => {
overwriteGetLocale(() => 'es-419');
- expect(m.auth_login_heading()).toBe('Inicia sesión en tu espacio de trabajo');
+ // Formal `usted` register — see docs/developers/i18n-glossary.md.
+ expect(m.auth_login_heading()).toBe('Inicie sesión en su espacio de trabajo');
expect(m.auth_login_submit()).toBe('Iniciar sesión');
// category 2 — server-side interpolation message
expect(m.auth_login_error_failed_with_status({ status: 500 })).toBe(
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
new file mode 100644
index 000000000..60759e39a
--- /dev/null
+++ b/docs/developers/i18n-glossary.md
@@ -0,0 +1,261 @@
+# es-419 translation glossary
+
+One equivalent per term, decided once. The catalogue is 4,300 keys across 29
+module files in `messages/`; without a fixed term list the same noun acquires
+four translations across those files and the result reads as machine output.
+
+This file is **machine-read**. `npm run lint:i18n-glossary`
+(`scripts/check-i18n-glossary.mjs`) parses the tables below and fails the build
+when `messages/es-419/**` contradicts them, so the glossary cannot quietly drift
+away from the catalogue it governs. Edit the table, not the gate.
+
+## Status of the Spanish catalogue
+
+English is the authoritative locale. `es-419` is a courtesy layer: a key with no
+Spanish translation falls back to English at runtime, which is safe. A key
+present but **empty** is not — it renders blank. Never commit `"key": ""`.
+
+Legally operative text is **not** translated. Inspection agreements and platform
+terms stay English, and their body text is tenant/authored content that does not
+live in this catalogue at all. Chrome *around* those documents (buttons, table
+headers, status labels) is ordinary UI and is translated.
+
+## Register
+
+**Formal *usted*, second person.** Not *tú*, and not *vos*.
+
+The reason is regional reach, not politeness. `es-419` spans voseo countries
+(Argentina, Uruguay, much of Central America) where the *tú* imperative is
+audibly foreign — "Ingresa" against "Ingresá". *Usted* is the one second person
+that is correct everywhere in the region, so it is the only choice that lets a
+single catalogue serve all of it. It is also the register a business uses when
+writing to a client about their house.
+
+Consequences, all machine-enforced below:
+
+- Possessive is **su / sus**, never *tu / tus*.
+- Imperatives take the *usted* form: **Ingrese**, **Guarde**, **Seleccione**.
+- Clitic is **le / lo / la**, never *te*.
+- Write **usted** in full where it is needed at all; never abbreviate to *Ud.*
+- Latin American vocabulary, never Castilian: *computadora* not *ordenador*,
+ *archivo* not *fichero*.
+
+**Sentence case for buttons and labels**, matching the English UI: "Guardar
+cambios", not "Guardar Cambios". Spanish sentence case also means months,
+weekdays and languages are lowercase — but those come from `Intl`, not from this
+catalogue.
+
+## Rules that outrank the tables
+
+1. **Translate what English says — do not fix English.** The English catalogue
+ has known inconsistencies (four words for one trade concept; "Roles" against
+ "Inspection roles"). Unifying them in Spanish desynchronises the two
+ catalogues and hides the English problem. Terminology renames are an
+ English-side pass; this is not it.
+2. **Placeholders are part of the string.** `{address}`, `{count}`, `{status}`
+ must survive translation with the same names — a dropped or renamed
+ placeholder compiles to a different function signature and breaks the call
+ site. Enforced.
+3. **Do not translate the product name.** OpenInspection stays OpenInspection.
+4. **Do not translate through the key name.** Keys are English identifiers and
+ several of them lie about their own copy — the `settings_profile_credentials_*`
+ family renders "Licenses & affiliations", not "Credentials". Translate the
+ value in front of you.
+5. **Tenant data is not in scope.** Rating-system labels, template names, canned
+ comment bodies, inspection-role labels and trade names live in the database
+ and stay in whatever language the tenant typed. A tenant who renamed their
+ rating scale sees their own words, not these.
+6. **Numbers, dates, money and addresses are formatted by code**
+ (`app/lib/format.ts`, `app/lib/money.ts`), never spelled into a message. Do
+ not hardcode a currency symbol or a date pattern in a translated string.
+
+---
+
+## Product nouns
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Inspection | inspección | orden de trabajo | The canonical noun. English forbids "Order"/"Job"; Spanish must not reintroduce them. |
+| Company | empresa | compañía | One word for the business. "Compañía" is not wrong Spanish, it is just a second word for the same thing. |
+| Workspace | espacio de trabajo | — | Appears only in the login heading. Not a user-facing concept elsewhere — do not spread it. |
+| Report | informe | reporte | Region-neutral and unambiguous. "Reporte" is common in some markets, but picking both is how one noun becomes two. |
+| Template | plantilla | — | Standard software Spanish. |
+| Finding | hallazgo | — | Rare in the UI (a repair-request column, a metrics chart). Distinct from *defecto*: a hallazgo is observed, a defecto is judged. |
+| Defect | defecto | — | The severity level. See the rating table. |
+| Repair Items | elementos de reparación | recomendaciones | English forbids "Recommendations" for this feature; the Spanish ban mirrors it exactly. |
+| Repair Request | solicitud de reparación | — | The client-facing document built from repair items. |
+| Canned Comment | comentario predefinido | comentario enlatado | "Enlatado" is a literal calque of the English idiom and reads as a joke. |
+| Notes | notas | apuntes | Inspector free text. Keep distinct from *comentarios*. |
+| Comments | comentarios | — | Library entries and message threads. Never merge with *notas*. |
+| Booking | reserva | — | The public self-scheduling flow. "Online Booking" → "Reservas en línea". |
+| Appointment | cita | — | A scheduled visit. Deliberately a different word from *reserva*. |
+| Schedule (noun) | agenda | — | |
+| Schedule (verb) | programar | — | "Scheduled" → *programada*. |
+| Invoice | factura | — | |
+| Estimate | presupuesto | — | Not *estimado*, which reads as a guess rather than a priced offer. |
+| Agreement | acuerdo | — | The document itself stays in English; this is the word for it in chrome. |
+| Trade | oficio | — | The contractor discipline. English also says "contractor type" and "recommended contractor" for adjacent things — translate each as written (rule 1). |
+| Contractor | contratista | — | |
+| Property | propiedad | — | |
+| Licenses & affiliations | licencias y afiliaciones | — | The `settings_profile_credentials_*` family. This is licences plus association memberships — **not** login credentials. |
+| Credentials (sign-in / provider secrets) | credenciales | — | Only for authentication: the login form, and email/SMS/accounting provider secrets. Never for the licences feature above. |
+
+## Roles and parties
+
+The same handful of role words appears in a dozen places — the team settings
+page, the invite modal, the inspection people list, the signer list, the message
+thread, the public verify page. They must read identically in all of them; the
+gate compares them against each other.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Owner (account role) | Titular | — | *Propietario* is the property owner — a different person, in a product whose whole subject is someone's house. "Titular" is the account holder and carries no such collision. |
+| Manager (account role) | Gerente | — | Not *Administrador*, which reads as a system admin. |
+| Inspector | Inspector | — | Same word. Feminine *Inspectora* only where the string is about one known person. |
+| Agent | Agente | — | Real-estate agent. |
+| Client | Cliente | — | English forbids "Customer"; do not reach for *consumidor*. |
+| Co-Client | Cliente secundario | — | Spanish has no "co-" formation here, and the codebase's own name for the concept is the secondary client. |
+| Other | Otro | — | The role bucket, masculine to agree with *rol*. |
+| Contact | Contacto | — | The label English gives the `other` message role. The divergence is English's; keep it. |
+| Signer | Firmante | — | |
+| Recipient | Destinatario | — | |
+| Staff | personal | — | "Office staff" → *personal de oficina*. |
+| Team | equipo | — | |
+| Field Observer | Observador de campo | — | Commercial sign-off role. |
+| PCR Reviewer | Revisor del PCR | — | PCR stays an acronym; it names a document type. |
+
+### Roles that are database seeds, not catalogue keys
+
+`Buyer's Agent`, `Listing Agent`, `Attorney`, `Transaction Coordinator`,
+`Insurance Agent`, `Title Company` and `Co-Client` are seeded labels in
+`server/lib/people/default-role-profiles.ts`, and tenants can rename them. They
+are **not** translatable today and **no message key should be invented for
+them** during translation. The equivalents are fixed here so that whenever they
+do become translatable the term is already decided:
+
+| English | es-419 | Why |
+|---|---|---|
+| Buyer's Agent | Agente del comprador | |
+| Listing Agent | Agente del vendedor | Do not calque "listing". The English pair was chosen for accuracy about *which party the agent serves*, and naming the party is exactly how Spanish says it. |
+| Attorney | Abogado | |
+| Transaction Coordinator | Coordinador de transacción | |
+| Insurance Agent | Agente de seguros | |
+| Title Company | Empresa de títulos | *Empresa*, not *compañía* — consistent with the Company row above. |
+
+## Ratings and severity
+
+`Satisfactory`, `Monitor` and `Defect` each appear in five or more module files
+(`labels`, `library`, `editor`, `editor-2`, `templates`). They are the single
+easiest place to end up with four Spanish words for one concept.
+
+Residential and commercial severity are **separate scales and must never share
+vocabulary**: the commercial standard excludes routine maintenance from
+"deficiency", which is the opposite of the residential reading.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Satisfactory | Satisfactorio | — | |
+| Monitor | Vigilar | monitorear | The middle tier. "Monitorear" is a calque and long for a chip. |
+| Not Inspected | No inspeccionado | — | |
+| Not Present | No presente | — | |
+| Deficient | Deficiente | — | The commercial/TREC wording. |
+| Deficiency | Deficiencia | — | Commercial only. Not a synonym for *defecto*. |
+| Hazard | Peligro | — | |
+| Functional | Funcional | — | |
+| Marginal | Marginal | — | |
+| Maintenance | Mantenimiento | — | |
+
+## Recurring UI verbs and states
+
+These are a few hundred keys between them. One word each.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Save | Guardar | salvar | *Salvar* is to rescue. "Save changes" → "Guardar cambios". |
+| Cancel | Cancelar | — | |
+| Delete | Eliminar | — | |
+| Remove | Quitar | — | Detaching something, not destroying it. Distinct from *Eliminar*. |
+| Clear | Borrar | — | Emptying a field. This is why *borrar* is not the word for Delete. |
+| Archive | Archivar | — | |
+| Publish | Publicar | — | "Published" → *Publicado*. |
+| Draft | Borrador | — | The noun. Unrelated to *borrar*. |
+| Send | Enviar | — | |
+| Resend | Reenviar | — | |
+| Download | Descargar | — | |
+| Upload | Subir | — | |
+| Add | Agregar | añadir | Both are correct Spanish; *agregar* is the region-neutral default and picking one is the point. |
+| Edit | Editar | — | |
+| Preview | Vista previa | — | |
+| Search | Buscar | — | |
+| Continue | Continuar | — | |
+| Back | Atrás | — | |
+| Next | Siguiente | — | |
+| Confirm | Confirmar | — | |
+| Retry | Reintentar | — | |
+| Share | Compartir | — | |
+| Sign | Firmar | — | |
+| Pay | Pagar | — | |
+| Assign | Asignar | — | |
+| Invite | Invitar | — | |
+| Loading… | Cargando… | — | Keep the ellipsis character the English string uses. |
+| Saving… | Guardando… | — | |
+| Sending… | Enviando… | — | |
+| Uploading… | Subiendo… | — | |
+
+## Register enforcement
+
+Every entry here is wrong in `es-419` in every context, which is what makes it
+safe to ban outright. Soft preferences belong in the "Why" column of the tables
+above, not in this one.
+
+
+
+| Concept | Use | Never | Why |
+|---|---|---|---|
+| Possessive, 2nd person | su / sus | tu, tus | *tú* register. Both accented and unaccented forms are caught. |
+| Subject pronoun, 2nd person | usted | tú, ti, contigo, tuyo, tuya | |
+| Clitic, 2nd person | le / lo / la | te | *te* is the *tú* clitic; *usted* takes *le*. |
+| Abbreviated courtesy | usted | Ud., Vd. | Write it out. |
+| 2nd person plural | ustedes | vosotros, vuestro, vuestra | Castilian only; wrong everywhere in `es-419`. |
+| Computer | computadora | ordenador | Castilian. |
+| File | archivo | fichero | Castilian. |
+| To take / get | tomar, obtener | coger | Vulgar in most of Latin America. |
+
+## Consistency across modules
+
+Two keys whose **English is character-for-character identical** must have
+identical Spanish. This is checked automatically and is the rule that stops
+"Satisfactory" becoming *Satisfactorio* in `labels.json` and *Aceptable* in
+`library.json`.
+
+Where the same English genuinely needs two Spanish renderings — usually gender
+agreement, or a word that is a noun in one place and a verb in another — list
+the keys here with the reason, and the gate will allow it.
+
+
+
+*(No declared divergences yet. Add them as `- \`key_one\`, \`key_two\` — reason.)*
+
+## Working through a module
+
+1. Read the English values, not the key names.
+2. Apply the tables above. If a term recurs and is not in a table, add a row
+ here first — that is cheaper than finding three spellings later.
+3. Keep every placeholder.
+4. `npm run i18n:compile` once for the whole module, not per key.
+5. `npm run lint:i18n-glossary` and `npm run lint:i18n-catalog`.
+
+### Known deviation
+
+The 15 keys in `messages/es-419/auth.json` were written during the login pilot,
+before this glossary existed, in the *tú* register. They were re-registered to
+*usted* when this file landed. If any pre-glossary Spanish is found elsewhere,
+fix the register rather than widening the gate.
diff --git a/messages/es-419/auth.json b/messages/es-419/auth.json
index b041e168b..f5ef4a1d2 100644
--- a/messages/es-419/auth.json
+++ b/messages/es-419/auth.json
@@ -1,11 +1,11 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"auth_login_meta_title": "Iniciar sesión — OpenInspection",
- "auth_login_heading": "Inicia sesión en tu espacio de trabajo",
- "auth_login_subtitle": "Ingresa tus credenciales para acceder a inspecciones, informes y herramientas de equipo.",
+ "auth_login_heading": "Inicie sesión en su espacio de trabajo",
+ "auth_login_subtitle": "Ingrese sus credenciales para acceder a inspecciones, informes y herramientas de equipo.",
"auth_login_email_label": "Correo electrónico",
"auth_login_password_label": "Contraseña",
- "auth_login_forgot_link": "¿Olvidaste tu contraseña?",
+ "auth_login_forgot_link": "¿Olvidó su contraseña?",
"auth_login_submit": "Iniciar sesión",
"auth_login_submit_pending": "Iniciando sesión…",
"auth_login_error_failed_with_status": "Error al iniciar sesión ({status})",
diff --git a/package.json b/package.json
index 8f31f78c6..81ae5e88f 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,7 @@
"type-check": "npm run i18n:compile && react-router typegen && npm run type-check:app && npm run type-check:api",
"type-check:app": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile ./.tsbuildinfo.app",
"type-check:api": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.api.json --noEmit --incremental --tsBuildInfoFile ./.tsbuildinfo.api",
- "lint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content && npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:migchain && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming && npm run lint:agent-routes",
+ "lint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content && npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:migchain && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:i18n-glossary && npm run lint:naming && npm run lint:agent-routes",
"lint:ds": "node scripts/check-ds-tokens.mjs",
"lint:agent-routes": "node scripts/check-agent-routes.mjs",
"lint:naming": "node scripts/check-naming.mjs",
@@ -60,6 +60,7 @@
"lint:tz": "node scripts/check-tz-safety.mjs",
"lint:i18n": "node scripts/check-i18n.mjs",
"lint:i18n-catalog": "node scripts/check-i18n-catalog.mjs",
+ "lint:i18n-glossary": "node scripts/check-i18n-glossary.mjs",
"check:ts-range": "node scripts/check-ts-range.mjs",
"lint:fix": "eslint . --fix --cache",
"security-scan": "npm audit --audit-level=high",
@@ -93,7 +94,7 @@
"mcp:snapshot": "node scripts/snapshot-openapi.mjs",
"lint:english": "node scripts/check-english-only.mjs",
"lint:eslint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content",
- "lint:gates-full": "npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:migchain && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming && npm run lint:agent-routes",
+ "lint:gates-full": "npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:migchain && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:i18n-glossary && npm run lint:naming && npm run lint:agent-routes",
"i18n:compile:cached": "node scripts/i18n-compile-if-changed.mjs"
},
"dependencies": {
diff --git a/scripts/check-i18n-glossary.mjs b/scripts/check-i18n-glossary.mjs
new file mode 100644
index 000000000..12f26f353
--- /dev/null
+++ b/scripts/check-i18n-glossary.mjs
@@ -0,0 +1,211 @@
+#!/usr/bin/env node
+/**
+ * i18n — glossary conformance gate (`lint:i18n-glossary`).
+ *
+ * `docs/developers/i18n-glossary.md` fixes one es-419 equivalent per product
+ * term. A glossary nobody checks is a suggestion, and 4,300 keys translated
+ * against a suggestion produce four Spanish words for "Report". This gate reads
+ * the glossary's own tables — the document is the source of truth, not a copy
+ * kept in here — and holds `messages/es-419/**` to them.
+ *
+ * Three checks:
+ * 1. BANNED TERM — a term the glossary's "Never" column rules out appears in
+ * a translation. Only context-free wrong words are listed
+ * there, so a hit is always a real hit.
+ * 2. CONSISTENCY — two keys with character-identical English must have
+ * identical Spanish, unless declared under gate:divergence.
+ * 3. PLACEHOLDERS — `{name}` tokens must survive translation unchanged. A
+ * dropped or renamed one compiles to a different function
+ * signature and breaks the call site at runtime.
+ *
+ * Plus self-guards: a gate that silently scans nothing is worse than no gate, so
+ * this one fails if the glossary stops parsing, if the tables shrink below a
+ * floor, or if the catalogue cannot be read.
+ */
+import { readFileSync, readdirSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const GLOSSARY = join(root, 'docs/developers/i18n-glossary.md');
+const SOURCE_LOCALE = 'en';
+const TARGET_LOCALE = 'es-419';
+
+/** Floors for the self-guard. Raise as the glossary grows; never lower to pass. */
+const MIN_TERM_ROWS = 60;
+const MIN_BANNED_TERMS = 15;
+const MIN_SOURCE_KEYS = 100;
+
+let failed = false;
+const fail = (msg) => { failed = true; console.error(`[i18n-glossary] ${msg}`); };
+
+/**
+ * Accent- and case-insensitive form, so "Reportes" is caught by "reporte".
+ * The combining-marks range is written as escapes on purpose: spelling it with
+ * literal accents would leave invisible characters in this file.
+ */
+const isCombiningMark = (cp) => cp >= 0x0300 && cp <= 0x036f;
+const norm = (s) => [...s.normalize('NFD')]
+ .filter((ch) => !isCombiningMark(ch.codePointAt(0)))
+ .join('')
+ .toLowerCase();
+
+function loadCatalogue(locale) {
+ const dir = join(root, 'messages', locale);
+ const merged = {};
+ const files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
+ for (const file of files) {
+ const { $schema, ...messages } = JSON.parse(readFileSync(join(dir, file), 'utf8'));
+ for (const [key, value] of Object.entries(messages)) merged[key] = { value: String(value), file };
+ }
+ return { merged, fileCount: files.length };
+}
+
+/**
+ * Parse every markdown table that follows a `` marker.
+ * Columns are (english | es-419 | never | why); only the first three are read.
+ */
+function parseGlossary(text) {
+ const terms = [];
+ const marker = //g;
+ let m;
+ while ((m = marker.exec(text)) !== null) {
+ const rest = text.slice(m.index + m[0].length);
+ let started = false;
+ for (const line of rest.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith('|')) { if (started) break; continue; }
+ const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : undefined).split('|').map((c) => c.trim());
+ if (cells.every((c) => /^:?-{2,}:?$/.test(c))) { started = true; continue; }
+ if (!started) continue; // header row
+ if (cells.length < 3) continue;
+ terms.push({ english: cells[0], approved: cells[1], never: cells[2] });
+ }
+ }
+ // Keys allowed to break the consistency rule, declared as list items.
+ const divergence = new Set();
+ const dm = //.exec(text);
+ if (dm) {
+ for (const line of text.slice(dm.index).split('\n')) {
+ if (/^#{1,6}\s/.test(line.trim())) break;
+ if (!line.trim().startsWith('-')) continue;
+ for (const k of line.matchAll(/`([a-z0-9_]+)`/g)) divergence.add(k[1]);
+ }
+ }
+ return { terms, divergence };
+}
+
+/** One regex per banned term: whole word, optional Spanish plural, spaces loose. */
+function bannedMatcher(term) {
+ const escaped = norm(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
+ return new RegExp(`(? ({ ...b, re: bannedMatcher(b.term) }));
+const hits = [];
+for (const [key, { value, file }] of Object.entries(target)) {
+ const haystack = norm(value);
+ // Several rows can rule out the same written word ("tu" and "tú" normalise
+ // alike); report the offending word once per key, not once per row.
+ const seen = new Set();
+ for (const b of matchers) {
+ const found = b.re.exec(haystack);
+ if (!found || seen.has(found[0])) continue;
+ seen.add(found[0]);
+ hits.push({ key, file, value, word: found[0], ...b });
+ }
+}
+if (hits.length) {
+ fail(`${hits.length} translation(s) use a term the glossary rules out:`);
+ for (const h of hits) {
+ console.error(` ${h.file} · ${h.key}\n "${h.value}"\n '${h.word}' is ruled out for ${h.english} — use '${h.approved}'.`);
+ }
+}
+
+// -------------------------------------------------------------- 2. consistency
+const byEnglish = new Map();
+for (const [key, { value }] of Object.entries(source)) {
+ const v = value.trim();
+ if (!byEnglish.has(v)) byEnglish.set(v, []);
+ byEnglish.get(v).push(key);
+}
+for (const [english, keys] of byEnglish) {
+ if (keys.length < 2) continue;
+ const translated = keys.filter((k) => k in target && target[k].value.trim() !== '');
+ if (translated.length < 2) continue;
+ const variants = new Map();
+ for (const k of translated) {
+ const es = target[k].value.trim();
+ if (!variants.has(es)) variants.set(es, []);
+ variants.get(es).push(k);
+ }
+ if (variants.size < 2) continue;
+ if (translated.every((k) => divergence.has(k))) continue;
+ fail(`"${english}" has ${variants.size} different translations — identical English must read identically:`);
+ for (const [es, ks] of variants) console.error(` "${es}" ← ${ks.join(', ')}`);
+ console.error(` Pick one, or declare the split under gate:divergence in the glossary with a reason.`);
+}
+
+// -------------------------------------------------------------- 3. placeholders
+const holders = (s) => [...s.matchAll(/\{([^}]*)\}/g)].map((x) => x[1].trim()).sort();
+for (const [key, { value, file }] of Object.entries(target)) {
+ if (!(key in source)) continue; // stale keys are check-i18n-catalog's job
+ const want = holders(source[key].value);
+ const got = holders(value);
+ if (want.join(' ') === got.join(' ')) continue;
+ fail(`${file} · ${key}: placeholders changed in translation — [${want.join(', ')}] became [${got.join(', ')}].`
+ + ` The call site passes the English set; a rename or a drop breaks it.`);
+}
+
+if (failed) {
+ console.error('[i18n-glossary] FAIL — see docs/developers/i18n-glossary.md.');
+ process.exit(1);
+}
+const translated = sourceKeys.filter((k) => k in target && target[k].value.trim() !== '').length;
+console.log(`[i18n-glossary] OK — ${terms.length} term row(s), ${banned.length} banned term(s), `
+ + `${divergence.size} declared divergence(s); checked ${translated} translated key(s) of ${sourceKeys.length}.`);
From 16446a634d7eb3d0b37865cb9e5fa4ecbb3a96a3 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 01:49:36 +0800
Subject: [PATCH 011/111] i18n(es-419): translate common.json (18 keys)
First module end to end, to surface the mechanics at a scale where a mistake is
cheap. Every one of these 18 keys is reused across the app -- 203 call sites,
65 of them common_cancel -- so they set the vocabulary the other 28 modules are
held to.
Register is usted, per docs/developers/i18n-glossary.md. Terms come from the
glossary tables; the four words not in a table (Close, Copied, Done, Undo/Redo)
are single-word UI verbs with no regional split.
Proved the gates actually bite before trusting them. Deliberately broke each
check and watched it fail: an empty value ("key": "") -- the shape a translator
leaves behind when they skip a hard string -- fails lint:i18n-catalog naming the
key; a banned term, a tu-register possessive, a dropped {placeholder} and the
same English translated two ways across modules each fail lint:i18n-glossary.
Verified rendered, not just compiled: with PARAGLIDE_LOCALE=es-419 the server
renders lang="es-419" and the Spanish strings with JS off, so the locale is
resolved in the paraglide ALS scope and not patched in at hydration. Light and
dark, 1440px and 390px, no console errors. Spanish is ~31% wider than English
across these 18 strings (Add -> Agregar is +96%), but nothing clipped: the
surfaces checked size to their widest label, not to the English one.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FGxHcE92k4t3d79y6DQfi3
---
messages/es-419/common.json | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/common.json b/messages/es-419/common.json
index 006f618aa..ebb860c98 100644
--- a/messages/es-419/common.json
+++ b/messages/es-419/common.json
@@ -1,3 +1,21 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "common_back": "Atrás",
+ "common_continue": "Continuar",
+ "common_next": "Siguiente",
+ "common_delete": "Eliminar",
+ "common_cancel": "Cancelar",
+ "common_close": "Cerrar",
+ "common_clear": "Borrar",
+ "common_edit": "Editar",
+ "common_copied": "Copiado",
+ "common_save": "Guardar",
+ "common_done": "Listo",
+ "common_remove": "Quitar",
+ "common_undo": "Deshacer",
+ "common_add": "Agregar",
+ "common_loading": "Cargando…",
+ "common_saving": "Guardando…",
+ "common_redo": "Rehacer",
+ "common_copy": "Copiar"
}
From a0669771f167cb7e03b97551a75db2e1f1bc656f Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 02:06:17 +0800
Subject: [PATCH 012/111] i18n(es-419): translate labels.json (99 keys)
Carries the severity scale the rest of the catalogue inherits: Satisfactory /
Monitor / Defect become Satisfactorio / Vigilar / Defecto, per the glossary's
rating table.
Settles the one decision this module could not avoid. A status word attaches to
a feminine noun in one module and a masculine one in the next -- inspeccion,
informe, factura, acuerdo -- while the consistency check forces exactly one
Spanish string per English string. 'Published' alone labels an inspections tab
and two report states in this file. Status labels are therefore masculine
singular, agreeing with the implicit estado, which is the only form that needs
no divergence declaration anywhere in the remaining catalogue. Prose hints still
agree normally: 'Cancelled inspections' is a sentence, not a chip.
The glossary said Published -> Publicado and Scheduled -> programada in adjacent
rows; that contradiction is resolved here rather than left for the next module
to guess at.
Coverage 33 -> 132, exactly the 99 keys this module holds.
---
docs/developers/i18n-glossary.md | 56 ++++++++++++++++-
messages/es-419/labels.json | 101 ++++++++++++++++++++++++++++++-
2 files changed, 154 insertions(+), 3 deletions(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index 60759e39a..a4144edd0 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -92,7 +92,7 @@ catalogue.
| Booking | reserva | — | The public self-scheduling flow. "Online Booking" → "Reservas en línea". |
| Appointment | cita | — | A scheduled visit. Deliberately a different word from *reserva*. |
| Schedule (noun) | agenda | — | |
-| Schedule (verb) | programar | — | "Scheduled" → *programada*. |
+| Schedule (verb) | programar | — | The verb. For the *status* "Scheduled" see the Status labels section — it is *Programado*, masculine, and that section explains why. |
| Invoice | factura | — | |
| Estimate | presupuesto | — | Not *estimado*, which reads as a guess rather than a priced offer. |
| Agreement | acuerdo | — | The document itself stays in English; this is the word for it in chrome. |
@@ -101,6 +101,15 @@ catalogue.
| Property | propiedad | — | |
| Licenses & affiliations | licencias y afiliaciones | — | The `settings_profile_credentials_*` family. This is licences plus association memberships — **not** login credentials. |
| Credentials (sign-in / provider secrets) | credenciales | — | Only for authentication: the login form, and email/SMS/accounting provider secrets. Never for the licences feature above. |
+| Library | biblioteca | — | The reusable-content area: templates, canned comments, repair items, tags, agreements, rating systems. |
+| Marketplace | Marketplace | — | Left in English. It is a feature name, it is the word Latin American software actually uses, and the `MP` badge that abbreviates it has no Spanish equivalent. *Mercado* is deliberately not banned — "market value" is a legitimate phrase elsewhere in the product. |
+| Dashboard | panel | — | "Back to Dashboard" → *Volver al panel*. Not *tablero*, which this product needs for the electrical panel. |
+| Tag | etiqueta | — | The library tagging feature. |
+| Label (of a template item) | etiqueta | — | The same word as Tag, on purpose. Both are *etiqueta* in ordinary Spanish, they never appear on the same surface, and inventing *rótulo* for one of them would be a word nobody uses to avoid a collision nobody sees. |
+| Severity | gravedad | — | Not *severidad*, which is an anglicism in this sense. |
+| Rating | calificación | — | "Rating system" → *sistema de calificación*; "Rating icons" → *iconos de calificación*. |
+| Est. min / Est. max | Est. mín / Est. máx | — | The abbreviated repair-cost range on a repair item. Kept abbreviated because the field is a narrow numeric input, and *est.* abbreviates *estimado* in Spanish exactly as it does in English. The Estimate row still bans the unabbreviated *estimado*: that ban is about the noun for a priced offer, not about this abbreviation. |
+| Amended (a report) | modificado | — | "Report amended" → *Informe modificado*. The feature is a revision after publication, not a legislative amendment, so not *enmendado*. |
## Roles and parties
@@ -160,8 +169,10 @@ vocabulary**: the commercial standard excludes routine maintenance from
| English | es-419 | Never | Why |
|---|---|---|---|
-| Satisfactory | Satisfactorio | — | |
+| Satisfactory | Satisfactorio | — | The top residential tier. |
| Monitor | Vigilar | monitorear | The middle tier. "Monitorear" is a calque and long for a chip. |
+| Defect (severity level) | Defecto | — | The bottom residential tier, capitalised as a chip. Same word as the Defect product noun; the two must not drift apart. |
+| N/A (severity level) | N/A | — | Left as written. In Spanish *N/A* abbreviates *no aplica* — the same abbreviation with the same expansion, so translating it would only make it longer. |
| Not Inspected | No inspeccionado | — | |
| Not Present | No presente | — | |
| Deficient | Deficiente | — | The commercial/TREC wording. |
@@ -171,6 +182,47 @@ vocabulary**: the commercial standard excludes routine maintenance from
| Marginal | Marginal | — | |
| Maintenance | Mantenimiento | — | |
+## Status labels
+
+A status word attaches to a different noun in every module: an inspection
+(*inspección*, feminine), a report (*informe*, masculine), an invoice
+(*factura*, feminine), an agreement (*acuerdo*, masculine), an event
+(*evento*, masculine). The same English word therefore cannot both agree with
+its subject and stay consistent — and the consistency check forces exactly one
+Spanish string per English string. "Published" already labels an inspections tab
+*and* two report states in `labels.json` alone.
+
+So **status labels are masculine singular**, agreeing with the implicit
+*estado*: "Cancelado", never "Cancelada". This is the only form that scales to
+the whole catalogue — it needs no divergence declaration in any module — and it
+is what a chip actually means: it names the state, not the thing.
+
+**Prose is different.** A hint that reads "Cancelled inspections" is a sentence,
+not a chip, and agrees normally: *Inspecciones canceladas*. The rule above binds
+the standalone label only.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Requested | Solicitado | — | |
+| Scheduled (status) | Programado | — | Masculine by the rule above. This is the row that governs the status chip; the Schedule (verb) row governs the verb. |
+| Confirmed | Confirmado | — | |
+| Completed | Completado | — | |
+| Cancelled | Cancelado | — | |
+| Active | Activo | — | |
+| In Progress | En curso | — | Not *En progreso*, a calque. |
+| Submitted | Enviado | — | The report was sent for review; same participle as Send. |
+| Published (status) | Publicado | — | |
+| Paid | Pagado | — | "Partially paid" → *Pagado parcialmente*. |
+| Signed | Firmado | — | |
+| Viewed | Visto | — | |
+| Declined | Rechazado | — | |
+| Expired | Vencido | — | |
+| Not sent | No enviado | — | The "Not …" agreement/invoice states all take this shape: *No requerido*, *Sin facturar*, *Sin factura*. |
+| Awaiting X | En espera de X | — | "Awaiting payment" → *En espera de pago*; "Awaiting signature" → *En espera de firma*; "Awaiting report" → *En espera del informe*. One shape for the whole family. |
+| All (filter / tab) | Todo | — | The uncountable form. The same English "All" labels an inspection filter, an inspection-status tab, a canned-comment tab and a marketplace tab; *Todo* is the only form that agrees with all four and sits correctly beside the singular status labels next to it. *Todos* is deliberately not banned — it is correct in ordinary prose ("a todos los destinatarios"). |
+
## Recurring UI verbs and states
These are a few hundred keys between them. One word each.
diff --git a/messages/es-419/labels.json b/messages/es-419/labels.json
index 006f618aa..e52a43a36 100644
--- a/messages/es-419/labels.json
+++ b/messages/es-419/labels.json
@@ -1,3 +1,102 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "label_col_property_address": "Dirección de la propiedad",
+ "label_col_client_name": "Nombre del cliente",
+ "label_col_inspection_date": "Fecha de inspección",
+ "label_col_inspector": "Inspector",
+ "label_col_status_icons": "Iconos de estado",
+ "label_col_defect_counts": "Conteo de defectos",
+ "label_col_agent": "Agente",
+ "label_col_price": "Precio",
+ "label_col_closing_date": "Fecha de cierre",
+ "label_col_reference_number": "N.º de referencia",
+ "label_col_referral_source": "Fuente de referencia",
+ "label_col_property_facts": "Datos de la propiedad",
+ "label_filter_all": "Todo",
+ "label_filter_past": "Pasado",
+ "label_filter_yesterday": "Ayer",
+ "label_filter_today": "Hoy",
+ "label_filter_tomorrow": "Mañana",
+ "label_filter_this_week": "Esta semana",
+ "label_filter_future": "Futuro",
+ "label_filter_needs_confirmation": "Requiere confirmación",
+ "label_filter_awaiting_report": "En espera del informe",
+ "label_tab_all": "Todo",
+ "label_tab_active": "Activo",
+ "label_tab_requested": "Solicitado",
+ "label_tab_to_review": "Por revisar",
+ "label_tab_awaiting_payment": "En espera de pago",
+ "label_tab_published": "Publicado",
+ "label_tab_cancelled": "Cancelado",
+ "label_bucket_needs_attention": "Requiere atención",
+ "label_bucket_needs_attention_hint": "Inspecciones que requieren acción",
+ "label_bucket_today": "Hoy",
+ "label_bucket_today_hint": "Programadas para hoy",
+ "label_bucket_this_week": "Esta semana",
+ "label_bucket_this_week_hint": "Próximas esta semana",
+ "label_bucket_later": "Más adelante",
+ "label_bucket_later_hint": "Inspecciones futuras",
+ "label_bucket_recent_reports": "Informes recientes",
+ "label_bucket_recent_reports_hint": "Completados recientemente",
+ "label_bucket_cancelled": "Cancelado",
+ "label_bucket_cancelled_hint": "Inspecciones canceladas",
+ "label_hub_agreement_not_sent": "No enviado",
+ "label_hub_agreement_not_required": "No requerido",
+ "label_hub_agreement_awaiting_signature": "En espera de firma",
+ "label_hub_agreement_viewed": "Visto",
+ "label_hub_agreement_signed": "Firmado",
+ "label_hub_agreement_declined": "Rechazado",
+ "label_hub_agreement_expired": "Vencido",
+ "label_hub_invoice_not_invoiced": "Sin facturar",
+ "label_hub_invoice_none": "Sin factura",
+ "label_hub_invoice_draft": "Borrador",
+ "label_hub_invoice_awaiting_payment": "En espera de pago",
+ "label_hub_invoice_partially_paid": "Pagado parcialmente",
+ "label_hub_invoice_remaining": "{amount} pendiente",
+ "label_hub_invoice_remaining_unknown": "El monto recibido no está registrado",
+ "label_hub_invoice_paid": "Pagado",
+ "label_hub_report_in_progress": "En curso",
+ "label_hub_report_submitted": "Enviado",
+ "label_hub_report_published": "Publicado",
+ "label_severity_good": "Satisfactorio",
+ "label_severity_marginal": "Vigilar",
+ "label_severity_significant": "Defecto",
+ "label_severity_minor": "N/A",
+ "label_status_requested": "Solicitado",
+ "label_status_scheduled": "Programado",
+ "label_status_confirmed": "Confirmado",
+ "label_status_completed": "Completado",
+ "label_status_cancelled": "Cancelado",
+ "label_status_report_in_progress": "En curso",
+ "label_status_report_submitted": "Enviado",
+ "label_status_report_published": "Publicado",
+ "label_cap_publish": "Publicar informes",
+ "label_cap_schedule_others": "Programar para otros",
+ "label_cap_financial": "Datos financieros",
+ "label_cap_manage_contacts": "Gestionar contactos",
+ "label_doccategory_prior_reports": "Informes anteriores",
+ "label_doccategory_plans_drawings": "Planos y dibujos",
+ "label_doccategory_environmental": "Ambiental",
+ "label_doccategory_leases_financials": "Contratos de arrendamiento y finanzas",
+ "label_doccategory_permits_certificates": "Permisos y certificados",
+ "label_doccategory_photos": "Fotos",
+ "label_doccategory_other": "Otro",
+ "label_trigger_inspection_created": "Inspección creada",
+ "label_trigger_inspection_confirmed": "Inspección confirmada",
+ "label_trigger_inspection_cancelled": "Inspección cancelada",
+ "label_trigger_inspection_reminder": "Antes de la inspección (recordatorio)",
+ "label_trigger_report_published": "Informe publicado",
+ "label_trigger_report_amended": "Informe modificado",
+ "label_trigger_invoice_created": "Factura creada",
+ "label_trigger_payment_received": "Pago recibido",
+ "label_trigger_agreement_signed": "Acuerdo firmado",
+ "label_trigger_agreement_signer_signed": "Un firmante firmó",
+ "label_trigger_agreement_viewed": "Acuerdo visto",
+ "label_trigger_agreement_declined": "Acuerdo rechazado",
+ "label_trigger_agreement_expired": "Acuerdo vencido",
+ "label_trigger_event_created": "Evento creado",
+ "label_trigger_event_completed": "Evento completado",
+ "label_cap_view_communication": "Ver mensajes y avisos enviados",
+ "label_trigger_booking_received": "Reserva recibida",
+ "label_trigger_inspection_completed": "Inspección completada"
}
From 590991e6831dbc8feee916b4bee1b3f1959f87d9 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 02:08:33 +0800
Subject: [PATCH 013/111] i18n(es-419): translate library.json (191 keys)
The severity trio repeats here (repair_items_severity_*) and matches labels.json
exactly, which is the point of doing these two together.
Two collisions worth naming. 'Tag' and 'Label' are both etiqueta: they never
share a surface, and inventing rotulo for one of them would spend a word nobody
uses on a clash nobody sees. 'Est. min'/'Est. max' stay abbreviated -- the field
is a narrow numeric input, est. abbreviates estimado in Spanish exactly as in
English, and the glossary's ban on estimado is about the noun for a priced
offer, not this abbreviation. Both are now glossary rows.
Marketplace is left in English: it is a feature name, it is the word the region's
software uses, and the MP badge that abbreviates it has no Spanish equivalent.
Coverage 132 -> 323, exactly the 191 keys this module holds.
---
messages/es-419/library.json | 193 ++++++++++++++++++++++++++++++++++-
1 file changed, 192 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/library.json b/messages/es-419/library.json
index 006f618aa..3728978e1 100644
--- a/messages/es-419/library.json
+++ b/messages/es-419/library.json
@@ -1,3 +1,194 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "library_action_confirm": "¿Confirmar?",
+ "library_layout_title": "Biblioteca",
+ "library_hub_templates_title": "Plantillas",
+ "library_hub_templates_desc": "Plantillas y secciones de informes.",
+ "library_hub_comments_title": "Comentarios predefinidos",
+ "library_hub_comments_desc": "Comentarios narrativos reutilizables.",
+ "library_hub_repair_items_title": "Elementos de reparación",
+ "library_hub_repair_items_desc": "Elementos de reparación guardados.",
+ "library_hub_tags_title": "Etiquetas",
+ "library_hub_tags_desc": "Etiquetas de inspecciones y contactos.",
+ "library_hub_agreements_title": "Acuerdos",
+ "library_hub_agreements_desc": "Plantillas de acuerdos previos a la inspección.",
+ "library_hub_rating_systems_title": "Sistemas de calificación",
+ "library_hub_rating_systems_desc": "Escalas de calificación de condición.",
+ "library_hub_defect_categories_title": "Categorías de defectos",
+ "library_hub_defect_categories_desc": "Agrupe los defectos para los resúmenes del informe.",
+ "library_hub_marketplace_title": "Marketplace",
+ "library_hub_marketplace_desc": "Contenido compartido de la comunidad.",
+ "library_defect_meta_title": "Categorías de defectos - OpenInspection",
+ "library_defect_heading": "Categorías de defectos",
+ "library_defect_meta_one": "{count} categoría",
+ "library_defect_meta_other": "{count} categorías",
+ "library_defect_new": "+ Nueva categoría",
+ "library_defect_empty_title": "Aún no hay categorías de defectos",
+ "library_defect_empty_desc": "Haga clic en \"+ Nueva categoría\" arriba para agrupar los defectos en los informes.",
+ "library_defect_summary_badge": "Resumen",
+ "library_rating_meta_title": "Sistemas de calificación - OpenInspection",
+ "library_rating_heading": "Sistemas de calificación",
+ "library_rating_meta_one": "{count} sistema",
+ "library_rating_meta_other": "{count} sistemas",
+ "library_rating_new": "+ Nuevo sistema de calificación",
+ "library_rating_empty_title": "Aún no hay sistemas de calificación",
+ "library_rating_empty_desc": "Haga clic en \"+ Nuevo sistema de calificación\" arriba para definir cómo se califican los elementos durante las inspecciones.",
+ "library_rating_default_badge": "Predeterminado",
+ "library_rating_err_invalid_levels": "Niveles no válidos",
+ "library_rating_err_save_failed": "No se pudo guardar el sistema de calificación",
+ "library_tags_meta_title": "Etiquetas - OpenInspection",
+ "library_tags_heading": "Etiquetas",
+ "library_tags_meta": "{count} etiquetas",
+ "library_tags_add": "+ Agregar etiqueta",
+ "library_tags_empty_title": "Aún no hay etiquetas",
+ "library_tags_empty_desc": "Haga clic en \"+ Agregar etiqueta\" arriba para organizar su biblioteca con etiquetas.",
+ "library_tags_col_name": "Nombre",
+ "library_tags_col_color": "Color",
+ "library_tags_col_used": "En uso",
+ "library_tags_col_actions": "Acciones",
+ "library_agreements_meta_title": "Acuerdos - OpenInspection",
+ "library_agreements_heading": "Acuerdos",
+ "library_agreements_meta_templates": "{templates} plantillas",
+ "library_agreements_new": "+ Nuevo acuerdo",
+ "library_agreements_tab_templates": "Plantillas",
+ "library_agreements_tab_signing": "Firmas",
+ "library_agreements_field_agreement": "Acuerdo",
+ "library_agreements_select_template": "Seleccione una plantilla…",
+ "library_agreements_untitled": "Sin título",
+ "library_agreements_field_inspection": "Inspección",
+ "library_agreements_select_inspection": "Seleccione una inspección…",
+ "library_agreements_send_for_signing": "Enviar para firma",
+ "library_agreements_sent": "Enviado — se enviaron por correo electrónico los enlaces a los firmantes.",
+ "library_agreements_empty_templates_title": "Aún no hay plantillas de acuerdos",
+ "library_agreements_empty_signing_title": "Aún no hay acuerdos firmados",
+ "library_agreements_empty_templates_desc": "Haga clic en \"+ Nuevo acuerdo\" arriba para crear su primera plantilla de acuerdo.",
+ "library_agreements_empty_signing_desc": "Los acuerdos firmados aparecerán aquí después de que los clientes completen el proceso de firma.",
+ "library_agreements_col_title": "Título",
+ "library_agreements_col_last_updated": "Última actualización",
+ "library_agreements_col_client": "Cliente",
+ "library_agreements_col_status": "Estado",
+ "library_agreements_col_actions": "Acciones",
+ "library_agreements_sign_title": "Firma del inspector",
+ "library_agreements_sign_desc": "Dibuje su firma a continuación. Esto prefirmará el acuerdo; el cliente firma por separado después de que usted lo envíe.",
+ "library_agreements_save_signature": "Guardar firma",
+ "library_agreements_err_missing_request_id": "Falta requestId",
+ "library_agreements_err_api_status": "API {status}",
+ "library_agreements_err_remind_throttled": "Ya se envió un recordatorio en la última hora. Inténtelo de nuevo más tarde.",
+ "library_agreements_err_signer_not_awaiting": "Este firmante ya no está en espera de firma.",
+ "library_agreements_err_remind_failed": "No se pudo enviar el recordatorio ({status}).",
+ "library_agreements_err_malformed_signers": "Los datos de los firmantes tienen un formato incorrecto.",
+ "library_agreements_err_pick_template": "Elija una plantilla de acuerdo.",
+ "library_agreements_err_pick_inspection": "Elija una inspección.",
+ "library_agreements_err_no_signers": "Agregue al menos un firmante.",
+ "library_agreements_err_send_failed": "No se pudo enviar ({status}): {detail}",
+ "library_agreements_err_link_failed": "No se pudo obtener el enlace ({status}).",
+ "library_agreements_err_missing_envelope": "Falta envelopeId o signatureBase64",
+ "library_agreements_err_api_returned": "La API devolvió {status}: {detail}",
+ "comments_meta_title": "Comentarios predefinidos - OpenInspection",
+ "comments_heading": "Comentarios predefinidos",
+ "comments_meta": "{count} en la biblioteca",
+ "comments_add": "+ Agregar comentario",
+ "comments_tab_all": "Todo",
+ "comments_empty_title": "Aún no hay comentarios",
+ "comments_empty_desc": "Haga clic en \"+ Agregar comentario\" arriba para crear su primer fragmento de comentario.",
+ "comments_delete_title": "Eliminar comentario",
+ "comments_delete_message": "¿Eliminar \"{text}\"? Esta acción no se puede deshacer. Los informes que ya usan este texto lo conservan.",
+ "comments_delete_many_title": "Eliminar comentarios",
+ "comments_delete_many_message": "¿Eliminar {count} comentarios? Esta acción no se puede deshacer. Los informes que ya usan su texto lo conservan.",
+ "comments_delete_selected": "Eliminar seleccionados ({count})",
+ "comments_delete_failed": "No se pudo eliminar. No se quitó nada - inténtelo de nuevo.",
+ "comments_select_label": "Seleccionar este comentario",
+ "comments_select_all": "Seleccionar todo en esta página",
+ "comments_clear_selection": "Borrar selección",
+ "repair_items_meta_title": "Elementos de reparación - OpenInspection",
+ "repair_items_heading": "Elementos de reparación",
+ "repair_items_meta": "{count} en la biblioteca",
+ "repair_items_add": "+ Agregar elemento",
+ "repair_items_empty_title": "Aún no hay elementos de reparación",
+ "repair_items_empty_desc": "Haga clic en \"+ Agregar elemento\" arriba para crear su primer elemento de reparación.",
+ "repair_items_modal_edit_title": "Editar elemento de reparación",
+ "repair_items_modal_new_title": "Nuevo elemento de reparación",
+ "repair_items_field_name": "Nombre",
+ "repair_items_field_category": "Categoría",
+ "repair_items_field_severity": "Gravedad",
+ "repair_items_field_summary": "Resumen de la reparación",
+ "repair_items_field_est_min": "Est. mín",
+ "repair_items_field_est_max": "Est. máx",
+ "repair_items_field_contractor": "Contratista recomendado",
+ "repair_items_placeholder_name": "p. ej., Reemplazar interruptor con doble conexión",
+ "repair_items_placeholder_category": "Eléctrico",
+ "repair_items_severity_good": "Satisfactorio",
+ "repair_items_severity_marginal": "Vigilar",
+ "repair_items_severity_significant": "Defecto",
+ "repair_items_contractor_none": "— ninguno —",
+ "repair_items_delete_title": "Eliminar elemento de reparación",
+ "repair_items_delete_message": "¿Eliminar \"{name}\"? Esta acción no se puede deshacer.",
+ "marketplace_meta_title": "Marketplace - OpenInspection",
+ "marketplace_heading": "Marketplace",
+ "marketplace_meta": "{count} disponibles",
+ "marketplace_tab_all": "Todo",
+ "marketplace_tab_templates": "Plantillas",
+ "marketplace_tab_comments": "Comentarios",
+ "marketplace_tab_agreements": "Acuerdos",
+ "marketplace_empty_title": "Marketplace está vacío",
+ "marketplace_empty_desc": "Aquí aparecerán las plantillas y los paquetes de contenido de la comunidad.",
+ "marketplace_install": "Instalar",
+ "marketplace_installing": "Instalando…",
+ "marketplace_install_error": "No se pudo instalar esta plantilla. Inténtelo de nuevo.",
+ "notifications_meta_title": "Notificaciones - OpenInspection",
+ "notifications_heading": "Notificaciones",
+ "notifications_meta": "{count} notificaciones",
+ "notifications_empty_title": "No hay notificaciones",
+ "notifications_empty_desc": "Está al día.",
+ "docs_meta_title": "Documentación de la API - OpenInspection",
+ "misc_not_found_meta_title": "Página no encontrada - OpenInspection",
+ "misc_not_found_heading": "Página no encontrada",
+ "misc_not_found_desc": "La página que busca no existe o se ha movido.",
+ "misc_not_found_home": "Ir al inicio",
+ "misc_feature_disabled_meta_title": "Función deshabilitada - OpenInspection",
+ "misc_feature_disabled_heading": "Función no disponible",
+ "misc_feature_disabled_desc": "Esta función no está habilitada en su espacio de trabajo. Comuníquese con su administrador o mejore su plan.",
+ "misc_feature_disabled_back": "Volver al panel",
+ "misc_version_diff_meta_title": "Comparación de versiones - OpenInspection",
+ "misc_version_diff_err_not_found": "Versión no encontrada",
+ "misc_version_diff_err_unavailable": "Servicio no disponible",
+ "misc_version_diff_error_heading": "Comparación de versiones",
+ "misc_version_diff_back_inspection": "Volver a la inspección",
+ "misc_version_diff_title": "Cambios de la versión {version}",
+ "misc_version_diff_meta_one": "Inspección n.º {id} — {count} cambio",
+ "misc_version_diff_meta_other": "Inspección n.º {id} — {count} cambios",
+ "misc_version_diff_back_editor": "Volver al editor",
+ "misc_version_diff_no_changes": "No hay cambios en esta versión.",
+ "misc_version_diff_col_field": "Campo",
+ "misc_version_diff_col_before": "Antes",
+ "misc_version_diff_col_after": "Después",
+ "misc_version_diff_empty_value": "vacío",
+ "misc_version_diff_added": "Agregado",
+ "misc_version_diff_removed": "Quitado",
+ "dashboard_row_no_address": "Sin dirección",
+ "dashboard_row_no_client": "Sin cliente",
+ "dashboard_row_open_editor": "Abrir editor",
+ "dashboard_row_view_report": "Ver informe",
+ "dashboard_row_status_requested": "Solicitado",
+ "dashboard_row_status_scheduled": "Programado",
+ "dashboard_row_status_confirmed": "Confirmado",
+ "dashboard_row_status_completed": "Completado",
+ "dashboard_row_status_cancelled": "Cancelado",
+ "dashboard_onboarding_title": "Primeros pasos",
+ "dashboard_onboarding_progress": "{done} de {total} completados",
+ "dashboard_onboarding_dismiss": "Descartar",
+ "dashboard_columns_title": "Personalizar columnas",
+ "dashboard_columns_required": "(obligatorio)",
+ "dashboard_columns_reset": "Restablecer valores predeterminados",
+ "dashboard_filters_title": "Filtros",
+ "dashboard_filters_reset": "Restablecer",
+ "dashboard_filters_apply": "Aplicar",
+ "dashboard_filters_date_from": "Fecha desde",
+ "dashboard_filters_date_to": "Fecha hasta",
+ "dashboard_filters_agent_id": "ID del agente",
+ "dashboard_skeleton_loading": "Cargando inspecciones…",
+ "dashboard_toolbar_search": "Buscar...",
+ "dashboard_toolbar_filters": "Filtros",
+ "dashboard_toolbar_columns": "Columnas",
+ "notifications_load_failed": "No se pudieron cargar las notificaciones. Esta lista puede estar incompleta — vuelva a cargar antes de suponer que no hay nada aquí."
}
From 2d55fbb56547a6519c00a7cbc6a45c5c125dcca0 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 02:10:17 +0800
Subject: [PATCH 014/111] i18n(es-419): translate templates.json (114 keys)
Third file carrying the severity trio (templates_mapping_row2_*), and it agrees
with labels.json and library.json.
The Spectora mapping panel keeps Spectora's vocabulary on the left-hand side:
'Orders' is translated as Ordenes because that is what the competitor calls the
thing being mapped away. The glossary's ban is on 'orden de trabajo' as a name
for our own Inspection, which this is not.
Coverage 323 -> 437, exactly the 114 keys this module holds.
---
messages/es-419/templates.json | 116 ++++++++++++++++++++++++++++++++-
1 file changed, 115 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/templates.json b/messages/es-419/templates.json
index 006f618aa..2a74b6e68 100644
--- a/messages/es-419/templates.json
+++ b/messages/es-419/templates.json
@@ -1,3 +1,117 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "templates_list_meta_title": "Plantillas - OpenInspection",
+ "templates_breadcrumb_library": "Biblioteca",
+ "templates_breadcrumb_current": "Plantillas",
+ "templates_list_heading": "Plantillas de inspección",
+ "templates_list_count_one": "{count} plantilla",
+ "templates_list_count_other": "{count} plantillas",
+ "templates_list_meta_imported": "{count} importadas de Marketplace",
+ "templates_list_meta_updates": "{count} con actualizaciones disponibles",
+ "templates_action_import_spectora": "Importar Spectora",
+ "templates_action_new_template": "+ Nueva plantilla",
+ "templates_search_placeholder": "Buscar plantillas...",
+ "templates_sort_date": "Última modificación",
+ "templates_sort_usage": "Más usadas",
+ "templates_view_cards": "Tarjetas",
+ "templates_view_list": "Lista",
+ "templates_create_error_name_required": "El nombre es obligatorio",
+ "templates_create_error_failed": "No se pudo crear",
+ "templates_duplicate_error_failed": "Error al duplicar",
+ "templates_import_error_name_json_required": "El nombre y el JSON son obligatorios",
+ "templates_import_error_invalid_json": "JSON no válido",
+ "templates_import_error_failed": "Error al importar",
+ "templates_duplicate_copy_suffix": "{name} (copia)",
+ "templates_create_title": "Nueva plantilla",
+ "templates_create_submit": "Crear plantilla",
+ "templates_name_label": "Nombre de la plantilla",
+ "templates_create_name_placeholder": "p. ej. Residencial completa",
+ "templates_delete_title": "Eliminar plantilla",
+ "templates_delete_body": "¿Seguro que desea eliminar esta plantilla? Esta acción no se puede deshacer.",
+ "templates_import_title": "Importar desde Spectora",
+ "templates_import_submit": "Importar",
+ "templates_import_name_placeholder": "p. ej. Spectora Residencial",
+ "templates_import_json_label": "JSON de exportación de Spectora",
+ "templates_import_json_placeholder": "Pegue aquí el JSON de exportación de Spectora...",
+ "templates_mapping_title": "¿Viene de Spectora?",
+ "templates_mapping_review_settings": "Revisar la configuración del editor",
+ "templates_mapping_dismiss": "Entendido",
+ "templates_mapping_intro": "Así se corresponden los conceptos de Spectora con OpenInspection.",
+ "templates_mapping_row1_from": "Comentarios",
+ "templates_mapping_row1_to1": "Defectos",
+ "templates_mapping_row1_to2": "Notas",
+ "templates_mapping_row1_desc": "Su biblioteca de comentarios se convierte en defectos predefinidos; el texto libre va en Notas.",
+ "templates_mapping_row2_from": "Iconos de calificación",
+ "templates_mapping_row2_to1": "Satisfactorio",
+ "templates_mapping_row2_to2": "Vigilar",
+ "templates_mapping_row2_to3": "Defecto",
+ "templates_mapping_row2_desc": "Las calificaciones son botones con la palabra completa y colores semánticos.",
+ "templates_mapping_row3_from": "Órdenes",
+ "templates_mapping_row3_to1": "Inspecciones",
+ "templates_mapping_row3_to2": "Facturas",
+ "templates_mapping_row3_desc": "Una orden = una inspección más su factura y su acuerdo.",
+ "templates_empty_search": "Ninguna plantilla coincide con su búsqueda.",
+ "templates_empty_title": "Comience con una plantilla",
+ "templates_empty_body": "Su espacio de trabajo incluye plantillas iniciales — pero si está migrando, traiga las suyas.",
+ "templates_empty_new": "+ Nueva plantilla",
+ "templates_col_name": "Nombre",
+ "templates_col_version": "Versión",
+ "templates_col_items": "Elementos",
+ "templates_col_actions": "Acciones",
+ "templates_badge_marketplace": "Marketplace",
+ "templates_row_items": "{count} elementos",
+ "templates_action_duplicate": "Duplicar",
+ "templates_card_empty_search_title": "No hay plantillas coincidentes",
+ "templates_card_empty_search_body": "Pruebe con otro término de búsqueda.",
+ "templates_card_used": "usada {count}×",
+ "templates_card_badge_mp": "MP",
+ "templates_item_label": "Etiqueta",
+ "templates_item_description": "Descripción",
+ "templates_item_type": "Tipo",
+ "templates_item_required": "Obligatorio",
+ "templates_item_safety": "Elemento de seguridad",
+ "templates_item_choices": "Opciones (una por línea)",
+ "templates_item_min": "Mín",
+ "templates_item_max": "Máx",
+ "templates_item_default_recommendation": "Recomendación predeterminada",
+ "templates_comments_browse_library": "Explorar biblioteca",
+ "templates_comments_add": "+ Agregar",
+ "templates_comments_move_up": "Subir",
+ "templates_comments_move_down": "Bajar",
+ "templates_comments_title_placeholder": "Título",
+ "templates_comments_delete_aria": "Eliminar comentario",
+ "templates_comments_abbr_placeholder": "abrev",
+ "templates_comments_abbr_title": "Código corto — escriba esto en el informe para completar este comentario",
+ "templates_comments_text_placeholder": "Texto del comentario...",
+ "templates_section_disclaimer_placeholder": "Aviso legal de la sección (opcional)",
+ "templates_property_type_label": "Tipo de propiedad",
+ "templates_property_type_unspecified": "Sin especificar (unifamiliar)",
+ "templates_property_type_single_family": "Unifamiliar",
+ "templates_property_type_multi_unit": "Multifamiliar",
+ "templates_property_type_commercial": "Comercial",
+ "templates_subtype_label": "Subtipo",
+ "templates_subtype_all": "Todo comercial",
+ "templates_edit_meta_title": "Editar plantilla - OpenInspection",
+ "templates_edit_error_no_schema": "Sin esquema",
+ "templates_edit_error_save_failed": "No se pudo guardar",
+ "templates_edit_untitled": "Plantilla sin título",
+ "templates_edit_appearance_label": "Apariencia predeterminada del informe",
+ "templates_edit_appearance_help": "Las inspecciones nuevas creadas con esta plantilla usan esta apariencia, a menos que se anule.",
+ "templates_edit_appearance_inherit": "Heredar el valor predeterminado de la empresa",
+ "templates_edit_new_section": "Nueva sección",
+ "templates_edit_new_item": "Nuevo elemento",
+ "templates_edit_new_entry": "Nueva entrada",
+ "templates_edit_item_copy_suffix": "{label} (copia)",
+ "templates_edit_exit_preview": "Salir de la vista previa",
+ "templates_edit_preview": "Vista previa",
+ "templates_edit_rating_system": "Sistema de calificación",
+ "templates_edit_saving": "Guardando...",
+ "templates_edit_saved": "¡Guardado!",
+ "templates_edit_empty_add_section": "Agregue una sección para comenzar",
+ "templates_edit_tab_properties": "Propiedades",
+ "templates_edit_tab_comments": "Comentarios",
+ "templates_edit_error_not_found": "No se encontró esta plantilla. Es posible que se haya eliminado.",
+ "templates_edit_error_forbidden": "No tiene permiso para editar esta plantilla.",
+ "templates_edit_error_generic": "Se produjo un error al abrir el editor de plantillas.",
+ "templates_edit_error_back": "Volver a Plantillas"
}
From ebd9df44b86240ce7b4ab924271081bc643590a7 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 07:26:38 +0800
Subject: [PATCH 015/111] feat(agreements): neutral language disclosure, not a
contractual clause
Counsel advised against embedding InterNACHI's governing-language clause as
platform contractual language: it allocates risk between the tenant and their
client, and we are not a party, author none of the agreement text, and control
none of its terms.
This states a fact and decides nothing. A test forbids govern/prevail/controls/
binding/shall, because "strengthening the wording" is the edit that would undo
it.
Containment is structural, not advisory. The copy is a ,
and the agreement body's write-time sanitizer allows neither -- so the
disclosure cannot pass through the agreement pipeline and come out intact. Two
guards hold the line: the spec asserts the agreement sanitizer destroys it, and
a source scan asserts nothing on the agreement-body path imports the module.
DOMPurify is deliberately not exercised here. Under happy-dom it drops the
outermost element and applies no allow-list at all, so a round-trip assertion
would have been a green that proved nothing about a browser. The spec reads the
two real allow-lists from source instead.
---
.../legal/agreement-language-disclosure.ts | 68 +++++++
.../agreements/language-disclosure.spec.ts | 185 ++++++++++++++++++
2 files changed, 253 insertions(+)
create mode 100644 server/lib/legal/agreement-language-disclosure.ts
create mode 100644 tests/unit/agreements/language-disclosure.spec.ts
diff --git a/server/lib/legal/agreement-language-disclosure.ts b/server/lib/legal/agreement-language-disclosure.ts
new file mode 100644
index 000000000..d30cfb34b
--- /dev/null
+++ b/server/lib/legal/agreement-language-disclosure.ts
@@ -0,0 +1,68 @@
+/**
+ * Neutral platform disclosure shown ALONGSIDE an inspection agreement.
+ *
+ * Rewritten on counsel's advice. An earlier design embedded InterNACHI's
+ * governing-language clause into the agreement body. Counsel: "do not embed the
+ * InterNACHI clause as platform contractual language — if implemented, position
+ * it as a neutral platform disclosure."
+ *
+ * A governing-language provision allocates risk between the tenant and their
+ * client. We are not a party to that contract, we author none of its text, and
+ * we control none of its terms. Inserting one would have made it the only
+ * contractual language we wrote, in the one document where we deliberately
+ * write none.
+ *
+ * So: this states a fact and decides nothing. No "governs", no "prevails", no
+ * responsibility for a translation's accuracy. A tenant who wants a
+ * governing-language clause puts it in THEIR agreement text — that is theirs to
+ * write, and nothing here should look like we already wrote it for them.
+ *
+ * ## Why the wrapper matters
+ *
+ * The copy is a ``, and that is load-bearing rather than
+ * decorative. `agreements.content` is TENANT data whose write-time sanitizer
+ * (`server/services/agreement/sanitizer.ts`) allows only the Quill toolbar's
+ * tags — no ``, no `role`. So the disclosure cannot pass through the
+ * agreement pipeline and come out intact: composed into the body it arrives as
+ * an anonymous paragraph among the terms, which is exactly the reading counsel
+ * ruled out. The shape is what makes the wrong thing visibly wrong, and
+ * `tests/unit/agreements/language-disclosure.spec.ts` asserts both halves —
+ * that the agreement sanitizer destroys it, and that nothing on the
+ * agreement-body path imports this module.
+ *
+ * A renderer therefore must NOT reuse ``: its allow-list is the
+ * tenant-content one and would silently eat the wrapper. Use
+ * `DISCLOSURE_SANITIZER_PROFILE` below, which round-trips this copy unchanged.
+ *
+ * ## Why a versioned constant and not a message key
+ *
+ * This is platform legal copy: changing it is a deliberate act with a version
+ * bump, not a string edited inside a component or retranslated by a catalogue
+ * pass. The agreement itself stays English and is never translated here — the
+ * disclosure is how an English-only agreement is handled honestly, not a step
+ * toward translating one.
+ */
+
+/** Version of the disclosure copy. Bump on ANY wording change. */
+const DISCLOSURE_VERSION = 1;
+
+export const AGREEMENT_LANGUAGE_DISCLOSURE = Object.freeze({
+ version: DISCLOSURE_VERSION,
+ html: [
+ '',
+ 'This agreement is provided in English. If you would prefer to review ',
+ 'it in another language, you may wish to have it translated before signing.
',
+ ' ',
+ ].join(''),
+});
+
+/**
+ * The allow-list a renderer must sanitize the disclosure with — plain block
+ * elements plus the wrapper, and nothing that can carry a payload or a link.
+ * Deliberately narrower than a general-purpose profile: this markup is ours and
+ * fixed, so the profile is sized to exactly the copy above.
+ */
+export const DISCLOSURE_SANITIZER_PROFILE = Object.freeze({
+ ALLOWED_TAGS: Object.freeze(['section', 'p', 'strong', 'em', 'br']),
+ ALLOWED_ATTR: Object.freeze(['class', 'role']),
+});
diff --git a/tests/unit/agreements/language-disclosure.spec.ts b/tests/unit/agreements/language-disclosure.spec.ts
new file mode 100644
index 000000000..e8fa018b0
--- /dev/null
+++ b/tests/unit/agreements/language-disclosure.spec.ts
@@ -0,0 +1,185 @@
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, it, expect } from 'vitest';
+import {
+ AGREEMENT_LANGUAGE_DISCLOSURE as D,
+ DISCLOSURE_SANITIZER_PROFILE,
+} from '../../../server/lib/legal/agreement-language-disclosure';
+import { sanitizeAgreementHtml } from '../../../server/services/agreement/sanitizer';
+
+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+
+describe('agreement language disclosure', () => {
+ it('states the fact', () => {
+ expect(D.html).toMatch(/provided in English/i);
+ expect(D.html).toMatch(/translated before signing/i);
+ });
+
+ it('makes NO contractual assertion', () => {
+ // Counsel: a disclosure may state a fact, it may not decide which text
+ // prevails. Every word below allocates risk between two parties we are
+ // not one of. This test is the line, and it is why the plan was rewritten.
+ for (const forbidden of [/govern/i, /prevail/i, /controls?/i,
+ /binding/i, /conflict between/i, /shall/i]) {
+ expect(D.html).not.toMatch(forbidden);
+ }
+ });
+
+ it('does not reproduce the InterNACHI clause', () => {
+ // That wording is written for the INSPECTOR to place in THEIR agreement.
+ // Borrowing it makes the platform the author of a term.
+ expect(D.html).not.toMatch(/QUALIFIED EXPERT TRANSLATE THIS AGREEMENT/i);
+ });
+
+ it('contains no element the sanitizer strips', () => {
+ expect(D.html).not.toMatch(/<(a|img|svg|script|iframe)/i);
+ });
+
+ it('is versioned', () => {
+ expect(D.version).toBeGreaterThan(0);
+ });
+
+ it('is frozen — the copy is not a string a component may edit', () => {
+ expect(Object.isFrozen(D)).toBe(true);
+ });
+});
+
+/** Every element name that appears in `html`, lowercased. */
+function tagsIn(html: string): string[] {
+ return [...html.matchAll(/<\/?([a-z][a-z0-9-]*)\b/gi)].map((m) => m[1].toLowerCase());
+}
+
+/** Every attribute name that appears in `html`, lowercased. */
+function attrsIn(html: string): string[] {
+ return [...html.matchAll(/\s([a-z][a-z0-9-]*)\s*=/gi)].map((m) => m[1].toLowerCase());
+}
+
+// DOMPurify is deliberately NOT invoked in this spec. Measured here: under
+// happy-dom it drops the outermost element and applies no allow-list at all
+// (`hi
` sanitizes to `hi`; a `` nested one level deep
+// survives a profile that permits neither). A round-trip assertion against it
+// would have passed while proving nothing about a browser. What IS faithful is
+// DOMPurify's contract — a tag absent from ALLOWED_TAGS is removed — applied to
+// the two allow-lists read from their real source below.
+describe('agreement language disclosure — what reaches the screen', () => {
+ it('its own render profile covers every tag and attribute in the copy', () => {
+ for (const tag of tagsIn(D.html)) {
+ expect(DISCLOSURE_SANITIZER_PROFILE.ALLOWED_TAGS).toContain(tag);
+ }
+ for (const attr of attrsIn(D.html)) {
+ expect(DISCLOSURE_SANITIZER_PROFILE.ALLOWED_ATTR).toContain(attr);
+ }
+ // The extractor is the test; prove it can see something.
+ expect(tagsIn(D.html)).toContain('section');
+ expect(attrsIn(D.html)).toContain('role');
+ });
+
+ it('the render profile admits no element that could carry a payload', () => {
+ for (const tag of ['a', 'img', 'svg', 'script', 'iframe', 'style', 'form']) {
+ expect(DISCLOSURE_SANITIZER_PROFILE.ALLOWED_TAGS).not.toContain(tag);
+ }
+ for (const attr of DISCLOSURE_SANITIZER_PROFILE.ALLOWED_ATTR) {
+ expect(attr).not.toMatch(/^on|href|src|style/i);
+ }
+ });
+
+ it('the agreement view component would strip the wrapper — so it must not render this', () => {
+ // Read the allow-list from , the component the agreement
+ // body is rendered with, rather than restating it here: a copy would go
+ // stale and turn this into a green that means nothing.
+ const src = readFileSync(join(REPO_ROOT, 'app/components/SanitizedHtml.tsx'), 'utf8');
+ const tags = src.match(/const ALLOWED_TAGS = (\[[^\]]*\])/);
+ const attrs = src.match(/const ALLOWED_ATTR = (\[[^\]]*\])/);
+ expect(tags, 'SanitizedHtml ALLOWED_TAGS moved — this guard went blind').not.toBeNull();
+ expect(attrs, 'SanitizedHtml ALLOWED_ATTR moved — this guard went blind').not.toBeNull();
+ const viewTags: string[] = JSON.parse(tags![1]);
+ const viewAttrs: string[] = JSON.parse(attrs![1]);
+ expect(viewTags).toContain('p');
+
+ // DOMPurify removes any tag outside ALLOWED_TAGS, keeping its children. So
+ // routing the disclosure through the tenant-content component delivers the
+ // sentence with its wrapper gone — a loose paragraph, indistinguishable
+ // from a term. Task: render it with DISCLOSURE_SANITIZER_PROFILE instead.
+ expect(viewTags).not.toContain('section');
+ expect(viewAttrs).not.toContain('role');
+ });
+
+ it('is DESTROYED by the agreement-body sanitizer — it is not agreement content', () => {
+ // sanitizeAgreementHtml() is the write-time sanitizer for `agreements.content`,
+ // and its allow-list is the Quill toolbar: no , no `role`. So the
+ // disclosure cannot travel through the agreement pipeline intact — pasted into
+ // the body it arrives stripped of the wrapper that marks it as NOT a clause,
+ // i.e. as an anonymous paragraph among the terms. That is the failure mode
+ // counsel ruled out, and this asserts the pipeline itself refuses the shape.
+ const throughAgreementSanitizer = sanitizeAgreementHtml(D.html);
+ expect(throughAgreementSanitizer).not.toBe(D.html);
+ expect(throughAgreementSanitizer).not.toMatch(/ {
+ // The detector is the whole test. A scan that cannot see the thing it looks
+ // for passes vacuously forever, so prove it sees one before trusting a zero.
+ it('the importer detector actually detects', () => {
+ expect(importsDisclosure(
+ `import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../lib/legal/${DISCLOSURE_MODULE}';`,
+ )).toBe(true);
+ expect(importsDisclosure(
+ `const m = await import('../../lib/legal/${DISCLOSURE_MODULE}');`,
+ )).toBe(true);
+ expect(importsDisclosure(`import { sanitizeAgreementHtml } from './sanitizer';`)).toBe(false);
+ });
+
+ it('no module that builds the agreement body imports the disclosure', () => {
+ const sources = [...walk(join(REPO_ROOT, 'server')), ...walk(join(REPO_ROOT, 'app'))]
+ .map((file) => ({ file: relative(REPO_ROOT, file).replace(/\\/g, '/'), src: readFileSync(file, 'utf8') }));
+
+ // Explicit hosts, plus anything that touches the agreement-body sanitizer.
+ // Named paths are asserted to exist so a rename fails loudly instead of
+ // quietly shrinking the guard to nothing.
+ const NAMED_HOSTS = [
+ 'server/services/agreement/sanitizer.ts',
+ 'server/services/agreement/template.ts',
+ 'server/services/agreement.service.ts',
+ 'server/api/agreements-render.ts',
+ ];
+ const known = new Set(sources.map((s) => s.file));
+ for (const host of NAMED_HOSTS) expect(known.has(host)).toBe(true);
+
+ const agreementBody = sources.filter(
+ (s) => NAMED_HOSTS.includes(s.file)
+ || s.file.startsWith('server/services/agreement/')
+ || s.src.includes('sanitizeAgreementHtml'),
+ );
+ expect(agreementBody.length).toBeGreaterThanOrEqual(NAMED_HOSTS.length);
+
+ const offenders = agreementBody.filter((s) => importsDisclosure(s.src)).map((s) => s.file);
+ // A neutral platform disclosure that is composed into the contract text is
+ // no longer neutral and no longer a disclosure: it is a term we wrote in a
+ // contract we are not party to. Render it beside the agreement instead.
+ expect(offenders).toEqual([]);
+ });
+});
From 7c883ee67fe7b6fa04d13db24a49209def87dc9d Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 08:00:28 +0800
Subject: [PATCH 016/111] feat(agreements): show the language disclosure beside
the agreement, not in it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three surfaces show an agreement to a signer: the standalone signing page, the
same section inside the client Hub, and the checkout sign card. A fourth — the
archived copy rendered for the signed PDF — is the one a dispute produces. All
four now carry the platform language disclosure, and none of them puts it in the
agreement body.
That distinction is the whole point. "Append it at render" and "append it to the
body" produce identical screens and completely different legal positions: the
body is a contract between the tenant and their client, which we are not a party
to and write no word of. So the tests assert placement, not presence alone — the
disclosure must sit outside the body container, and after a render the pinned
snapshot and its content_hash must be byte-identical. They are: the hash is taken
over the stored string, and the disclosure never enters it, so no existing
signature is invalidated.
The copy gained a plain-text heading, "Not part of this agreement", held to the
same no-contractual-assertion test as the sentence. Position is what counsel
asked for and a reader does not infer position from a border.
Two guard changes fall out of this:
- The renderer sanitizes with DISCLOSURE_SANITIZER_PROFILE, never .
That component's allow-list is the tenant rich-text one, which permits neither
nor role, and would deliver the sentence as a loose paragraph among
the terms — the exact reading this exists to prevent. Asserted against source,
because DOMPurify under happy-dom applies no allow-list at all and both
components emit identical markup on the first pass.
- The containment scan previously listed agreements-render.ts as an
agreement-body host, which would have kept the disclosure out of the archived
copy entirely. It now names only the modules that compose the stored string;
the renderer is held to a stronger pair of tests instead.
---
.../AgreementLanguageDisclosure.test.tsx | 39 ++++++++
.../AgreementLanguageDisclosure.tsx | 75 +++++++++++++++
app/components/checkout/SignCard.test.tsx | 53 +++++++++++
app/components/checkout/SignCard.tsx | 13 ++-
.../portal/sections/AgreementSection.test.tsx | 45 +++++++++
.../portal/sections/AgreementSection.tsx | 12 ++-
server/api/agreements-render.ts | 30 ++++++
.../legal/agreement-language-disclosure.ts | 12 ++-
.../unit/agreements/agreements-render.spec.ts | 95 +++++++++++++++++++
.../agreements/language-disclosure.spec.ts | 74 ++++++++++++++-
10 files changed, 441 insertions(+), 7 deletions(-)
create mode 100644 app/components/agreements/AgreementLanguageDisclosure.test.tsx
create mode 100644 app/components/agreements/AgreementLanguageDisclosure.tsx
create mode 100644 app/components/checkout/SignCard.test.tsx
diff --git a/app/components/agreements/AgreementLanguageDisclosure.test.tsx b/app/components/agreements/AgreementLanguageDisclosure.test.tsx
new file mode 100644
index 000000000..881c46c32
--- /dev/null
+++ b/app/components/agreements/AgreementLanguageDisclosure.test.tsx
@@ -0,0 +1,39 @@
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import { AgreementLanguageDisclosure } from './AgreementLanguageDisclosure';
+import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../../../server/lib/legal/agreement-language-disclosure';
+
+// What this file can and cannot settle:
+//
+// It can check what a reader sees. It CANNOT check which allow-list the mount
+// pass uses — DOMPurify under happy-dom drops the outermost element and applies
+// no allow-list at all, so a round trip through it proves nothing about a
+// browser (measured; see tests/unit/agreements/language-disclosure.spec.ts).
+// Worse, the server-sanitized-then-re-sanitized pattern means the wrong
+// component would render identical markup on this first pass, so no synchronous
+// DOM assertion here can tell the two apart. The sanitizer choice is asserted
+// against source in that spec, and confirmed in a real browser.
+
+describe('AgreementLanguageDisclosure', () => {
+ it('states the fact, under a heading that says it is not a term', () => {
+ const { container } = render( );
+ expect(container.textContent).toContain(AGREEMENT_LANGUAGE_DISCLOSURE.label);
+ expect(container.textContent).toMatch(/provided in English/i);
+ expect(container.textContent).toMatch(/translated before signing/i);
+ });
+
+ it('keeps the wrapper that marks it as a note rather than prose', () => {
+ const { container } = render( );
+ const note = container.querySelector('section[role="note"]');
+ expect(note, 'the wrapper is what stops this reading as a loose paragraph').not.toBeNull();
+ expect(note!.textContent).toMatch(/provided in English/i);
+ });
+
+ it('offers nothing to click and nothing to load', () => {
+ // A platform note inside a signing flow is the last place to introduce an
+ // outbound link or a remote asset.
+ const { container } = render( );
+ expect(container.querySelector('a, img, iframe, svg, form')).toBeNull();
+ });
+});
diff --git a/app/components/agreements/AgreementLanguageDisclosure.tsx b/app/components/agreements/AgreementLanguageDisclosure.tsx
new file mode 100644
index 000000000..1d194f1b1
--- /dev/null
+++ b/app/components/agreements/AgreementLanguageDisclosure.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useRef } from "react";
+import {
+ AGREEMENT_LANGUAGE_DISCLOSURE,
+ DISCLOSURE_SANITIZER_PROFILE,
+} from "../../../server/lib/legal/agreement-language-disclosure";
+
+/**
+ * The platform's language disclosure, rendered as a SIBLING of an agreement and
+ * never inside one.
+ *
+ * `agreements.content` is tenant data — a contract between the tenant and their
+ * client, which we are not a party to and write no word of. This block is the
+ * platform speaking, so it has to be readable as the platform speaking. Three
+ * things do that, in descending order of how much a reader relies on them:
+ *
+ * 1. It says so. `AGREEMENT_LANGUAGE_DISCLOSURE.label` is the heading, and it
+ * is the only part of this a hurried signer is guaranteed to take in.
+ * 2. It sits outside the scroll region that holds the agreement text, in its
+ * own band with the muted surface used elsewhere for interface notes.
+ * 3. `role="note"` (carried by the copy's own wrapper) says the same thing to
+ * assistive technology.
+ *
+ * Deliberately NOT ``: that component's allow-list is the tenant
+ * rich-text one — the Quill toolbar — which permits neither `` nor
+ * `role`. Routing this through it would strip the wrapper and deliver a bare
+ * paragraph, i.e. exactly the anonymous-clause reading the disclosure exists to
+ * avoid. `DISCLOSURE_SANITIZER_PROFILE` is sized to this copy instead.
+ *
+ * The copy is a frozen module constant with no interpolation, so the DOMPurify
+ * pass is defence in depth rather than a filter on untrusted input — the same
+ * posture the agreement body gets, and cheap insurance against a future edit to
+ * the constant. SSR emits the constant directly because Workers have no DOM.
+ */
+export function AgreementLanguageDisclosure({ className }: { className?: string }) {
+ const ref = useRef(null);
+ const html = AGREEMENT_LANGUAGE_DISCLOSURE.html;
+
+ useEffect(() => {
+ let cancelled = false;
+ void import("dompurify").then(({ default: DOMPurify }) => {
+ if (cancelled || !ref.current) return;
+ ref.current.innerHTML = DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: [...DISCLOSURE_SANITIZER_PROFILE.ALLOWED_TAGS],
+ ALLOWED_ATTR: [...DISCLOSURE_SANITIZER_PROFILE.ALLOWED_ATTR],
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [html]);
+
+ return (
+
+ {/* fg-2, not the fg-4 an eyebrow usually gets: this line is the disclosure's
+ working part, and at 10px on the muted surface fg-4 measured 2.6:1 in
+ dark mode — present, but not something a hurried signer reads. */}
+
+ {AGREEMENT_LANGUAGE_DISCLOSURE.label}
+
+
+
+ );
+}
diff --git a/app/components/checkout/SignCard.test.tsx b/app/components/checkout/SignCard.test.tsx
new file mode 100644
index 000000000..d5fb78aad
--- /dev/null
+++ b/app/components/checkout/SignCard.test.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import { createRoutesStub } from 'react-router';
+import { SignCard } from './SignCard';
+import type { StepState } from '~/lib/checkout-steps';
+
+// The checkout flow is the other surface a client signs on. Whatever the
+// standalone signing page tells a signer about language, this one has to tell
+// them too — a disclosure that depends on which link the client happened to
+// open is not a disclosure.
+
+function renderCard(state: StepState = 'todo') {
+ const Stub = createRoutesStub([
+ {
+ path: '/',
+ Component: () => (
+ {}}
+ />
+ ),
+ },
+ ]);
+ return render( );
+}
+
+describe('SignCard — language disclosure', () => {
+ it('shows it alongside the snapshot', () => {
+ const { container } = renderCard();
+ const note = container.querySelector('[data-testid="agreement-language-disclosure"]');
+ expect(note).not.toBeNull();
+ expect(note!.textContent).toMatch(/provided in English/i);
+ });
+
+ it('renders it OUTSIDE the snapshot, not within it', () => {
+ const { container } = renderCard();
+ const body = container.querySelector('[data-testid="agreement-body"]');
+ const note = container.querySelector('[data-testid="agreement-language-disclosure"]');
+ expect(body).not.toBeNull();
+ expect(body!.contains(note!)).toBe(false);
+ expect(body!.textContent?.trim()).toBe('terms');
+ });
+
+ it('is still there once this step is done', () => {
+ const { container } = renderCard('done');
+ expect(container.querySelector('[data-testid="agreement-language-disclosure"]')).not.toBeNull();
+ });
+});
diff --git a/app/components/checkout/SignCard.tsx b/app/components/checkout/SignCard.tsx
index 58313f8a9..fda73d50f 100644
--- a/app/components/checkout/SignCard.tsx
+++ b/app/components/checkout/SignCard.tsx
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useFetcher } from "react-router";
import { SanitizedHtml } from "~/components/SanitizedHtml";
+import { AgreementLanguageDisclosure } from "~/components/agreements/AgreementLanguageDisclosure";
import {
OnBehalfFields,
onBehalfPayload,
@@ -126,14 +127,22 @@ export function SignCard({
)}
- {/* Snapshot content (scrollable) */}
-
+ {/* Snapshot content (scrollable) — tenant data, and the only thing
+ being signed. */}
+
+ {/* Platform disclosure — a sibling of the snapshot, outside its scroll
+ region and never composed into it. */}
+
+
{isDone ? (
diff --git a/app/components/portal/sections/AgreementSection.test.tsx b/app/components/portal/sections/AgreementSection.test.tsx
index 41da00144..72dcdf08c 100644
--- a/app/components/portal/sections/AgreementSection.test.tsx
+++ b/app/components/portal/sections/AgreementSection.test.tsx
@@ -64,3 +64,48 @@ describe('AgreementSection — verify link (IA-46)', () => {
expect(container.querySelector('a[href^="/verify/"]')).toBeNull();
});
});
+
+// ---------------------------------------------------------------------------
+// Language disclosure — beside the agreement, never inside it.
+// ---------------------------------------------------------------------------
+
+function unsigned(): AgreementData {
+ return signed({
+ status: 'sent',
+ signer: { name: 'Jane', role: 'client', status: 'sent' },
+ progress: { signed: 0, total: 1 },
+ });
+}
+
+describe('AgreementSection — language disclosure', () => {
+ it('shows it to a signer who has not signed yet', () => {
+ const { container } = renderSection(unsigned());
+ const note = container.querySelector('[data-testid="agreement-language-disclosure"]');
+ expect(note).not.toBeNull();
+ expect(note!.textContent).toMatch(/provided in English/i);
+ });
+
+ it('still shows it after signing — the screen keeps saying what the record says', () => {
+ const { container } = renderSection(signed());
+ expect(container.querySelector('[data-testid="agreement-language-disclosure"]')).not.toBeNull();
+ });
+
+ it('renders it OUTSIDE the agreement body, not within it', () => {
+ // "Append it at render" and "append it to the body" look identical on a
+ // screenshot and are completely different legally: the body is the tenant's
+ // contract, which we write no word of. Nesting is the failure this catches.
+ const { container } = renderSection(unsigned());
+ const body = container.querySelector('[data-testid="agreement-body"]');
+ const note = container.querySelector('[data-testid="agreement-language-disclosure"]');
+ expect(body).not.toBeNull();
+ expect(note).not.toBeNull();
+ expect(body!.contains(note!)).toBe(false);
+ expect(body!.textContent).not.toMatch(/provided in English/i);
+ });
+
+ it('leaves the agreement content itself untouched', () => {
+ const { container } = renderSection(unsigned());
+ const body = container.querySelector('[data-testid="agreement-body"]');
+ expect(body!.textContent?.trim()).toBe('terms');
+ });
+});
diff --git a/app/components/portal/sections/AgreementSection.tsx b/app/components/portal/sections/AgreementSection.tsx
index b713a2137..407188098 100644
--- a/app/components/portal/sections/AgreementSection.tsx
+++ b/app/components/portal/sections/AgreementSection.tsx
@@ -26,6 +26,7 @@ import { useState, useRef, useEffect } from "react";
import { useFetcher } from "react-router";
import { m } from "~/paraglide/messages";
import { SanitizedHtml } from "~/components/SanitizedHtml";
+import { AgreementLanguageDisclosure } from "~/components/agreements/AgreementLanguageDisclosure";
import { SignaturePad, type SignaturePadHandle } from "~/components/media-studio/SignaturePad";
import {
OnBehalfFields,
@@ -209,14 +210,21 @@ export function AgreementSection({
)}
- {/* Agreement content */}
-
+ {/* Agreement content — tenant data, and the only thing being signed. */}
+
+ {/* Platform disclosure — a sibling of the agreement, outside its scroll
+ region and never composed into it. */}
+
+
{/* Signature area */}
{alreadySigned || signed ? (
diff --git a/server/api/agreements-render.ts b/server/api/agreements-render.ts
index 5fa5f5696..97de91216 100644
--- a/server/api/agreements-render.ts
+++ b/server/api/agreements-render.ts
@@ -5,6 +5,7 @@ import { eq, and, asc } from 'drizzle-orm';
import * as schema from '../lib/db/schema';
import { qrToSvg } from '../lib/qr';
import { AgreementService } from '../services/agreement.service';
+import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../lib/legal/agreement-language-disclosure';
import { safeISODate } from '../lib/date';
/**
@@ -97,6 +98,9 @@ const HTML_HEAD = `
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; color: #0f172a; max-width: 720px; margin: 32px auto; padding: 0 16px; }
h1 { font-size: 18px; margin: 0 0 24px 0; }
.body { white-space: pre-wrap; border: 1px solid #e2e8f0; padding: 16px; border-radius: 8px; }
+ .lang-note { margin-top: 12px; font-size: 12px; color: #475569; }
+ .lang-note .label { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
+ .lang-note p { margin: 4px 0 0 0; }
.sig-block { margin-top: 32px; padding-top: 16px; border-top: 2px solid #0f172a; }
.sig-row { display: flex; gap: 24px; margin-top: 16px; }
.sig-cell { flex: 1; }
@@ -111,6 +115,31 @@ const HTML_FOOT = ``;
const escapeHtml = (s: string): string =>
s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+/**
+ * The platform language disclosure, as it appears in the ARCHIVED copy — the
+ * document a dispute produces. Emitted as a sibling of `.body`, after it and
+ * outside its border, in the same muted register as the verify block: this is
+ * the platform speaking about the document, not a term of it, and the heading
+ * says so in words rather than relying on a reader to read a border.
+ *
+ * It is deliberately not inside `.body`. That div holds the pinned content
+ * snapshot verbatim, the snapshot is what `content_hash` is taken over, and
+ * anything added to it would both alter the record of what was signed and
+ * become a clause we wrote in a contract we are not a party to.
+ *
+ * Emitted raw rather than escaped because it is a frozen module constant with
+ * no interpolation — the markup IS the payload here, and it carries no href,
+ * src or event attribute for escaping to protect against. Workers have no DOM,
+ * so there is no sanitizer pass available on this path; the guard is that the
+ * string is ours and fixed, asserted by
+ * `tests/unit/agreements/language-disclosure.spec.ts`.
+ */
+const languageNoteHtml = (): string =>
+ `
` +
+ `
${escapeHtml(AGREEMENT_LANGUAGE_DISCLOSURE.label)}
` +
+ AGREEMENT_LANGUAGE_DISCLOSURE.html +
+ `
`;
+
/**
* Pure render handler exported for unit testing. Takes a D1Database and the
* URL path params; the live route in index.ts wraps this with tenant routing
@@ -203,6 +232,7 @@ export async function agreementRenderHandler(
const html = HTML_HEAD +
`
${escapeHtml(agreement.name)} ` +
`
${escapeHtml(snapshotContent)}
` +
+ languageNoteHtml() +
`
` +
`
` +
signerCellsHtml +
diff --git a/server/lib/legal/agreement-language-disclosure.ts b/server/lib/legal/agreement-language-disclosure.ts
index d30cfb34b..081672b69 100644
--- a/server/lib/legal/agreement-language-disclosure.ts
+++ b/server/lib/legal/agreement-language-disclosure.ts
@@ -43,11 +43,21 @@
* toward translating one.
*/
-/** Version of the disclosure copy. Bump on ANY wording change. */
+/** Version of the disclosure copy. Bump on ANY wording change, `label` included. */
const DISCLOSURE_VERSION = 1;
export const AGREEMENT_LANGUAGE_DISCLOSURE = Object.freeze({
version: DISCLOSURE_VERSION,
+ /**
+ * Plain-text heading every renderer puts directly above `html`. It is part of
+ * the disclosure, not chrome a component chose: the whole instruction from
+ * counsel is about POSITION, and this sentence is what makes the position
+ * legible to a reader who is not going to reason about borders and type
+ * sizes. Kept here rather than in each renderer so the signing screen and the
+ * archived copy cannot drift apart, and out of the message catalogue for the
+ * same reason `html` is — versioned platform copy, not a translatable string.
+ */
+ label: 'Not part of this agreement',
html: [
'
',
'This agreement is provided in English. If you would prefer to review ',
diff --git a/tests/unit/agreements/agreements-render.spec.ts b/tests/unit/agreements/agreements-render.spec.ts
index 31d6b0244..70cfbfaa0 100644
--- a/tests/unit/agreements/agreements-render.spec.ts
+++ b/tests/unit/agreements/agreements-render.spec.ts
@@ -362,3 +362,98 @@ describe('cert-render handler', () => {
expect(body).toContain('hash2aaaaaaaaaaa');
});
});
+
+// ---------------------------------------------------------------------------
+// Language disclosure in the ARCHIVED copy (the document a dispute produces).
+//
+// The signing screen and this document have to agree. A signer told on screen
+// that the agreement is English-only, holding a signed PDF that says nothing of
+// the kind, is left worse off than if we had never shown the note: the record
+// now contradicts what happened.
+//
+// Equally, the note must stay OUT of the body box. That div holds the pinned
+// content snapshot verbatim, `content_hash` is taken over the stored string, and
+// anything added inside it would both rewrite the record of what was signed and
+// make us the author of a term in a contract we are not a party to.
+// ---------------------------------------------------------------------------
+
+/** The verbatim contents of the `.body` box — the snapshot, and nothing else. */
+function bodyBoxOf(html: string): string {
+ // escapeHtml() leaves no markup inside the box, so the first closing tag is
+ // the box's own. Asserted below rather than assumed.
+ const m = html.match(/
([\s\S]*?)<\/div>/);
+ return m ? m[1] : '';
+}
+
+describe('agreement-render handler — language disclosure', () => {
+ let db: BetterSQLite3Database
;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ await db.insert(schema.tenants).values({
+ id: TENANT_A, name: 'A', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.inspections).values({
+ id: INSP_ID, tenantId: TENANT_A, propertyAddress: '1 Main St', clientName: 'Jane',
+ clientEmail: 'jane@x', date: '2026-06-01', status: 'requested', paymentStatus: 'unpaid',
+ price: 0, createdAt: new Date(),
+ } as never);
+ await db.insert(schema.agreements).values({
+ id: AGR_ID, tenantId: TENANT_A, name: 'Standard', content: 'Agreement body
',
+ version: 1, createdAt: new Date(),
+ });
+ await db.insert(schema.agreementRequests).values({
+ id: REQ_ID, tenantId: TENANT_A, inspectionId: INSP_ID, agreementId: AGR_ID,
+ clientEmail: 'jane@x', clientName: 'Jane Doe',
+ token: TOKEN_A, status: 'signed',
+ signatureBase64: 'data:image/png;base64,clientsig',
+ signedAt: new Date(),
+ contentSnapshot: 'Snapshot at sign time
',
+ contentHash: 'deadbeef',
+ createdAt: new Date(),
+ });
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+ });
+
+ it('carries the disclosure into the signed document', async () => {
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ expect(html).toMatch(/provided in English/i);
+ expect(html).toContain('Not part of this agreement');
+ // The wrapper travels with it: the shape is what marks it as a note.
+ expect(html).toContain('role="note"');
+ });
+
+ it('places it OUTSIDE the body box', async () => {
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ const box = bodyBoxOf(html);
+ // Prove the extractor sees the box before trusting what it does not see.
+ expect(box).toContain('Snapshot at sign time');
+ expect(box).not.toMatch(/provided in English/i);
+ expect(box).not.toContain('Not part of this agreement');
+ // …and it lands after the box, before the signatures — a note about the
+ // document, read in the order a person reads the page.
+ expect(html.indexOf('Not part of this agreement')).toBeGreaterThan(html.indexOf('Snapshot at sign time'));
+ const sigBlock = html.indexOf('');
+ expect(sigBlock).toBeGreaterThan(-1);
+ expect(html.indexOf('Not part of this agreement')).toBeLessThan(sigBlock);
+ });
+
+ it('writes nothing — the snapshot and its hash survive the render', async () => {
+ await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const row = await db.select().from(schema.agreementRequests)
+ .where(eq(schema.agreementRequests.id, REQ_ID)).get();
+ expect(row!.contentSnapshot).toBe('
Snapshot at sign time
');
+ expect(row!.contentSnapshot).not.toMatch(/provided in English/i);
+ // contentHash is SHA-256 of the stored string. Because the disclosure never
+ // enters that string, no existing signature is invalidated by shipping this.
+ expect(row!.contentHash).toBe('deadbeef');
+ const agreement = await db.select().from(schema.agreements)
+ .where(eq(schema.agreements.id, AGR_ID)).get();
+ expect(agreement!.content).toBe('
Agreement body
');
+ });
+});
diff --git a/tests/unit/agreements/language-disclosure.spec.ts b/tests/unit/agreements/language-disclosure.spec.ts
index e8fa018b0..1fb54d8f4 100644
--- a/tests/unit/agreements/language-disclosure.spec.ts
+++ b/tests/unit/agreements/language-disclosure.spec.ts
@@ -20,12 +20,26 @@ describe('agreement language disclosure', () => {
// Counsel: a disclosure may state a fact, it may not decide which text
// prevails. Every word below allocates risk between two parties we are
// not one of. This test is the line, and it is why the plan was rewritten.
+ // The heading is held to the same standard as the sentence: it is shown
+ // to the signer in the same block and carries the same risk of reading
+ // as a term.
for (const forbidden of [/govern/i, /prevail/i, /controls?/i,
/binding/i, /conflict between/i, /shall/i]) {
expect(D.html).not.toMatch(forbidden);
+ expect(D.label).not.toMatch(forbidden);
}
});
+ it('carries a heading that says what the block is NOT', () => {
+ // Position is the whole instruction, and a reader does not infer position
+ // from a border. The heading states it in words, so it travels with the
+ // copy instead of living in whichever component happens to render it.
+ expect(D.label).toMatch(/not part of this agreement/i);
+ // Plain text: renderers escape it or print it as a text node. Markup here
+ // would mean two sanitizer stories for one constant.
+ expect(D.label).not.toMatch(/[<>]/);
+ });
+
it('does not reproduce the InterNACHI clause', () => {
// That wording is written for the INSPECTOR to place in THEIR agreement.
// Borrowing it makes the platform the author of a term.
@@ -153,18 +167,24 @@ describe('agreement language disclosure — containment', () => {
expect(importsDisclosure(`import { sanitizeAgreementHtml } from './sanitizer';`)).toBe(false);
});
- it('no module that builds the agreement body imports the disclosure', () => {
+ it('no module that composes the agreement body imports the disclosure', () => {
const sources = [...walk(join(REPO_ROOT, 'server')), ...walk(join(REPO_ROOT, 'app'))]
.map((file) => ({ file: relative(REPO_ROOT, file).replace(/\\/g, '/'), src: readFileSync(file, 'utf8') }));
// Explicit hosts, plus anything that touches the agreement-body sanitizer.
// Named paths are asserted to exist so a rename fails loudly instead of
// quietly shrinking the guard to nothing.
+ //
+ // These are the modules that BUILD THE STRING stored in
+ // `agreements.content` (and its pinned snapshot). A document renderer that
+ // merely contains that string is a different thing and is governed by the
+ // next test — see the note there; listing one here would have forbidden the
+ // archived copy from carrying the disclosure at all, which is the one place
+ // it matters most.
const NAMED_HOSTS = [
'server/services/agreement/sanitizer.ts',
'server/services/agreement/template.ts',
'server/services/agreement.service.ts',
- 'server/api/agreements-render.ts',
];
const known = new Set(sources.map((s) => s.file));
for (const host of NAMED_HOSTS) expect(known.has(host)).toBe(true);
@@ -182,4 +202,54 @@ describe('agreement language disclosure — containment', () => {
// contract we are not party to. Render it beside the agreement instead.
expect(offenders).toEqual([]);
});
+
+ // The counterpart to the scan above. "Beside the agreement" has to hold on
+ // every surface that shows an agreement, and the archived copy is the one a
+ // dispute actually produces: a disclosure the signer saw on screen and cannot
+ // find in the signed document is worse than no disclosure, because the record
+ // then contradicts what happened. So this asserts PRESENCE, and
+ // `tests/unit/agreements/agreements-render.spec.ts` asserts the placement —
+ // outside the body box, with the stored string untouched.
+ it('the archived copy renderer carries the disclosure', () => {
+ const RENDERER = 'server/api/agreements-render.ts';
+ const src = readFileSync(join(REPO_ROOT, RENDERER), 'utf8');
+ expect(importsDisclosure(src), `${RENDERER} no longer shows the disclosure`).toBe(true);
+ });
+
+ // Which allow-list the browser pass uses cannot be settled in a DOM test:
+ // both components emit the server string on the first pass and only diverge
+ // once DOMPurify runs, and DOMPurify under happy-dom applies no allow-list at
+ // all. The choice is legible in source, so it is asserted there — and seen for
+ // real in a browser.
+ const DISCLOSURE_COMPONENT = 'app/components/agreements/AgreementLanguageDisclosure.tsx';
+
+ it('the browser renderer sanitizes with the disclosure profile', () => {
+ const src = readFileSync(join(REPO_ROOT, DISCLOSURE_COMPONENT), 'utf8');
+ expect(src).toContain('DISCLOSURE_SANITIZER_PROFILE');
+ });
+
+ it('the browser renderer does NOT route the copy through the tenant-content component', () => {
+ // Its allow-list is the Quill toolbar: no
, no `role`. Reusing it
+ // would hand the reader a loose paragraph among the terms — the exact
+ // reading this disclosure exists to prevent.
+ const src = readFileSync(join(REPO_ROOT, DISCLOSURE_COMPONENT), 'utf8');
+ expect(/import\s*\{[^}]*\bSanitizedHtml\b[^}]*\}/.test(src)).toBe(false);
+ });
+
+ it('every signing surface renders the disclosure component', () => {
+ // One import per surface. A surface that grows its own copy of the block,
+ // or quietly drops it, shows up here rather than in a dispute.
+ const SIGNING_SURFACES = [
+ 'app/components/portal/sections/AgreementSection.tsx',
+ 'app/components/checkout/SignCard.tsx',
+ ];
+ for (const surface of SIGNING_SURFACES) {
+ const src = readFileSync(join(REPO_ROOT, surface), 'utf8');
+ expect(src, `${surface} does not render the disclosure`)
+ .toMatch(/
Date: Tue, 4 Aug 2026 08:14:36 +0800
Subject: [PATCH 017/111] fix(i18n): drop the unused export on
DEFAULT_CONTACT_LOCALE
Full lint at the batch boundary found the one thing the per-task hooks
cannot: knip went from zero to one finding. The constant is used only by the
resolver in its own module, so it stops being an export rather than gaining
a consumer -- the same call the same task made for normalizeLocale.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FGxHcE92k4t3d79y6DQfi3
---
server/lib/i18n/contact-locale.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/server/lib/i18n/contact-locale.ts b/server/lib/i18n/contact-locale.ts
index ef468a8b5..fed77e2c3 100644
--- a/server/lib/i18n/contact-locale.ts
+++ b/server/lib/i18n/contact-locale.ts
@@ -47,8 +47,9 @@ export const SUPPORTED_CONTACT_LOCALES = ['en', 'es-419'] as const;
/** A locale the product can actually speak. */
export type ContactLocale = (typeof SUPPORTED_CONTACT_LOCALES)[number];
-/** The base locale, used when nothing else resolves. */
-export const DEFAULT_CONTACT_LOCALE: ContactLocale = 'en';
+/** The base locale, used when nothing else resolves. Module-local: exporting it
+ * with no consumer is a knip finding, and the resolver is the only caller. */
+const DEFAULT_CONTACT_LOCALE: ContactLocale = 'en';
/**
* A BCP-47 tag reduced to a locale we have messages for, or `null` when we
From e26e4283f5544e31c8061c2942ce42fd49802ada Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 08:49:54 +0800
Subject: [PATCH 018/111] feat(agreements): record which language disclosure a
signer was shown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The disclosure is on four screens as of the last commit, and nowhere in the
database. That gap only matters later: bump the copy, and every signature ever
collected silently becomes a signature against text nobody can identify.
agreement_signers gains language_disclosure_version, nullable. Nullable is the
feature, not a concession — NULL means "the platform did not draw this screen
and cannot say what was on it", and two real cases produce it. Signatures older
than this commit are one. The other is POST /api/inspections/:id/sign: that
endpoint hands the agreement text to a caller through GET /:id/agreement and
takes back a signature, so what the signer read is the caller's business, not
ours. It records null. The public sign route records the live version, because
the two surfaces it serves both render the disclosure component and a test says
so.
markSignedBySigner takes the version as a required argument and never defaults
it. The service knows nothing about screens; only a caller does. An optional
field would let the next sign surface record silence indistinguishable from a
pre-feature signature.
What the record now supports decides what the evidence surfaces may print:
- The archived copy shows the disclosure only when EVERY signature on the
envelope recorded the version live today. Superseded copy is archived
nowhere, so against an older signature the choice is between printing nothing
and printing words that signer demonstrably never read. It prints nothing.
- /verify follows the same rule — Task 2 deferred it here for exactly this
reason — and gets the answer as a server-decided boolean. A public page is
not where that rule gets re-derived.
- The certificate of completion is a different document: it states facts ABOUT
the signing event rather than reproducing what was signed, so it names
whatever version was recorded, superseded included, and stays silent when
nothing was.
Erasure lint checked rather than assumed: its PII heuristic matches none of
language_disclosure_version, so no ERASURE_OUT_OF_SCOPE entry is needed until
the heuristic widens. The migration is a plain ALTER TABLE ADD (column at the
table end, no rebuild); db:check green at hand=87 / generated=87.
Every guard here was run with its fix removed first; eleven went red.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
app/routes/public/verify.tsx | 15 +
migrations/0033_lumpy_calypso.sql | 1 +
migrations/meta/0033_snapshot.json | 10316 ++++++++++++++++
migrations/meta/_journal.json | 7 +
scripts/file-size-baseline.json | 3 +-
server/api/agreements-render.ts | 33 +-
server/api/bookings/agreement.ts | 5 +
server/api/inspections/agreements.ts | 5 +
server/api/public/verify.ts | 14 +
server/lib/db/schema/inspection/agreements.ts | 15 +
.../legal/agreement-language-disclosure.ts | 29 +
server/lib/verify-data.ts | 3 +
server/services/agreement/signer-state.ts | 9 +
.../unit/agreements/agreement-signers.spec.ts | 49 +-
.../unit/agreements/agreement.service.spec.ts | 2 +-
.../unit/agreements/agreements-render.spec.ts | 137 +
.../agreements/language-disclosure.spec.ts | 104 +
tests/unit/inspections/verify-data.spec.ts | 5 +
18 files changed, 10734 insertions(+), 18 deletions(-)
create mode 100644 migrations/0033_lumpy_calypso.sql
create mode 100644 migrations/meta/0033_snapshot.json
diff --git a/app/routes/public/verify.tsx b/app/routes/public/verify.tsx
index ae1da1d95..b55a624bd 100644
--- a/app/routes/public/verify.tsx
+++ b/app/routes/public/verify.tsx
@@ -3,6 +3,7 @@ import type { Route } from "./+types/verify";
import { createApi } from "~/lib/api-client.server";
import { formatDateTime } from "~/lib/format";
import { SanitizedHtml } from "~/components/SanitizedHtml";
+import { AgreementLanguageDisclosure } from "~/components/agreements/AgreementLanguageDisclosure";
import { ViewerTimeZoneProvider, useViewerTimeZone } from "~/lib/viewer-timezone";
import { ViewerTimeZoneNotice } from "~/components/public/ViewerTimeZoneNotice";
import { m } from "~/paraglide/messages";
@@ -32,6 +33,12 @@ interface VerifyData {
contentSnapshot: string | null;
contentHash: string | null;
signers: VerifySigner[];
+ // Server-decided: every signature here recorded the language-disclosure
+ // version that is live now, so this page may show that copy beside the
+ // snapshot as part of what these signers were shown. False on a pre-feature
+ // signature and on any surface the platform did not draw — the page then says
+ // nothing rather than showing today's words against an older signature.
+ languageDisclosureCurrent: boolean;
}
export async function loader({ params, context }: Route.LoaderArgs) {
@@ -133,6 +140,14 @@ function VerifyBody() {
/>
)}
+ {/* Outside the snapshot box, never inside it: that box is the string the
+ content hash is taken over, and this is the platform speaking about the
+ document rather than a term of it. Shown only when every signature on
+ this envelope recorded the version of the copy that is live now. */}
+ {result.languageDisclosureCurrent && (
+
+ )}
+
{/* Signers */}
{m.public_verify_section_signers()}
diff --git a/migrations/0033_lumpy_calypso.sql b/migrations/0033_lumpy_calypso.sql
new file mode 100644
index 000000000..ab733a732
--- /dev/null
+++ b/migrations/0033_lumpy_calypso.sql
@@ -0,0 +1 @@
+ALTER TABLE `agreement_signers` ADD `language_disclosure_version` integer;
\ No newline at end of file
diff --git a/migrations/meta/0033_snapshot.json b/migrations/meta/0033_snapshot.json
new file mode 100644
index 000000000..5ce455ca2
--- /dev/null
+++ b/migrations/meta/0033_snapshot.json
@@ -0,0 +1,10316 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "751269e4-c0b0-4cba-b006-f51f05d4c5a6",
+ "prevId": "9c0a6763-baa0-4523-bf75-d5b2854fca74",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "language_disclosure_version": {
+ "name": "language_disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index 1f6e9ef40..b0dd85ef8 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -232,6 +232,13 @@
"when": 1785773942431,
"tag": "0032_free_matthew_murdock",
"breakpoints": true
+ },
+ {
+ "idx": 33,
+ "version": "6",
+ "when": 1785803986362,
+ "tag": "0033_lumpy_calypso",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 56669c39d..18f35f791 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -39,8 +39,8 @@
"server/api/portal.ts": 525,
"server/services/portal-access.service.ts": 525,
"server/api/inspections/publish.ts": 520,
+ "server/api/bookings/agreement.ts": 519,
"app/components/settings/ManagedComplianceWizard.tsx": 514,
- "server/api/bookings/agreement.ts": 514,
"app/routes/settings-profile.tsx": 509,
"server/api/repair-builder.ts": 504,
"app/routes/inspection-edit/action.server.ts": 501,
@@ -64,5 +64,6 @@
"server/lib/middleware/di.ts": 422,
"app/routes/templates.tsx": 414,
"app/routes/calendar.tsx": 410,
+ "server/services/agreement/signer-state.ts": 409,
"app/lib/section-loaders.ts": 402
}
diff --git a/server/api/agreements-render.ts b/server/api/agreements-render.ts
index 97de91216..1ec176e7c 100644
--- a/server/api/agreements-render.ts
+++ b/server/api/agreements-render.ts
@@ -5,7 +5,10 @@ import { eq, and, asc } from 'drizzle-orm';
import * as schema from '../lib/db/schema';
import { qrToSvg } from '../lib/qr';
import { AgreementService } from '../services/agreement.service';
-import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../lib/legal/agreement-language-disclosure';
+import {
+ AGREEMENT_LANGUAGE_DISCLOSURE,
+ signaturesRecordCurrentDisclosure,
+} from '../lib/legal/agreement-language-disclosure';
import { safeISODate } from '../lib/date';
/**
@@ -133,12 +136,22 @@ const escapeHtml = (s: string): string =>
* so there is no sanitizer pass available on this path; the guard is that the
* string is ours and fixed, asserted by
* `tests/unit/agreements/language-disclosure.spec.ts`.
+ *
+ * Returns '' unless every signature on this document recorded the version of
+ * the copy that is live right now — see `signaturesRecordCurrentDisclosure`.
+ * This document is produced in a dispute, and a signing screen's copy printed
+ * next to a signature that predates it would read as evidence of what that
+ * person was shown. Silence is the accurate answer when the record has none.
*/
-const languageNoteHtml = (): string =>
- `` +
+const languageNoteHtml = (
+ signerDisclosureVersions: ReadonlyArray
,
+): string => {
+ if (!signaturesRecordCurrentDisclosure(signerDisclosureVersions)) return '';
+ return `` +
`
${escapeHtml(AGREEMENT_LANGUAGE_DISCLOSURE.label)}
` +
AGREEMENT_LANGUAGE_DISCLOSURE.html +
`
`;
+};
/**
* Pure render handler exported for unit testing. Takes a D1Database and the
@@ -232,7 +245,9 @@ export async function agreementRenderHandler(
const html = HTML_HEAD +
`${escapeHtml(agreement.name)} ` +
`${escapeHtml(snapshotContent)}
` +
- languageNoteHtml() +
+ // Legacy envelope-level fallback above leaves `signedSigners` empty, which
+ // is exactly the "no version on the record" case — the note drops out.
+ languageNoteHtml(signedSigners.map((s) => s.languageDisclosureVersion)) +
`` +
`
` +
signerCellsHtml +
@@ -300,7 +315,15 @@ export async function certRenderHandler(
const name = escapeHtml(s.name || s.email || 'Signer');
const inPerson = s.channel === 'in_person' ? ' · Signed in person' : '';
const onBehalf = s.onBehalfOf ? ` · on behalf of ${escapeHtml(s.onBehalfOf)}` : '';
- return `
${escapeHtml(roleLabel(s.role))}: ${name}${inPerson}${onBehalf} · ${at} `;
+ // The certificate states FACTS about the signing event, so unlike the
+ // archived copy it can carry a superseded version number honestly: it
+ // reports which notice was displayed, it does not reproduce the notice.
+ // Omitted entirely when nothing was recorded — an absent line says "no
+ // record", which is true, where "v0" or "none" would sound like a finding.
+ const langNotice = typeof s.languageDisclosureVersion === 'number'
+ ? ` · Language notice v${s.languageDisclosureVersion} displayed`
+ : '';
+ return `
${escapeHtml(roleLabel(s.role))}: ${name}${inPerson}${onBehalf}${langNotice} · ${at} `;
}).join('')
: `
Client: ${escapeHtml(clientLabel)}${reqRow.signedAt ? ` · ${escapeHtml(utcDisplay(reqRow.signedAt))}` : ''} `;
diff --git a/server/api/bookings/agreement.ts b/server/api/bookings/agreement.ts
index f87f4df10..bda874230 100644
--- a/server/api/bookings/agreement.ts
+++ b/server/api/bookings/agreement.ts
@@ -13,6 +13,7 @@ import { withMcpMetadata } from "../../lib/route-metadata-standards";
import { PublicAgreementBodySchema } from '../../lib/validations/agreement-public.schema';
import { runEnvelopeCompletionPipeline, runSignerReceiptEffects } from '../../lib/sign-effects';
import { getDrizzle } from '../../lib/route-helpers';
+import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../../lib/legal/agreement-language-disclosure';
// Local aliases for the literal unions the DB columns are narrowed to in the
// JSON responses below. Kept file-local (not exported) so the public router
@@ -417,6 +418,10 @@ const agreementRoutes = createApiRouter()
userAgent: ua,
onBehalfOf: onBehalfOf ?? null,
onBehalfDisclaimer: onBehalfDisclaimer ?? null,
+ // The two surfaces this route serves — standalone sign page, checkout
+ // sign card — both render
(asserted by
+ // the containment spec), so this states a screen the signer saw.
+ languageDisclosureVersion: AGREEMENT_LANGUAGE_DISCLOSURE.version,
});
// Spec 2A — per-signer automation event so per-tenant rules can react to
diff --git a/server/api/inspections/agreements.ts b/server/api/inspections/agreements.ts
index 2a22bfa5a..ee311e185 100644
--- a/server/api/inspections/agreements.ts
+++ b/server/api/inspections/agreements.ts
@@ -348,6 +348,11 @@ const agreementsRoutes = createApiRouter()
userAgent: ua,
onBehalfOf: body.onBehalfOf ?? null,
onBehalfDisclaimer: body.onBehalfDisclaimer ?? null,
+ // NULL: this is the on-site API surface. `GET /:id/agreement` hands
+ // the caller the agreement text and the caller draws its own screen,
+ // so we cannot know whether the signer saw the language disclosure.
+ // A version here would assert something the platform does not know.
+ languageDisclosureVersion: null,
});
// Spec 2A — per-signer automation event (fires on EVERY sign).
diff --git a/server/api/public/verify.ts b/server/api/public/verify.ts
index 8c10d24d4..c01c00b33 100644
--- a/server/api/public/verify.ts
+++ b/server/api/public/verify.ts
@@ -5,6 +5,7 @@ import { createApiRouter } from '../../lib/openapi-router';
import { withMcpMetadata } from '../../lib/route-metadata-standards';
import { createApiResponseSchema } from '../../lib/validations/shared.schema';
import { loadVerifyData, loadReportVerifyData } from '../../lib/verify-data';
+import { signaturesRecordCurrentDisclosure } from '../../lib/legal/agreement-language-disclosure';
import { buildRenderReportUrl } from '../../lib/public-urls';
import { getBookingHost, resolveTenantSlug } from '../../lib/url';
import { isReportPublished } from '../../lib/status/report-status';
@@ -35,6 +36,12 @@ const VerifyResponseSchema = z.object({
contentSnapshot: z.string().nullable(),
contentHash: z.string().nullable(),
signers: z.array(VerifySignerSchema),
+ // True only when EVERY signature here recorded the language-disclosure
+ // version that is live right now, so the page may show that copy as part of
+ // what these people were shown. Decided server-side and shipped as a boolean
+ // rather than the raw versions: the rule is about the record's ability to
+ // support a claim, and a public page must not be the place it is re-derived.
+ languageDisclosureCurrent: z.boolean(),
});
const verifyRoute = createRoute(withMcpMetadata({
@@ -122,6 +129,13 @@ const publicVerifyRoutes = createApiRouter()
signedAt: s.signedAt ? new Date(s.signedAt).toISOString() : null,
channel: s.channel ?? null,
})),
+ // SIGNED signers only. A pending signer has been shown nothing
+ // and recorded nothing; counting it would suppress the notice on
+ // a document whose actual signatories all saw it.
+ languageDisclosureCurrent: signaturesRecordCurrentDisclosure(
+ data.signers.filter((s) => s.status === 'signed')
+ .map((s) => s.languageDisclosureVersion),
+ ),
},
}, 200);
})
diff --git a/server/lib/db/schema/inspection/agreements.ts b/server/lib/db/schema/inspection/agreements.ts
index a9f2b2683..17826aadf 100644
--- a/server/lib/db/schema/inspection/agreements.ts
+++ b/server/lib/db/schema/inspection/agreements.ts
@@ -101,6 +101,21 @@ export const agreementSigners = sqliteTable('agreement_signers', {
// set = link killed regardless of expiry. Resolution fails closed on either.
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
revokedAt: integer('revoked_at', { mode: 'timestamp_ms' }),
+ // Which version of the platform language DISCLOSURE this signer was shown.
+ // Not a contractual term (counsel, 2026-08-02) — but still the only way to
+ // answer "what was this person actually shown", which is the question a
+ // dispute turns on.
+ //
+ // NULLABLE and meant to stay that way. NULL means "the platform did not
+ // render the disclosure to this signer, or cannot vouch that it did" —
+ // signatures collected before this shipped, and the on-site
+ // `POST /api/inspections/:id/sign` surface, where the API returns the
+ // agreement text and the CALLER draws the screen. Writing a version for
+ // either would be a false statement about what they saw. Only a surface the
+ // platform renders may state a number here.
+ //
+ // Appended at the table end (see the expiresAt/revokedAt note above).
+ languageDisclosureVersion: integer('language_disclosure_version'),
}, (t) => [
index('idx_agreement_signers_tenant_request').on(t.tenantId, t.requestId),
uniqueIndex('idx_agreement_signers_request_email').on(t.requestId, t.email),
diff --git a/server/lib/legal/agreement-language-disclosure.ts b/server/lib/legal/agreement-language-disclosure.ts
index 081672b69..c2286abaa 100644
--- a/server/lib/legal/agreement-language-disclosure.ts
+++ b/server/lib/legal/agreement-language-disclosure.ts
@@ -76,3 +76,32 @@ export const DISCLOSURE_SANITIZER_PROFILE = Object.freeze({
ALLOWED_TAGS: Object.freeze(['section', 'p', 'strong', 'em', 'br']),
ALLOWED_ATTR: Object.freeze(['class', 'role']),
});
+
+/**
+ * May an EVIDENCE surface — the archived copy, the public verifier — print the
+ * copy above against this set of signatures?
+ *
+ * Only when every signature recorded the version that is in this file right
+ * now. `versions` is one entry per SIGNED signer, taken from
+ * `agreement_signers.language_disclosure_version`.
+ *
+ * The rule is narrow on purpose. Superseded copy is not archived anywhere: a
+ * bump replaces the string, and there is no table of old ones. So for a
+ * signature that recorded version N < current, the honest options are "print
+ * nothing" and "print a sentence this person never saw", and the second is a
+ * claim the record cannot support — precisely the claim a dispute would be
+ * about. NULL (no version recorded — a pre-feature signature, or a surface the
+ * platform did not draw) fails the same way and for the same reason.
+ *
+ * A signing SURFACE must not consult this: it shows the current copy to a
+ * person standing in front of it, which is always truthful, and it is what puts
+ * the version on the record in the first place.
+ */
+export function signaturesRecordCurrentDisclosure(
+ versions: ReadonlyArray,
+): boolean {
+ // No signatures = nothing to vouch for. An empty set trivially satisfying
+ // "every" would print the copy onto a document nobody signed.
+ if (versions.length === 0) return false;
+ return versions.every((v) => v === DISCLOSURE_VERSION);
+}
diff --git a/server/lib/verify-data.ts b/server/lib/verify-data.ts
index 9daac4076..97f974209 100644
--- a/server/lib/verify-data.ts
+++ b/server/lib/verify-data.ts
@@ -36,6 +36,9 @@ export async function loadVerifyData(c: Context, envelopeId: string)
status: schema.agreementSigners.status,
signedAt: schema.agreementSigners.signedAt,
channel: schema.agreementSigners.channel,
+ // Not exposed per-signer by the verifier — it decides ONE thing with it:
+ // whether the page may print the current language disclosure at all.
+ languageDisclosureVersion: schema.agreementSigners.languageDisclosureVersion,
})
.from(schema.agreementSigners)
.where(eq(schema.agreementSigners.requestId, envelopeId))
diff --git a/server/services/agreement/signer-state.ts b/server/services/agreement/signer-state.ts
index ba6bf4da9..375bb72ca 100644
--- a/server/services/agreement/signer-state.ts
+++ b/server/services/agreement/signer-state.ts
@@ -258,6 +258,14 @@ export function SignerStateMixin
async markSignedBySigner(presented: string, signatureBase64: string, opts: {
signedAtMs: number; channel: 'remote' | 'in_person'; ipAddress?: string | null; userAgent?: string | null;
onBehalfOf?: string | null; onBehalfDisclaimer?: string | null;
+ /**
+ * Which language-disclosure version this signer was SHOWN, or null
+ * when the platform did not draw the screen and so cannot say.
+ * REQUIRED and never defaulted here: only the caller knows what
+ * reached a screen, and an optional field would let a future sign
+ * surface record silence that reads like a pre-feature signature.
+ */
+ languageDisclosureVersion: number | null;
}): Promise<{ tenantId: string; inspectionId: string; requestId: string; signerId: string; envelopeCompletedNow: boolean; envelopeStatus: string }> {
const db = this.getDrizzle();
const resolved = await this.getSignerByPresentedToken(presented);
@@ -282,6 +290,7 @@ export function SignerStateMixin
userAgent: opts.userAgent ?? null,
onBehalfOf: opts.onBehalfOf ?? null,
onBehalfDisclaimer: opts.onBehalfDisclaimer ?? null,
+ languageDisclosureVersion: opts.languageDisclosureVersion,
})
.where(and(
eq(agreementSigners.id, signer.id),
diff --git a/tests/unit/agreements/agreement-signers.spec.ts b/tests/unit/agreements/agreement-signers.spec.ts
index fde996331..eea25a469 100644
--- a/tests/unit/agreements/agreement-signers.spec.ts
+++ b/tests/unit/agreements/agreement-signers.spec.ts
@@ -138,11 +138,11 @@ describe('AgreementService — signer-level envelope state machine', () => {
const link1 = await svc.getSignerLink(TENANT_A, r.requestId, signers[0].id);
const link2 = await svc.getSignerLink(TENANT_A, r.requestId, signers[1].id);
- const first = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'remote' });
+ const first = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null });
expect(first.envelopeCompletedNow).toBe(false);
expect(first.envelopeStatus).toBe('viewed');
- const second = await svc.markSignedBySigner(link2, 'sig-john', { signedAtMs: 2000, channel: 'remote' });
+ const second = await svc.markSignedBySigner(link2, 'sig-john', { signedAtMs: 2000, channel: 'remote', languageDisclosureVersion: null });
expect(second.envelopeCompletedNow).toBe(true);
expect(second.envelopeStatus).toBe('signed');
@@ -163,7 +163,7 @@ describe('AgreementService — signer-level envelope state machine', () => {
const signers = await testDb.select().from(schema.agreementSigners)
.orderBy(asc(schema.agreementSigners.createdAt)).all();
const link1 = await svc.getSignerLink(TENANT_A, r.requestId, signers[0].id);
- const res = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'in_person' });
+ const res = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'in_person', languageDisclosureVersion: null });
expect(res.envelopeCompletedNow).toBe(true);
expect(res.envelopeStatus).toBe('signed');
});
@@ -205,9 +205,9 @@ describe('AgreementService — signer-level envelope state machine', () => {
});
const s = await testDb.select().from(schema.agreementSigners).all();
const link = await svc.getSignerLink(TENANT_A, r.requestId, s[0].id);
- const first = await svc.markSignedBySigner(link, 'sig', { signedAtMs: 1000, channel: 'remote' });
+ const first = await svc.markSignedBySigner(link, 'sig', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null });
expect(first.envelopeCompletedNow).toBe(true);
- const second = await svc.markSignedBySigner(link, 'sig-again', { signedAtMs: 2000, channel: 'remote' });
+ const second = await svc.markSignedBySigner(link, 'sig-again', { signedAtMs: 2000, channel: 'remote', languageDisclosureVersion: null });
expect(second.envelopeCompletedNow).toBe(false);
});
@@ -328,6 +328,7 @@ describe('AgreementService — signer-level envelope state machine', () => {
signedAtMs: 5000, channel: 'in_person',
ipAddress: '1.2.3.4', userAgent: 'UA/1.0',
onBehalfOf: 'Jane Buyer', onBehalfDisclaimer: 'authorized agent',
+ languageDisclosureVersion: null,
});
const row = await testDb.select().from(schema.agreementSigners).where(eq(schema.agreementSigners.id, s[0].id)).get();
expect(row!.channel).toBe('in_person');
@@ -338,6 +339,32 @@ describe('AgreementService — signer-level envelope state machine', () => {
expect(row!.signatureBase64).toBe('sig');
});
+ // The version is evidence, so it has to land on the row that carries the
+ // signature — not be inferred later from a timestamp against a changelog.
+ it('records the language-disclosure version the caller states', async () => {
+ const r = await svc.findOrCreate(TENANT_A, INSP_ID, { signers: [{ name: 'Jane', email: 'jane@test.com' }], completionPolicy: 'one' });
+ const s = await testDb.select().from(schema.agreementSigners).all();
+ const link = await svc.getSignerLink(TENANT_A, r.requestId, s[0].id);
+ await svc.markSignedBySigner(link, 'sig', {
+ signedAtMs: 5000, channel: 'remote', languageDisclosureVersion: 7,
+ });
+ const row = await testDb.select().from(schema.agreementSigners).where(eq(schema.agreementSigners.id, s[0].id)).get();
+ // 7, not "whatever the constant says today" — the service must not
+ // substitute its own idea of the current version for the caller's claim.
+ expect(row!.languageDisclosureVersion).toBe(7);
+ });
+
+ it('leaves the version NULL when the caller cannot vouch for a screen', async () => {
+ const r = await svc.findOrCreate(TENANT_A, INSP_ID, { signers: [{ name: 'Jane', email: 'jane@test.com' }], completionPolicy: 'one' });
+ const s = await testDb.select().from(schema.agreementSigners).all();
+ const link = await svc.getSignerLink(TENANT_A, r.requestId, s[0].id);
+ await svc.markSignedBySigner(link, 'sig', {
+ signedAtMs: 5000, channel: 'in_person', languageDisclosureVersion: null,
+ });
+ const row = await testDb.select().from(schema.agreementSigners).where(eq(schema.agreementSigners.id, s[0].id)).get();
+ expect(row!.languageDisclosureVersion).toBeNull();
+ });
+
it('single-fire: sign A (1/2) then sign B TWICE -> exactly one envelopeCompletedNow=true', async () => {
const r = await svc.findOrCreate(TENANT_A, INSP_ID, {
signers: [
@@ -352,13 +379,13 @@ describe('AgreementService — signer-level envelope state machine', () => {
const linkB = await svc.getSignerLink(TENANT_A, r.requestId, signers[1].id);
// A signs first: envelope 1/2, not complete.
- const a = await svc.markSignedBySigner(linkA, 'sig-jane', { signedAtMs: 1000, channel: 'remote' });
+ const a = await svc.markSignedBySigner(linkA, 'sig-jane', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null });
expect(a.envelopeCompletedNow).toBe(false);
// B signs (2/2) — this completes the envelope. Second call is the
// idempotent re-sign of an already-signed signer.
- const b1 = await svc.markSignedBySigner(linkB, 'sig-john', { signedAtMs: 2000, channel: 'remote' });
- const b2 = await svc.markSignedBySigner(linkB, 'sig-john-again', { signedAtMs: 3000, channel: 'remote' });
+ const b1 = await svc.markSignedBySigner(linkB, 'sig-john', { signedAtMs: 2000, channel: 'remote', languageDisclosureVersion: null });
+ const b2 = await svc.markSignedBySigner(linkB, 'sig-john-again', { signedAtMs: 3000, channel: 'remote', languageDisclosureVersion: null });
const fires = [a, b1, b2].filter((x) => x.envelopeCompletedNow).length;
expect(fires).toBe(1);
@@ -383,8 +410,8 @@ describe('AgreementService — signer-level envelope state machine', () => {
// deterministically, but the service awaits between read + write, so the
// atomic claim (conditional UPDATE row-count) is what guarantees single-fire.
const [c1, c2] = await Promise.all([
- svc.markSignedBySigner(link, 'sig-1', { signedAtMs: 1000, channel: 'remote' }),
- svc.markSignedBySigner(link, 'sig-2', { signedAtMs: 1000, channel: 'remote' }),
+ svc.markSignedBySigner(link, 'sig-1', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null }),
+ svc.markSignedBySigner(link, 'sig-2', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null }),
]);
const fires = [c1, c2].filter((x) => x.envelopeCompletedNow).length;
expect(fires).toBeLessThanOrEqual(1);
@@ -475,7 +502,7 @@ describe('AgreementService — signer-level envelope state machine', () => {
// Sign as Jane → envelope may stay 'viewed' (policy 'all', Bob outstanding),
// but Jane's signer row becomes 'signed'. Her token must still resolve.
await svc.markViewedBySigner(janeToken);
- await svc.markSignedBySigner(janeToken, 'data:image/png;base64,XX', { signedAtMs: Date.now(), channel: 'remote' });
+ await svc.markSignedBySigner(janeToken, 'data:image/png;base64,XX', { signedAtMs: Date.now(), channel: 'remote', languageDisclosureVersion: null });
const link = await svc.getSignerLinkByEmail(TENANT_A, INSP_ID, 'jane@test.com');
expect(link).toBeTruthy();
});
diff --git a/tests/unit/agreements/agreement.service.spec.ts b/tests/unit/agreements/agreement.service.spec.ts
index 96d5e66d5..2adf06215 100644
--- a/tests/unit/agreements/agreement.service.spec.ts
+++ b/tests/unit/agreements/agreement.service.spec.ts
@@ -29,6 +29,6 @@ describe('AgreementService', () => {
it('markSignedBySigner on a declined signer throws Conflict', async () => {
const { token } = await svc.findOrCreate(TENANT_A, INSP_ID);
await svc.markDeclinedBySigner(token);
- await expect(svc.markSignedBySigner(token, 'sig', { signedAtMs: Date.now(), channel: 'remote' })).rejects.toThrow();
+ await expect(svc.markSignedBySigner(token, 'sig', { signedAtMs: Date.now(), channel: 'remote', languageDisclosureVersion: null })).rejects.toThrow();
});
});
diff --git a/tests/unit/agreements/agreements-render.spec.ts b/tests/unit/agreements/agreements-render.spec.ts
index 70cfbfaa0..d77a5a065 100644
--- a/tests/unit/agreements/agreements-render.spec.ts
+++ b/tests/unit/agreements/agreements-render.spec.ts
@@ -7,6 +7,7 @@ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
import { agreementRenderHandler, certRenderHandler } from '../../../server/api/agreements-render';
+import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../../../server/lib/legal/agreement-language-disclosure';
const TENANT_A = '00000000-0000-0000-0000-000000000001';
const INSP_ID = '00000000-0000-0000-0000-000000000010';
@@ -375,8 +376,33 @@ describe('cert-render handler', () => {
// content snapshot verbatim, `content_hash` is taken over the stored string, and
// anything added inside it would both rewrite the record of what was signed and
// make us the author of a term in a contract we are not a party to.
+//
+// And the agreement has to be MUTUAL: the document may only carry the note when
+// the signatures on it recorded the version of the copy that is live now. There
+// is no archive of superseded copy, so against an older signature the choice is
+// between printing nothing and printing words that signer never read.
// ---------------------------------------------------------------------------
+const SIGNER_ID = '00000000-0000-0000-0000-000000000200';
+
+/**
+ * A signed signer on REQ_ID whose record says which disclosure version it saw.
+ * `version` null = the record says nothing (pre-feature signature, or the
+ * on-site API surface the platform does not draw).
+ */
+async function insertSignedSigner(
+ db: BetterSQLite3Database,
+ version: number | null,
+): Promise {
+ await db.insert(schema.agreementSigners).values({
+ id: SIGNER_ID, tenantId: TENANT_A, requestId: REQ_ID,
+ name: 'Jane Doe', email: 'jane@x', role: 'client', status: 'signed',
+ signatureBase64: 'data:image/png;base64,clientsig',
+ signedAt: new Date(), createdAt: new Date(),
+ languageDisclosureVersion: version,
+ });
+}
+
/** The verbatim contents of the `.body` box — the snapshot, and nothing else. */
function bodyBoxOf(html: string): string {
// escapeHtml() leaves no markup inside the box, so the first closing tag is
@@ -415,6 +441,7 @@ describe('agreement-render handler — language disclosure', () => {
contentHash: 'deadbeef',
createdAt: new Date(),
});
+ await insertSignedSigner(db, AGREEMENT_LANGUAGE_DISCLOSURE.version);
(mockDrizzle as unknown as ReturnType).mockReturnValue(db);
});
@@ -456,4 +483,114 @@ describe('agreement-render handler — language disclosure', () => {
.where(eq(schema.agreements.id, AGR_ID)).get();
expect(agreement!.content).toBe('Agreement body
');
});
+
+ // The three cases below are the whole reason the version is on the signature.
+ // Each replaces the signer seeded in beforeEach, so the ONLY difference
+ // between them and the passing case above is what the record says.
+
+ it('omits it when the signature recorded no version at all', async () => {
+ await db.delete(schema.agreementSigners).where(eq(schema.agreementSigners.id, SIGNER_ID));
+ await insertSignedSigner(db, null);
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ // The document still renders — it is only the claim that is withheld.
+ expect(html).toContain('Snapshot at sign time');
+ expect(html).not.toMatch(/provided in English/i);
+ expect(html).not.toContain('Not part of this agreement');
+ });
+
+ it('omits it when the signature recorded a SUPERSEDED version', async () => {
+ await db.delete(schema.agreementSigners).where(eq(schema.agreementSigners.id, SIGNER_ID));
+ await insertSignedSigner(db, AGREEMENT_LANGUAGE_DISCLOSURE.version - 1);
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ expect(html).toContain('Snapshot at sign time');
+ // Superseded copy is not archived. Printing today's words here would put a
+ // sentence in front of a judge that this signer demonstrably did not read.
+ expect(html).not.toMatch(/provided in English/i);
+ });
+
+ it('omits it when ONE of several signers has no version — the document is one record', async () => {
+ await db.insert(schema.agreementSigners).values({
+ id: '00000000-0000-0000-0000-000000000201', tenantId: TENANT_A, requestId: REQ_ID,
+ name: 'John Doe', email: 'john@x', role: 'co_client', status: 'signed',
+ signatureBase64: 'data:image/png;base64,cosig',
+ signedAt: new Date(), createdAt: new Date(),
+ languageDisclosureVersion: null,
+ });
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ expect(html).not.toMatch(/provided in English/i);
+ });
+
+ it('the legacy envelope-level signature (no signer rows) carries no claim', async () => {
+ await db.delete(schema.agreementSigners).where(eq(schema.agreementSigners.requestId, REQ_ID));
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ // Fallback block still renders the signature…
+ expect(html).toContain('Jane Doe');
+ // …and says nothing about a notice nobody recorded.
+ expect(html).not.toMatch(/provided in English/i);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The certificate of completion is a different kind of document: it states
+// FACTS ABOUT the signing event rather than reproducing what was signed. So it
+// can report a superseded version number honestly — "notice v1 was displayed"
+// is true forever — where the archived copy above cannot reproduce v1's words.
+// ---------------------------------------------------------------------------
+describe('cert-render handler — language disclosure version', () => {
+ let db: BetterSQLite3Database;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ await db.insert(schema.tenants).values({
+ id: TENANT_A, name: 'A', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.inspections).values({
+ id: INSP_ID, tenantId: TENANT_A, propertyAddress: '1 Main St', clientName: 'Jane',
+ clientEmail: 'jane@x', date: '2026-06-01', status: 'requested', paymentStatus: 'unpaid',
+ price: 0, createdAt: new Date(),
+ } as never);
+ await db.insert(schema.agreements).values({
+ id: AGR_ID, tenantId: TENANT_A, name: 'Standard', content: 'Agreement body
',
+ version: 1, createdAt: new Date(),
+ });
+ await db.insert(schema.agreementRequests).values({
+ id: REQ_ID, tenantId: TENANT_A, inspectionId: INSP_ID, agreementId: AGR_ID,
+ clientEmail: 'jane@x', clientName: 'Jane Doe',
+ token: TOKEN_A, status: 'signed',
+ signatureBase64: 'data:image/png;base64,clientsig',
+ signedAt: new Date(), createdAt: new Date(),
+ });
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+ });
+
+ it('reports the version the signer was shown', async () => {
+ await insertSignedSigner(db, AGREEMENT_LANGUAGE_DISCLOSURE.version);
+ const res = await certRenderHandler({} as D1Database, REQ_ID);
+ const html = await res.text();
+ expect(html).toContain(`Language notice v${AGREEMENT_LANGUAGE_DISCLOSURE.version} displayed`);
+ });
+
+ it('reports a version that is NOT the current one rather than suppressing it', async () => {
+ const superseded = AGREEMENT_LANGUAGE_DISCLOSURE.version - 1;
+ await insertSignedSigner(db, superseded);
+ const res = await certRenderHandler({} as D1Database, REQ_ID);
+ const html = await res.text();
+ expect(html).toContain(`Language notice v${superseded} displayed`);
+ });
+
+ it('says nothing when nothing was recorded — no "v0", no "none"', async () => {
+ await insertSignedSigner(db, null);
+ const res = await certRenderHandler({} as D1Database, REQ_ID);
+ const html = await res.text();
+ // Prove the roster rendered before trusting the absence.
+ expect(html).toContain('Jane Doe');
+ expect(html).not.toMatch(/Language notice/i);
+ });
});
diff --git a/tests/unit/agreements/language-disclosure.spec.ts b/tests/unit/agreements/language-disclosure.spec.ts
index 1fb54d8f4..ab277194b 100644
--- a/tests/unit/agreements/language-disclosure.spec.ts
+++ b/tests/unit/agreements/language-disclosure.spec.ts
@@ -5,6 +5,7 @@ import { describe, it, expect } from 'vitest';
import {
AGREEMENT_LANGUAGE_DISCLOSURE as D,
DISCLOSURE_SANITIZER_PROFILE,
+ signaturesRecordCurrentDisclosure,
} from '../../../server/lib/legal/agreement-language-disclosure';
import { sanitizeAgreementHtml } from '../../../server/services/agreement/sanitizer';
@@ -59,6 +60,82 @@ describe('agreement language disclosure', () => {
});
});
+// ---------------------------------------------------------------------------
+// What an EVIDENCE surface may say. The signing screen shows a person the copy
+// that exists while they are standing there — always truthful. A document
+// produced afterwards is making a claim about the past, and may only make it
+// when the record supports it.
+// ---------------------------------------------------------------------------
+describe('agreement language disclosure — what the record supports', () => {
+ it('says yes when every signature recorded the version that is live now', () => {
+ expect(signaturesRecordCurrentDisclosure([D.version])).toBe(true);
+ expect(signaturesRecordCurrentDisclosure([D.version, D.version])).toBe(true);
+ });
+
+ it('says no when a signature recorded nothing', () => {
+ // Pre-feature signatures, and the on-site API surface where the caller —
+ // not the platform — draws the screen.
+ expect(signaturesRecordCurrentDisclosure([null])).toBe(false);
+ expect(signaturesRecordCurrentDisclosure([undefined])).toBe(false);
+ });
+
+ it('says no when a signature recorded a DIFFERENT version', () => {
+ // Superseded copy is archived nowhere: a bump replaces the string. So the
+ // only alternatives for an older signature are printing nothing and
+ // printing words that signer never saw.
+ expect(signaturesRecordCurrentDisclosure([D.version - 1])).toBe(false);
+ expect(signaturesRecordCurrentDisclosure([D.version + 1])).toBe(false);
+ });
+
+ it('needs EVERY signature, not just one — the document is a single record', () => {
+ expect(signaturesRecordCurrentDisclosure([D.version, null])).toBe(false);
+ expect(signaturesRecordCurrentDisclosure([null, D.version])).toBe(false);
+ });
+
+ it('says no on an empty set — "every" is vacuously true and would be a lie', () => {
+ // A document with no signatures on it is not a document that shows what
+ // anyone was shown.
+ expect(signaturesRecordCurrentDisclosure([])).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Who may put a version on the record. Only a surface the PLATFORM renders can
+// state what a signer saw; an API that hands the agreement text to a caller and
+// takes back a signature knows nothing about the screen in between.
+// ---------------------------------------------------------------------------
+describe('agreement language disclosure — who may claim a version', () => {
+ const PLATFORM_RENDERED_SIGN_ROUTE = 'server/api/bookings/agreement.ts';
+ const CALLER_RENDERED_SIGN_ROUTE = 'server/api/inspections/agreements.ts';
+
+ /** The `languageDisclosureVersion:` argument each sign call passes. */
+ function versionArgIn(src: string): string | null {
+ const m = src.match(/languageDisclosureVersion:\s*([^,\n]+)/);
+ return m ? m[1].trim() : null;
+ }
+
+ it('the extractor actually extracts', () => {
+ expect(versionArgIn(' languageDisclosureVersion: null,')).toBe('null');
+ expect(versionArgIn('const x = 1;')).toBeNull();
+ });
+
+ it('the route serving the platform-drawn signing pages records the live version', () => {
+ const src = readFileSync(join(REPO_ROOT, PLATFORM_RENDERED_SIGN_ROUTE), 'utf8');
+ expect(versionArgIn(src), `${PLATFORM_RENDERED_SIGN_ROUTE} stopped recording a version`)
+ .toBe('AGREEMENT_LANGUAGE_DISCLOSURE.version');
+ });
+
+ it('the on-site API route records NOTHING — it did not draw the screen', () => {
+ // `GET /:id/agreement` hands the agreement text to a caller that renders
+ // its own surface. A version written here would assert something the
+ // platform cannot know. Give this endpoint a surface we render and the
+ // answer changes; until then null is the only true value.
+ const src = readFileSync(join(REPO_ROOT, CALLER_RENDERED_SIGN_ROUTE), 'utf8');
+ expect(versionArgIn(src), `${CALLER_RENDERED_SIGN_ROUTE} now claims a version it cannot know`)
+ .toBe('null');
+ });
+});
+
/** Every element name that appears in `html`, lowercased. */
function tagsIn(html: string): string[] {
return [...html.matchAll(/<\/?([a-z][a-z0-9-]*)\b/gi)].map((m) => m[1].toLowerCase());
@@ -252,4 +329,31 @@ describe('agreement language disclosure — containment', () => {
.toBe(false);
}
});
+
+ it('the verifier ENDPOINT decides the flag from the signed signatures only', () => {
+ // A pending signer has been shown nothing and recorded nothing. Folding it
+ // into the check would suppress the notice on a document whose actual
+ // signatories all saw it — a false negative that looks like caution.
+ const src = readFileSync(join(REPO_ROOT, 'server/api/public/verify.ts'), 'utf8');
+ expect(src).toContain('signaturesRecordCurrentDisclosure');
+ expect(
+ /languageDisclosureCurrent[\s\S]{0,400}?status\s*===\s*'signed'/.test(src),
+ 'the verifier endpoint no longer restricts the check to signed signers',
+ ).toBe(true);
+ });
+
+ it('the public verifier renders the disclosure ONLY behind the record check', () => {
+ // /verify is an evidence surface, not a signing surface: it shows what
+ // was signed, to someone who was not there. Reusing the same component is
+ // right — one block, one wording — but it may only appear when the server
+ // has confirmed every signature recorded the version that is live now.
+ // The gate is a server-decided boolean precisely so this page cannot
+ // re-derive the rule and drift from the archived copy.
+ const src = readFileSync(join(REPO_ROOT, 'app/routes/public/verify.tsx'), 'utf8');
+ expect(src).toMatch(/ {
id: 's1', tenantId: TENANT_A, requestId: REQ_ID,
name: 'Jane Doe', email: 'jane@x', role: 'client',
status: 'signed', channel: 'in_person', signedAt: new Date(), createdAt: new Date(1),
+ languageDisclosureVersion: 3,
},
{
id: 's2', tenantId: TENANT_A, requestId: REQ_ID,
@@ -90,6 +91,10 @@ describe('loadVerifyData — Track I-a snapshot + signers', () => {
for (const s of data!.signers) {
expect(s).not.toHaveProperty('email');
}
+ // The verifier decides one thing with this: whether the page may print the
+ // current language disclosure. It is not exposed per-signer in the response.
+ expect(data!.signers[0].languageDisclosureVersion).toBe(3);
+ expect(data!.signers[1].languageDisclosureVersion).toBeNull();
});
it('exposes a NULL snapshot for pre-feature envelopes', async () => {
From 2a532dad1d40def767676f47b52bd282547fed03 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 09:00:50 +0800
Subject: [PATCH 019/111] docs(agreements): put counsel's reasoning, and what
it does NOT settle, in the module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Counsel's recommendation was already reflected in the disclosure's shape. What
was missing is everything around it: the date, the three facts that make the
recommendation land, and — the part that costs something if it is lost — that
California Civil Code §1632 is still an open question, and what that question
gates.
The reader this header is now written for is the one about to translate the
agreement body. That work is blocked on §1632, it must not be started on an
assumption about the answer, and translating the disclosure is not a route
around it. Nothing in the module said so; a person could have read the whole
file, seen a disclosure about language, and concluded translation was the
obvious next step.
The three supplied facts are recorded with their edges intact rather than
summarised into comfort. The material one is that we hold NO record of the
language a booking was negotiated in — negotiation is typically a phone call
outside the software — and that contacts.locale is a stated reading preference
that must not be offered as evidence of it. That distinction is the one most
likely to be lost by someone looking for a field to point at.
One question is deliberately unasked and now says so where it will be found: if
a tenant offers a courtesy Spanish report, does that report's limitations notice
suffice or does the agreement need a companion sentence? It depends on wording
that does not exist yet, so asking now buys an answer to the wrong question.
Whoever schedules that work owns asking it.
Four assertions hold the record in place — the date, the §1632 gate and what it
blocks, the parked question, and that the cited counsel document EXISTS, so a
rename fails in CI rather than in a dispute. A tidy-up that prunes "background"
comments is the realistic way this gets deleted. All four were run against a
stripped module first and went red.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
.../legal/agreement-language-disclosure.ts | 45 ++++++++++++++++--
.../agreements/language-disclosure.spec.ts | 47 ++++++++++++++++++-
2 files changed, 87 insertions(+), 5 deletions(-)
diff --git a/server/lib/legal/agreement-language-disclosure.ts b/server/lib/legal/agreement-language-disclosure.ts
index c2286abaa..d80a64aff 100644
--- a/server/lib/legal/agreement-language-disclosure.ts
+++ b/server/lib/legal/agreement-language-disclosure.ts
@@ -1,10 +1,10 @@
/**
* Neutral platform disclosure shown ALONGSIDE an inspection agreement.
*
- * Rewritten on counsel's advice. An earlier design embedded InterNACHI's
- * governing-language clause into the agreement body. Counsel: "do not embed the
- * InterNACHI clause as platform contractual language — if implemented, position
- * it as a neutral platform disclosure."
+ * Rewritten on counsel's advice, received 2026-08-02. An earlier design embedded
+ * InterNACHI's governing-language clause into the agreement body. Counsel: "do
+ * not embed the InterNACHI clause as platform contractual language — if
+ * implemented, position it as a neutral platform disclosure."
*
* A governing-language provision allocates risk between the tenant and their
* client. We are not a party to that contract, we author none of its text, and
@@ -41,6 +41,43 @@
* pass. The agreement itself stays English and is never translated here — the
* disclosure is how an English-only agreement is handled honestly, not a step
* toward translating one.
+ *
+ * ## What is NOT settled — read before extending this (counsel, 2026-08-02)
+ *
+ * **California Civil Code §1632 is unresolved.** Counsel gave a preliminary
+ * position only: applicability turns on whether the agreement falls in an
+ * enumerated contract category AND whether the transaction was primarily
+ * negotiated in a covered language. Three facts were requested and supplied
+ * (`docs/legal/2026-08-02-counsel-response-and-followup.md`):
+ *
+ * 1. **Negotiation language: we hold no record of it**, and that absence is the
+ * honest answer rather than a gap to paper over. Nothing captures it, and
+ * the negotiation itself is typically a phone call outside the software.
+ * `contacts.locale` is a stated READING preference and must not be offered
+ * as evidence of the language a deal was struck in.
+ * 2. **Platform role: not a party.** We carry the agreement and attest to the
+ * signing. We do not negotiate, advise, or take a fee from the transaction.
+ * 3. **Template/control: none.** Every tenant authors their own agreement body
+ * and versions it; we review no terms. Which is why the clause would have
+ * been the ONLY contractual text we wrote in that document.
+ *
+ * The disclosure below did NOT wait on that answer, and deliberately: it asserts
+ * nothing contractual, so it is useful under either §1632 answer and harmful
+ * under neither.
+ *
+ * **What DOES wait on it: translated agreements.** If §1632 reaches this
+ * contract type the build is a different one — translated versions, a
+ * language-of-negotiation record, a per-state rule — and none of it may be
+ * started on an assumption about the answer. If you arrived here intending to
+ * translate the agreement body, that is the work this note is about, and the
+ * §1632 answer is its gate. Translating the disclosure is not a route around it.
+ *
+ * **One question is deliberately unasked.** If a tenant later offers a courtesy
+ * Spanish REPORT, does that report's own limitations notice suffice, or does the
+ * agreement need a companion sentence? It goes to counsel when the
+ * courtesy-translation work is scheduled and not before — the answer depends on
+ * what that notice ends up saying, so asking early buys an answer to the wrong
+ * question. Whoever schedules that work owns asking it.
*/
/** Version of the disclosure copy. Bump on ANY wording change, `label` included. */
diff --git a/tests/unit/agreements/language-disclosure.spec.ts b/tests/unit/agreements/language-disclosure.spec.ts
index ab277194b..6314cc605 100644
--- a/tests/unit/agreements/language-disclosure.spec.ts
+++ b/tests/unit/agreements/language-disclosure.spec.ts
@@ -1,4 +1,4 @@
-import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';
@@ -60,6 +60,51 @@ describe('agreement language disclosure', () => {
});
});
+// ---------------------------------------------------------------------------
+// The counsel record. This module's header is the ONLY place the reasoning
+// lives next to the code, and it exists to stop two specific things: re-asking
+// counsel a question already answered, and starting translated-agreement work
+// on an assumption about a question that is NOT answered. A future tidy-up that
+// prunes "background" comments would delete both without anyone noticing —
+// which is what this guard is for.
+// ---------------------------------------------------------------------------
+describe('agreement language disclosure — the counsel record survives', () => {
+ const MODULE = 'server/lib/legal/agreement-language-disclosure.ts';
+ const COUNSEL_DOC = 'docs/legal/2026-08-02-counsel-response-and-followup.md';
+ const src = () => readFileSync(join(REPO_ROOT, MODULE), 'utf8');
+
+ it('dates the advice — undated legal reasoning cannot be superseded safely', () => {
+ expect(src()).toContain('2026-08-02');
+ // Prove the read is of the module and not an empty string.
+ expect(src()).toContain('DISCLOSURE_VERSION');
+ });
+
+ it('cites a document that EXISTS, so a rename fails here and not in a dispute', () => {
+ expect(src()).toContain(COUNSEL_DOC);
+ // The superproject holds docs/; the module lives in this repo. Resolve up
+ // one level, and assert the resolution itself works before trusting it.
+ const docPath = resolve(REPO_ROOT, '..', '..', COUNSEL_DOC);
+ expect(existsSync(resolve(REPO_ROOT, '..', '..', 'docs', 'legal')),
+ 'docs/legal moved — this guard is looking in the wrong place').toBe(true);
+ expect(existsSync(docPath), `${COUNSEL_DOC} is cited by ${MODULE} but does not exist`).toBe(true);
+ });
+
+ it('says §1632 is UNRESOLVED and names what that blocks', () => {
+ // The next reader is plausibly someone about to translate the agreement
+ // body. The module has to stop them, not merely fail to encourage them.
+ expect(src()).toMatch(/1632/);
+ expect(src()).toMatch(/unresolved|not settled|NOT settled/i);
+ expect(src()).toMatch(/translated agreements?/i);
+ });
+
+ it('keeps the deliberately-unasked question findable', () => {
+ // Deferred on purpose: the answer depends on what a courtesy-translation
+ // notice ends up saying. Deferred is not the same as forgotten, and a
+ // plan file nobody re-opens is where it would have been forgotten.
+ expect(src()).toMatch(/courtesy/i);
+ });
+});
+
// ---------------------------------------------------------------------------
// What an EVIDENCE surface may say. The signing screen shows a person the copy
// that exists while they are standing there — always truthful. A document
From 01b8335a4763fe8156ecfacbb5d5ab87b6fb98ee Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 09:12:17 +0800
Subject: [PATCH 020/111] feat(#270): tenant + user date/time format
preferences
Enums, not a pattern string: three real answers do not justify a parser and a
validation surface. Defaults reproduce today's output exactly (us + 12h), so
nothing changes for anyone until they choose.
The workers specs build tenant_configs and users from literal CREATE TABLE
strings, and the cmd-apply upsert binds every schema column, so both DDLs grow
the columns too. Only the tenant_configs copy is guarded by a sync test; the
users copy is duplicated inline in two specs with no guard.
---
migrations/0034_sweet_smiling_tiger.sql | 4 +
migrations/meta/0034_snapshot.json | 10346 ++++++++++++++++++++++
migrations/meta/_journal.json | 7 +
server/lib/db/schema/tenant/core.ts | 7 +
server/lib/db/schema/tenant/user.ts | 6 +
tests/helpers/inline-ddl.ts | 2 +-
tests/workers/cmd-consumer.spec.ts | 2 +-
tests/workers/cmd-fixtures.spec.ts | 2 +-
8 files changed, 10373 insertions(+), 3 deletions(-)
create mode 100644 migrations/0034_sweet_smiling_tiger.sql
create mode 100644 migrations/meta/0034_snapshot.json
diff --git a/migrations/0034_sweet_smiling_tiger.sql b/migrations/0034_sweet_smiling_tiger.sql
new file mode 100644
index 000000000..8505cc6c5
--- /dev/null
+++ b/migrations/0034_sweet_smiling_tiger.sql
@@ -0,0 +1,4 @@
+ALTER TABLE `tenant_configs` ADD `date_format` text DEFAULT 'us' NOT NULL;--> statement-breakpoint
+ALTER TABLE `tenant_configs` ADD `time_format` text DEFAULT '12h' NOT NULL;--> statement-breakpoint
+ALTER TABLE `users` ADD `date_format` text;--> statement-breakpoint
+ALTER TABLE `users` ADD `time_format` text;
\ No newline at end of file
diff --git a/migrations/meta/0034_snapshot.json b/migrations/meta/0034_snapshot.json
new file mode 100644
index 000000000..f2c7db19f
--- /dev/null
+++ b/migrations/meta/0034_snapshot.json
@@ -0,0 +1,10346 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "7da9b9a9-1cc7-493a-9cc6-51e7e23056ce",
+ "prevId": "751269e4-c0b0-4cba-b006-f51f05d4c5a6",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "language_disclosure_version": {
+ "name": "language_disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'us'"
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'12h'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index b0dd85ef8..4214cadf1 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -239,6 +239,13 @@
"when": 1785803986362,
"tag": "0033_lumpy_calypso",
"breakpoints": true
+ },
+ {
+ "idx": 34,
+ "version": "6",
+ "when": 1785805642371,
+ "tag": "0034_sweet_smiling_tiger",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/lib/db/schema/tenant/core.ts b/server/lib/db/schema/tenant/core.ts
index 7c283df16..fcbea1b2c 100644
--- a/server/lib/db/schema/tenant/core.ts
+++ b/server/lib/db/schema/tenant/core.ts
@@ -246,6 +246,13 @@ export const tenantConfigs = sqliteTable('tenant_configs', {
// Optional full-page body for hosted mode; null = built-in template.
privacyBody: text('privacy_body'),
termsBody: text('terms_body'),
+ // #270 — display SHAPE, independent of language. A US user wanting a
+ // 24-hour clock has no locale that expresses it: en-US implies 12h, en-GB
+ // implies 24h but also DD/MM and British spellings. NULL is not allowed
+ // here — the tenant default is the bottom of the resolution chain.
+ // Appended at END of the table per the D1 add-column-at-end rule.
+ dateFormat: text('date_format', { enum: ['us', 'iso', 'eu'] }).notNull().default('us'),
+ timeFormat: text('time_format', { enum: ['12h', '24h'] }).notNull().default('12h'),
});
/**
diff --git a/server/lib/db/schema/tenant/user.ts b/server/lib/db/schema/tenant/user.ts
index d54547bc4..6e9fcd0d1 100644
--- a/server/lib/db/schema/tenant/user.ts
+++ b/server/lib/db/schema/tenant/user.ts
@@ -94,6 +94,12 @@ export const users = sqliteTable('users', {
// Per-user display-locale override (BCP-47). NULL = inherit the tenant's
// default_locale. Affects only this user's UI.
locale: text('locale'),
+ // #270 — per-user override. NULL = inherit the tenant's setting, the same
+ // convention as `timezone` directly above. Governs this user's own
+ // workspace chrome only; inspection/report/appointment rendering always
+ // anchors to the tenant so all three parties read the same date aloud.
+ dateFormat: text('date_format', { enum: ['us', 'iso', 'eu'] }),
+ timeFormat: text('time_format', { enum: ['12h', '24h'] }),
}, (t) => [
index('idx_users_deleted_at').on(t.deletedAt),
// DB-2: soft-deleted rows must not block re-inviting the same email.
diff --git a/tests/helpers/inline-ddl.ts b/tests/helpers/inline-ddl.ts
index 61e599138..7c79a6470 100644
--- a/tests/helpers/inline-ddl.ts
+++ b/tests/helpers/inline-ddl.ts
@@ -21,7 +21,7 @@
* one sync assertion.
*/
export const TENANT_CONFIGS_TEST_DDL =
- 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, company_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, video_mode TEXT, sms_mode TEXT, sender_display_name TEXT, point_of_contact TEXT, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, secrets_enc TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, default_profile_id TEXT, attention_thresholds TEXT, inspection_prefs TEXT, is_estimates_shown INTEGER, is_repair_list_enabled INTEGER, is_customer_repair_export_enabled INTEGER, is_unpaid_blocked INTEGER, is_unsigned_agreement_blocked INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, is_concierge_review_required INTEGER, is_inspector_choice_allowed INTEGER, is_pdf_pipeline_enabled INTEGER, auto_sign_on_publish_default INTEGER, is_team_mode_default TEXT, is_apprentice_review_required INTEGER, is_guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, reinspection_statuses TEXT, is_collab_editing_enabled INTEGER NOT NULL DEFAULT 1, company_address TEXT, is_pdf_footer_shown INTEGER, is_pdf_page_numbers_shown INTEGER, is_pdf_license_shown INTEGER, sms_byo_provider TEXT, email_byo_provider TEXT, is_managed_eligible INTEGER NOT NULL DEFAULT 0, managed_provider TEXT NOT NULL DEFAULT \'twilio\', is_reserve_schedule_enabled INTEGER, reserve_term_years INTEGER, inflation_rate_bps INTEGER, default_timezone TEXT NOT NULL DEFAULT \'UTC\', booking_slot_mode TEXT NOT NULL DEFAULT \'fixed\', booking_slot_interval_min INTEGER NOT NULL DEFAULT 30, holiday_region TEXT, holiday_public_policy TEXT NOT NULL DEFAULT \'open\', holiday_internal_policy TEXT NOT NULL DEFAULT \'advisory\', default_locale TEXT NOT NULL DEFAULT \'en-US\', currency TEXT NOT NULL DEFAULT \'USD\', is_archive_revoking_access INTEGER NOT NULL DEFAULT 0, legal_mode TEXT NOT NULL DEFAULT \'hosted\', custom_privacy_url TEXT, custom_terms_url TEXT, privacy_body TEXT, terms_body TEXT, updated_at INTEGER);';
+ 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, company_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, video_mode TEXT, sms_mode TEXT, sender_display_name TEXT, point_of_contact TEXT, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, secrets_enc TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, default_profile_id TEXT, attention_thresholds TEXT, inspection_prefs TEXT, is_estimates_shown INTEGER, is_repair_list_enabled INTEGER, is_customer_repair_export_enabled INTEGER, is_unpaid_blocked INTEGER, is_unsigned_agreement_blocked INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, is_concierge_review_required INTEGER, is_inspector_choice_allowed INTEGER, is_pdf_pipeline_enabled INTEGER, auto_sign_on_publish_default INTEGER, is_team_mode_default TEXT, is_apprentice_review_required INTEGER, is_guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, reinspection_statuses TEXT, is_collab_editing_enabled INTEGER NOT NULL DEFAULT 1, company_address TEXT, is_pdf_footer_shown INTEGER, is_pdf_page_numbers_shown INTEGER, is_pdf_license_shown INTEGER, sms_byo_provider TEXT, email_byo_provider TEXT, is_managed_eligible INTEGER NOT NULL DEFAULT 0, managed_provider TEXT NOT NULL DEFAULT \'twilio\', is_reserve_schedule_enabled INTEGER, reserve_term_years INTEGER, inflation_rate_bps INTEGER, default_timezone TEXT NOT NULL DEFAULT \'UTC\', booking_slot_mode TEXT NOT NULL DEFAULT \'fixed\', booking_slot_interval_min INTEGER NOT NULL DEFAULT 30, holiday_region TEXT, holiday_public_policy TEXT NOT NULL DEFAULT \'open\', holiday_internal_policy TEXT NOT NULL DEFAULT \'advisory\', default_locale TEXT NOT NULL DEFAULT \'en-US\', currency TEXT NOT NULL DEFAULT \'USD\', is_archive_revoking_access INTEGER NOT NULL DEFAULT 0, legal_mode TEXT NOT NULL DEFAULT \'hosted\', custom_privacy_url TEXT, custom_terms_url TEXT, privacy_body TEXT, terms_body TEXT, date_format TEXT NOT NULL DEFAULT \'us\', time_format TEXT NOT NULL DEFAULT \'12h\', updated_at INTEGER);';
export const INSPECTION_RESULTS_TEST_DDL =
'CREATE TABLE IF NOT EXISTS inspection_results (id TEXT PRIMARY KEY NOT NULL, tenant_id TEXT NOT NULL, inspection_id TEXT NOT NULL, data TEXT NOT NULL, ydoc_state BLOB, last_synced_at INTEGER NOT NULL, rating_system_id TEXT, rating_system_snapshot TEXT, report_id TEXT);';
diff --git a/tests/workers/cmd-consumer.spec.ts b/tests/workers/cmd-consumer.spec.ts
index 0ebdd0115..a90fc1378 100644
--- a/tests/workers/cmd-consumer.spec.ts
+++ b/tests/workers/cmd-consumer.spec.ts
@@ -48,7 +48,7 @@ async function seedSchema(): Promise {
"CREATE TABLE IF NOT EXISTS sync_outbox (id TEXT PRIMARY KEY, event_type TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_tried_at INTEGER, last_error TEXT);",
);
await b.DB.exec(
- "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT);",
+ "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT, date_format TEXT, time_format TEXT);",
);
await b.DB.exec(
'CREATE TABLE IF NOT EXISTS processed_cmd_events (event_id TEXT PRIMARY KEY, cmd_type TEXT NOT NULL, processed_at INTEGER NOT NULL);',
diff --git a/tests/workers/cmd-fixtures.spec.ts b/tests/workers/cmd-fixtures.spec.ts
index b9e8cab23..b56877cbb 100644
--- a/tests/workers/cmd-fixtures.spec.ts
+++ b/tests/workers/cmd-fixtures.spec.ts
@@ -36,7 +36,7 @@ describe('cmd golden fixtures — consumer can apply every fixture (A-21)', () =
// Full users DDL (mirrors cmd-consumer.spec.ts) — the replyto fixture
// carries credentials, and the drizzle insert binds every column.
await b.DB.exec(
- "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT);",
+ "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT, date_format TEXT, time_format TEXT);",
);
await b.DB.exec('CREATE TABLE IF NOT EXISTS processed_cmd_events (event_id TEXT PRIMARY KEY, cmd_type TEXT NOT NULL, processed_at INTEGER NOT NULL);');
await b.DB.exec('CREATE TABLE IF NOT EXISTS parked_cmd_events (id TEXT PRIMARY KEY, envelope TEXT NOT NULL, reason TEXT NOT NULL, received_at INTEGER NOT NULL);');
From d6f11fa379d7508212c9776c9167d80019cfe593 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 09:25:21 +0800
Subject: [PATCH 021/111] feat(#270): resolve date/time format in the session
context
Per-field fallback, not per-object: a user who set only the clock keeps the
tenant's date order. Same contract as useDisplayTimeZone -- which returns its
default outside a data router rather than throwing, so the chrome still renders
on routes that are not under auth-layout.
resolveDisplayPrefs also rejects a stored value outside the enum. The drizzle
enum is type-layer only and D1 has no CHECK constraint, so a bad row would
otherwise reach Intl as an unknown option key.
useTenantFormatPrefs is the second half of the design rule: anything a second
party also reads anchors to the tenant, and that needs a hook rather than a
convention every caller re-implements.
---
app/hooks/useSessionContext.ts | 48 +++++++++++++++
server/api/session-context.ts | 32 +++++++++-
server/lib/session/display-prefs.ts | 75 ++++++++++++++++++++++++
tests/unit/session/display-prefs.spec.ts | 32 ++++++++++
4 files changed, 186 insertions(+), 1 deletion(-)
create mode 100644 server/lib/session/display-prefs.ts
create mode 100644 tests/unit/session/display-prefs.spec.ts
diff --git a/app/hooks/useSessionContext.ts b/app/hooks/useSessionContext.ts
index 03abec46d..981338d98 100644
--- a/app/hooks/useSessionContext.ts
+++ b/app/hooks/useSessionContext.ts
@@ -1,4 +1,9 @@
import { useRouteLoaderData } from "react-router";
+import {
+ resolveDisplayPrefs,
+ type DateFormat,
+ type TimeFormat,
+} from "../../server/lib/session/display-prefs";
/**
* Session context returned by GET /api/session/context.
@@ -27,6 +32,10 @@ export interface SessionContext {
defaultLocale: string;
/** Tenant transaction/display currency (ISO 4217; 'USD' when unset). */
currency: string;
+ /** Tenant default date order — see #270. Never null; 'us' when unset. */
+ dateFormat: DateFormat;
+ /** Tenant default clock — see #270. Never null; '12h' when unset. */
+ timeFormat: TimeFormat;
};
user: {
name: string | null;
@@ -37,6 +46,10 @@ export interface SessionContext {
timezone: string | null;
/** Per-user locale override (BCP-47), or null to inherit the tenant. */
locale: string | null;
+ /** Per-user date-order override, or null to inherit the tenant (#270). */
+ dateFormat: DateFormat | null;
+ /** Per-user clock override, or null to inherit the tenant (#270). */
+ timeFormat: TimeFormat | null;
};
deployment: {
mode: string;
@@ -89,3 +102,38 @@ export function useDisplayCurrency(): string {
const ctx = useSessionContext();
return ctx?.branding.currency || "USD";
}
+
+/**
+ * The viewer's effective date order and clock (#270) — user override, else
+ * tenant default, else 'us' + '12h', decided PER FIELD. Mirrors
+ * useDisplayTimeZone: no context (outside the auth layout, or the fetch failed)
+ * yields the defaults rather than throwing, so the chrome always renders.
+ *
+ * This is WORKSPACE CHROME only. Anything a second party also reads —
+ * inspection dates, report dates, appointment times — must resolve from the
+ * tenant alone, because the inspector, the client and the agent discuss one
+ * inspection out loud and must say the same date.
+ */
+export function useDisplayFormatPrefs(): { dateFormat: DateFormat; timeFormat: TimeFormat } {
+ const ctx = useSessionContext();
+ return resolveDisplayPrefs(ctx?.user, ctx?.branding);
+}
+
+/** The viewer's effective date order (#270). See useDisplayFormatPrefs. */
+export function useDisplayDateFormat(): DateFormat {
+ return useDisplayFormatPrefs().dateFormat;
+}
+
+/** The viewer's effective clock (#270). See useDisplayFormatPrefs. */
+export function useDisplayTimeFormat(): TimeFormat {
+ return useDisplayFormatPrefs().timeFormat;
+}
+
+/**
+ * The TENANT's date order and clock, ignoring any personal override — the
+ * resolution for inspection / report / appointment rendering.
+ */
+export function useTenantFormatPrefs(): { dateFormat: DateFormat; timeFormat: TimeFormat } {
+ const ctx = useSessionContext();
+ return resolveDisplayPrefs(null, ctx?.branding);
+}
diff --git a/server/api/session-context.ts b/server/api/session-context.ts
index 4a8b09cba..d1385c84c 100644
--- a/server/api/session-context.ts
+++ b/server/api/session-context.ts
@@ -4,6 +4,13 @@ import { and, eq } from 'drizzle-orm';
import { users, tenantConfigs, tenants } from '../lib/db/schema';
import { getSeatUsage } from '../features/seat-quota';
import { resolveLocale } from '../lib/locale';
+import {
+ DEFAULT_DISPLAY_PREFS,
+ isDateFormat,
+ isTimeFormat,
+ type DateFormat,
+ type TimeFormat,
+} from '../lib/session/display-prefs';
import { Errors } from '../lib/errors';
import { logger } from '../lib/logger';
import { mcpEnabled } from '../lib/mcp/flag';
@@ -42,6 +49,12 @@ const sessionContextRoutes = createApiRouter()
let userEmail: string | null = null;
let userTimezone: string | null = null;
let userLocale: string | null = null;
+ // #270 — the raw stored values, not the resolved pair: the client hook
+ // owns resolution, exactly as it already does for timezone and locale.
+ let userDateFormat: DateFormat | null = null;
+ let userTimeFormat: TimeFormat | null = null;
+ let tenantDateFormat: DateFormat = DEFAULT_DISPLAY_PREFS.dateFormat;
+ let tenantTimeFormat: TimeFormat = DEFAULT_DISPLAY_PREFS.timeFormat;
let tenantTimezone = 'UTC';
let tenantLocale = 'en-US';
let tenantCurrency = 'USD';
@@ -54,7 +67,14 @@ const sessionContextRoutes = createApiRouter()
if (tenantId) {
try {
const db = getDrizzle(c);
- const row = await db.select({ name: users.name, email: users.email, timezone: users.timezone, locale: users.locale })
+ const row = await db.select({
+ name: users.name,
+ email: users.email,
+ timezone: users.timezone,
+ locale: users.locale,
+ dateFormat: users.dateFormat,
+ timeFormat: users.timeFormat,
+ })
.from(users)
.where(and(eq(users.id, user.sub), eq(users.tenantId, tenantId)))
.get();
@@ -63,11 +83,15 @@ const sessionContextRoutes = createApiRouter()
userEmail = row.email;
userTimezone = row.timezone;
userLocale = row.locale;
+ userDateFormat = isDateFormat(row.dateFormat) ? row.dateFormat : null;
+ userTimeFormat = isTimeFormat(row.timeFormat) ? row.timeFormat : null;
}
const cfg = await db.select({
defaultTimezone: tenantConfigs.defaultTimezone,
defaultLocale: tenantConfigs.defaultLocale,
currency: tenantConfigs.currency,
+ dateFormat: tenantConfigs.dateFormat,
+ timeFormat: tenantConfigs.timeFormat,
// IA-100 — the contacts archive dialog states whether
// archiving also revokes report links, so it needs the
// policy, not just the link count.
@@ -82,6 +106,8 @@ const sessionContextRoutes = createApiRouter()
if (cfg?.defaultTimezone) tenantTimezone = cfg.defaultTimezone;
tenantLocale = resolveLocale(cfg?.defaultLocale);
if (cfg?.currency) tenantCurrency = cfg.currency;
+ if (isDateFormat(cfg?.dateFormat)) tenantDateFormat = cfg.dateFormat;
+ if (isTimeFormat(cfg?.timeFormat)) tenantTimeFormat = cfg.timeFormat;
archiveRevokesAccess = cfg?.archiveRevokesAccess ?? false;
legalCfg = cfg
? {
@@ -221,6 +247,8 @@ const sessionContextRoutes = createApiRouter()
defaultLocale: tenantLocale,
currency: tenantCurrency,
archiveRevokesAccess,
+ dateFormat: tenantDateFormat,
+ timeFormat: tenantTimeFormat,
},
user: {
name: userName,
@@ -229,6 +257,8 @@ const sessionContextRoutes = createApiRouter()
initials,
timezone: userTimezone,
locale: userLocale,
+ dateFormat: userDateFormat,
+ timeFormat: userTimeFormat,
},
deployment: {
mode: profile.mode || 'standalone',
diff --git a/server/lib/session/display-prefs.ts b/server/lib/session/display-prefs.ts
new file mode 100644
index 000000000..dbc28f5a3
--- /dev/null
+++ b/server/lib/session/display-prefs.ts
@@ -0,0 +1,75 @@
+/**
+ * Date/time SHAPE resolution — see #270. Sibling to server/lib/locale.ts and
+ * server/lib/tz.ts, and the third of the four independent display preferences
+ * (language, timezone, currency, shape).
+ *
+ * Shape is its own axis because no locale expresses "English words, American
+ * order, 24-hour clock", and that is a normal field preference: 14:30 is
+ * unambiguous over a radio where "2:30" is not.
+ *
+ * Resolution is per FIELD, not per object. A user who set only the clock keeps
+ * the tenant's date order; collapsing to a per-object choice silently reverts a
+ * preference the user did set.
+ *
+ * The drizzle `{ enum: [...] }` is type-layer only — D1 stores plain TEXT with
+ * no CHECK constraint — so an unrecognized stored value falls back rather than
+ * reaching `Intl` as an unknown key.
+ */
+
+export const DATE_FORMATS = ['us', 'iso', 'eu'] as const;
+export const TIME_FORMATS = ['12h', '24h'] as const;
+
+export type DateFormat = (typeof DATE_FORMATS)[number];
+export type TimeFormat = (typeof TIME_FORMATS)[number];
+
+export interface DisplayFormatPrefs {
+ dateFormat: DateFormat;
+ timeFormat: TimeFormat;
+}
+
+/**
+ * The bottom of the resolution chain. These reproduce today's rendering
+ * exactly, so a tenant that never touches the setting sees no change.
+ */
+export const DEFAULT_DISPLAY_PREFS: DisplayFormatPrefs = { dateFormat: 'us', timeFormat: '12h' };
+
+/** A row (user or tenant_configs) contributing either preference. */
+export interface DisplayFormatSource {
+ dateFormat?: string | null;
+ timeFormat?: string | null;
+}
+
+export function isDateFormat(raw: unknown): raw is DateFormat {
+ return typeof raw === 'string' && (DATE_FORMATS as readonly string[]).includes(raw);
+}
+
+export function isTimeFormat(raw: unknown): raw is TimeFormat {
+ return typeof raw === 'string' && (TIME_FORMATS as readonly string[]).includes(raw);
+}
+
+/**
+ * Resolve the viewer's effective date/time shape: user override, else tenant
+ * default, else the built-in default — decided independently for each field.
+ *
+ * Callers rendering anything a SECOND PARTY also sees (inspection dates, report
+ * dates, appointment times) must pass `null` for `user` and resolve from the
+ * tenant alone: the inspector, the client and the agent discuss one inspection
+ * by phone, and a per-viewer shape turns that into a support call.
+ */
+export function resolveDisplayPrefs(
+ user: DisplayFormatSource | null | undefined,
+ tenant: DisplayFormatSource | null | undefined,
+): DisplayFormatPrefs {
+ return {
+ dateFormat: isDateFormat(user?.dateFormat)
+ ? user.dateFormat
+ : isDateFormat(tenant?.dateFormat)
+ ? tenant.dateFormat
+ : DEFAULT_DISPLAY_PREFS.dateFormat,
+ timeFormat: isTimeFormat(user?.timeFormat)
+ ? user.timeFormat
+ : isTimeFormat(tenant?.timeFormat)
+ ? tenant.timeFormat
+ : DEFAULT_DISPLAY_PREFS.timeFormat,
+ };
+}
diff --git a/tests/unit/session/display-prefs.spec.ts b/tests/unit/session/display-prefs.spec.ts
new file mode 100644
index 000000000..7c61d9b50
--- /dev/null
+++ b/tests/unit/session/display-prefs.spec.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect } from 'vitest';
+import { resolveDisplayPrefs } from '../../../server/lib/session/display-prefs';
+
+describe('display preference resolution', () => {
+ it('prefers the user override', () => {
+ expect(resolveDisplayPrefs(
+ { dateFormat: 'iso', timeFormat: '24h' },
+ { dateFormat: 'us', timeFormat: '12h' },
+ )).toEqual({ dateFormat: 'iso', timeFormat: '24h' });
+ });
+
+ it('falls back per FIELD, not per object', () => {
+ // A user who set only the clock must keep the tenant's date order.
+ expect(resolveDisplayPrefs(
+ { dateFormat: null, timeFormat: '24h' },
+ { dateFormat: 'eu', timeFormat: '12h' },
+ )).toEqual({ dateFormat: 'eu', timeFormat: '24h' });
+ });
+
+ it('defaults to today\'s rendering when the tenant row is missing', () => {
+ expect(resolveDisplayPrefs(null, null)).toEqual({ dateFormat: 'us', timeFormat: '12h' });
+ });
+
+ it('ignores a stored value outside the enum', () => {
+ // D1 stores plain TEXT — the drizzle enum is type-layer only, so a bad
+ // row must not reach Intl as an unknown option key.
+ expect(resolveDisplayPrefs(
+ { dateFormat: 'dd.mm.yyyy', timeFormat: '36h' },
+ { dateFormat: 'eu', timeFormat: '24h' },
+ )).toEqual({ dateFormat: 'eu', timeFormat: '24h' });
+ });
+});
From af73b29e9fd12151c17805a22af5eb10a50f4677 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 09:55:31 +0800
Subject: [PATCH 022/111] feat(#270): thread the format preference through the
shared formatter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
format-date.ts pinned locale:'en-US' at two call sites with a comment saying
Phase A would thread the viewer's locale through; it never did, so a tenant on
es-419 read `Aug 3 · 7:58 AM EDT` -- English month, English meridiem, on a
Spanish page. The pin was three files from anything a reviewer was looking at.
`locale` is now a REQUIRED fourth argument, the same remedy already applied to
`timeZone` in this file for the same class of defect: an optional locale with an
'en-US' default would have left all fourteen call sites rendering the bug. All
fourteen now name a locale.
The date is assembled part-by-part rather than handed to Intl as an option bag,
because an option bag cannot express the requirement: Intl derives the ORDER
from the locale, so es-419 with {month:'short',day:'numeric'} gives `11 sept`,
not the American order a US company asked for. The month WORD comes from the
locale, the ORDER from the enum. Only the clock goes to Intl whole, via
hourCycle.
Language follows the viewer, shape follows the tenant. Translating a month name
cannot be misread; reordering one can, and the inspector, the client and the
agent discuss one inspection out loud.
Public surfaces have no session, so the tenant brand grows defaultLocale +
dateFormat + timeFormat. The report payload already carried the brand.
Defaults render byte-identically to before.
---
app/components/audit/EntityAuditTrail.tsx | 11 +-
.../dashboard/DashboardInspectionRow.test.tsx | 2 +
.../dashboard/DashboardInspectionRow.tsx | 5 +-
.../inspector-portal/ScheduleCard.tsx | 4 +-
.../inspector-portal/SigningRequests.tsx | 4 +-
.../new-inspection/ReviewPanel.test.tsx | 7 ++
app/components/new-inspection/ReviewPanel.tsx | 4 +-
app/components/portal/sections/ReportView.tsx | 4 +-
app/hooks/useSessionContext.ts | 20 ++++
app/lib/brand.ts | 31 ++++++
app/lib/format-date.test.ts | 98 +++++++++++++++--
app/lib/format-date.ts | 100 +++++++++++++++---
app/lib/format.ts | 16 ++-
app/lib/tenant-brand.server.ts | 3 +
app/routes/agent/dashboard.tsx | 7 +-
app/routes/contact-detail.tsx | 4 +-
app/routes/inspector-portal.tsx | 11 +-
app/routes/public/concierge-confirm-token.tsx | 8 +-
app/routes/public/portal-inspection.tsx | 6 +-
app/routes/public/portal.tsx | 4 +-
scripts/file-size-baseline.json | 2 +-
server/lib/validations/public-brand.schema.ts | 7 ++
server/services/branding.service.ts | 12 +++
23 files changed, 320 insertions(+), 50 deletions(-)
diff --git a/app/components/audit/EntityAuditTrail.tsx b/app/components/audit/EntityAuditTrail.tsx
index b9b6ffabb..05a6c90b9 100644
--- a/app/components/audit/EntityAuditTrail.tsx
+++ b/app/components/audit/EntityAuditTrail.tsx
@@ -1,6 +1,8 @@
import { useState } from "react";
import { useFetcher } from "react-router";
import { formatInspectionDateTime } from "~/lib/format-date";
+import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
+import type { InspectionDateTimeFormat } from "~/lib/format-date";
import { m } from "~/paraglide/messages";
// IA-64 — the read side of change traceability. Templates and comments are
@@ -22,11 +24,12 @@ function actionLabel(action: string): string {
return m.audit_action_other();
}
-function when(createdAt: number, timeZone: string): string {
- return formatInspectionDateTime(new Date(createdAt).toISOString(), undefined, timeZone);
+function when(createdAt: number, timeZone: string, fmt: InspectionDateTimeFormat): string {
+ return formatInspectionDateTime(new Date(createdAt).toISOString(), undefined, timeZone, fmt);
}
export function EntityAuditTrail({ entityId, timeZone }: { entityId: string; timeZone: string }) {
+ const fmt = useInspectionDateTimeFormat();
const [open, setOpen] = useState(false);
const fetcher = useFetcher<{ entries: AuditEntry[] }>();
@@ -64,7 +67,7 @@ export function EntityAuditTrail({ entityId, timeZone }: { entityId: string; tim
<>
{m.audit_trail_last_edited({ name: latest.actorName || m.audit_trail_unknown_actor() })}
- · {when(latest.createdAt, timeZone)}
+ · {when(latest.createdAt, timeZone, fmt)}
{entries.map((e) => (
@@ -72,7 +75,7 @@ export function EntityAuditTrail({ entityId, timeZone }: { entityId: string; tim
{actionLabel(e.action)} · {e.actorName || m.audit_trail_unknown_actor()}
- {when(e.createdAt, timeZone)}
+ {when(e.createdAt, timeZone, fmt)}
))}
diff --git a/app/components/dashboard/DashboardInspectionRow.test.tsx b/app/components/dashboard/DashboardInspectionRow.test.tsx
index 00fd34b50..a7f276fd8 100644
--- a/app/components/dashboard/DashboardInspectionRow.test.tsx
+++ b/app/components/dashboard/DashboardInspectionRow.test.tsx
@@ -22,6 +22,8 @@ vi.mock("~/hooks/useSessionContext", () => ({
useDisplayLocale: () => "en-US",
useDisplayCurrency: () => "USD",
useDisplayTimeZone: () => "UTC",
+ // #270 — the row renders an inspection date, whose SHAPE is the tenant's.
+ useTenantFormatPrefs: () => ({ dateFormat: "us", timeFormat: "12h" }),
}));
const INSPECTION = {
diff --git a/app/components/dashboard/DashboardInspectionRow.tsx b/app/components/dashboard/DashboardInspectionRow.tsx
index a072fa296..7c5cc948f 100644
--- a/app/components/dashboard/DashboardInspectionRow.tsx
+++ b/app/components/dashboard/DashboardInspectionRow.tsx
@@ -6,7 +6,7 @@ import { REPORT_STATE_TONE, type Inspection } from "~/lib/dashboard-schema";
import { Pill, Icon } from "@core/shared-ui";
import { m } from "~/paraglide/messages";
import { formatDollars } from "~/lib/money";
-import { useDisplayLocale, useDisplayCurrency } from "~/hooks/useSessionContext";
+import { useDisplayLocale, useDisplayCurrency, useTenantFormatPrefs } from "~/hooks/useSessionContext";
interface DashboardInspectionRowProps {
insp: Inspection;
@@ -36,6 +36,7 @@ export function DashboardInspectionRow({
}: DashboardInspectionRowProps) {
const locale = useDisplayLocale();
const currency = useDisplayCurrency();
+ const shape = useTenantFormatPrefs();
const isSelected = selectedIds.has(insp.id);
const showReportLink =
reportView && tenantSlug && isReportPublished(insp.reportStatus);
@@ -65,7 +66,7 @@ export function DashboardInspectionRow({
)}
{isColumnVisible("date") && insp.date && (
- · {formatInspectionDateTime(insp.date, undefined, timeZone)}
+ · {formatInspectionDateTime(insp.date, undefined, timeZone, { locale, ...shape })}
)}
{isColumnVisible("agent") && insp.agentName && (
diff --git a/app/components/inspector-portal/ScheduleCard.tsx b/app/components/inspector-portal/ScheduleCard.tsx
index 5cf206e90..115e8a9cf 100644
--- a/app/components/inspector-portal/ScheduleCard.tsx
+++ b/app/components/inspector-portal/ScheduleCard.tsx
@@ -3,6 +3,7 @@ import { useFetcher } from "react-router";
import { Card, Button, Modal } from "@core/shared-ui";
import { BlockHeading } from "./BlockHeading";
import { formatInspectionDateTime } from "~/lib/format-date";
+import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
import { toLocalInputValue, fromLocalInputValue } from "~/lib/datetime-local";
import { m } from "~/paraglide/messages";
import type { action } from "~/routes/inspector-portal";
@@ -41,6 +42,7 @@ export function ScheduleCard({
members: TeamMember[];
displayTz: string;
}) {
+ const fmt = useInspectionDateTimeFormat();
const [open, setOpen] = useState(false);
const fetcher = useFetcher();
const saving = fetcher.state !== "idle";
@@ -75,7 +77,7 @@ export function ScheduleCard({
{date
- ? formatInspectionDateTime(date, undefined, displayTz)
+ ? formatInspectionDateTime(date, undefined, displayTz, fmt)
: m.inspections_hub_schedule_unscheduled()}
{/* Labelled. An inspector's name is often just their email, and a
diff --git a/app/components/inspector-portal/SigningRequests.tsx b/app/components/inspector-portal/SigningRequests.tsx
index b24884440..a963af60b 100644
--- a/app/components/inspector-portal/SigningRequests.tsx
+++ b/app/components/inspector-portal/SigningRequests.tsx
@@ -3,6 +3,7 @@ import { Pill, Button } from "@core/shared-ui";
import { RequestDetail } from "~/components/agreements/RequestDetail";
import { pillToneFor, pillLabelFor } from "~/components/agreements/agreements-helpers";
import { formatInspectionDateTime } from "~/lib/format-date";
+import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
import { m } from "~/paraglide/messages";
/**
@@ -76,6 +77,7 @@ export function SigningRequests({
/** Inspector pre-sign, offered only while the envelope is still pending. */
onPreSign: (requestId: string) => void;
}) {
+ const fmt = useInspectionDateTimeFormat();
const [expandedId, setExpandedId] = useState(null);
return (
@@ -98,7 +100,7 @@ export function SigningRequests({
{req.clientEmail}
- {when && <> · {formatInspectionDateTime(when, undefined, displayTz)}>}
+ {when && <> · {formatInspectionDateTime(when, undefined, displayTz, fmt)}>}
diff --git a/app/components/new-inspection/ReviewPanel.test.tsx b/app/components/new-inspection/ReviewPanel.test.tsx
index 9fde601d7..cc1569438 100644
--- a/app/components/new-inspection/ReviewPanel.test.tsx
+++ b/app/components/new-inspection/ReviewPanel.test.tsx
@@ -4,6 +4,13 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { ReviewPanel } from "./ReviewPanel";
import type { NewInspectionSummary } from "~/lib/wizard-review";
+// The panel renders a scheduled datetime, so it reads the session display
+// preferences (#270). These hooks go through useRouteLoaderData, which throws
+// outside a data router — this suite renders bare, so it stubs them.
+vi.mock("~/hooks/useSessionContext", () => ({
+ useInspectionDateTimeFormat: () => ({ locale: "en-US", dateFormat: "us", timeFormat: "12h" }),
+}));
+
afterEach(cleanup);
const FULL: NewInspectionSummary = {
diff --git a/app/components/new-inspection/ReviewPanel.tsx b/app/components/new-inspection/ReviewPanel.tsx
index aade794af..dcc8d42a2 100644
--- a/app/components/new-inspection/ReviewPanel.tsx
+++ b/app/components/new-inspection/ReviewPanel.tsx
@@ -1,6 +1,7 @@
import { formatPriceCents, type WizardStepId } from "~/lib/wizard-steps";
import type { NewInspectionSummary } from "~/lib/wizard-review";
import { formatInspectionDateTime } from "~/lib/format-date";
+import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
import { m } from "~/paraglide/messages";
/**
@@ -56,6 +57,7 @@ export function ReviewPanel({
currentStep: WizardStepId;
onJump: (step: WizardStepId) => void;
}) {
+ const fmt = useInspectionDateTimeFormat();
const row = (step: WizardStepId) => ({ step, onJump, isCurrent: currentStep === step });
return (
@@ -73,7 +75,7 @@ export function ReviewPanel({
{scheduledIso && (
)}
diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx
index dbcc12856..a1737a28b 100644
--- a/app/components/portal/sections/ReportView.tsx
+++ b/app/components/portal/sections/ReportView.tsx
@@ -20,7 +20,7 @@
import { useState } from "react";
import { m } from "~/paraglide/messages";
import { usePdfExport, pdfActionLabel, pdfBusyHint } from "~/hooks/usePdfExport";
-import { brandTokens } from "~/lib/brand";
+import { brandFormat, brandTokens } from "~/lib/brand";
import { presetTokens } from "~/lib/report-style/preset-tokens";
import { formatInspectionDateTime } from "~/lib/format-date";
import { ErrorState } from "~/components/ErrorState";
@@ -470,7 +470,7 @@ export function ReportView(props: ReportViewProps) {
)}
- {data.date ? `${formatInspectionDateTime(data.date, undefined, data.reportTimeZone)} · ` : ""}
+ {data.date ? `${formatInspectionDateTime(data.date, undefined, data.reportTimeZone, brandFormat(data.brand))} · ` : ""}
{m.report_view_inspector({ name: data.inspectorName || m.report_view_na() })}
{data.inspectorCredentials && data.inspectorCredentials.length > 0 && (
diff --git a/app/hooks/useSessionContext.ts b/app/hooks/useSessionContext.ts
index 981338d98..db957eb1a 100644
--- a/app/hooks/useSessionContext.ts
+++ b/app/hooks/useSessionContext.ts
@@ -137,3 +137,23 @@ export function useTenantFormatPrefs(): { dateFormat: DateFormat; timeFormat: Ti
const ctx = useSessionContext();
return resolveDisplayPrefs(null, ctx?.branding);
}
+
+/**
+ * The format bundle for anything a SECOND PARTY also reads — inspection dates,
+ * report dates, appointment times (#270).
+ *
+ * The two axes resolve from different places on purpose. **Language** follows
+ * the viewer, because a Spanish-speaking agent should read Spanish. **Shape**
+ * follows the tenant, because `Sep 11` on one screen and `11/09` on another is
+ * a support call and, on a date-sensitive transaction, a missed appointment.
+ * Translating a month name cannot be misread; reordering one can.
+ */
+export function useInspectionDateTimeFormat(): {
+ locale: string;
+ dateFormat: DateFormat;
+ timeFormat: TimeFormat;
+} {
+ const locale = useDisplayLocale();
+ const prefs = useTenantFormatPrefs();
+ return { locale, ...prefs };
+}
diff --git a/app/lib/brand.ts b/app/lib/brand.ts
index d102dabe9..d8d16f632 100644
--- a/app/lib/brand.ts
+++ b/app/lib/brand.ts
@@ -1,4 +1,5 @@
import type { CSSProperties } from "react";
+import type { DateFormat, TimeFormat } from "../../server/lib/session/display-prefs";
/**
* A-10 — tenant brand shared by every public surface (profile / booking /
@@ -12,6 +13,12 @@ export interface TenantBrand {
/** Tenant display timezone (IANA; 'UTC' when unset). Public/report surfaces
* anchor displayed inspection dates to this zone. */
defaultTimezone: string;
+ /** #270 — the tenant's display language and date/time shape. A public page
+ * has no viewer to override them, and an inspection date must read the same
+ * to the inspector, the client and the agent, so these are tenant values. */
+ defaultLocale: string;
+ dateFormat: DateFormat;
+ timeFormat: TimeFormat;
/** IA-36 ⑨ — client-facing recovery channels, null until the tenant sets
* them. A dead-link page needs somewhere to send the reader; naming the
* company without a way to reach it only says who to blame. */
@@ -27,12 +34,36 @@ export const EMPTY_BRAND: TenantBrand = {
primaryColor: null,
logoUrl: null,
defaultTimezone: "UTC",
+ defaultLocale: "en-US",
+ dateFormat: "us",
+ timeFormat: "12h",
supportEmail: null,
companyPhone: null,
privacyUrl: null,
termsUrl: null,
};
+/**
+ * The date/time format bundle a PUBLIC surface renders with (#270).
+ *
+ * Public pages run in loaders, where the session hooks do not exist, and they
+ * have no authenticated user to hold a personal override anyway. Everything
+ * here is the tenant's — which is also what the design requires of an
+ * inspection date: the client, the agent and the inspector must read the same
+ * one out loud.
+ */
+export function brandFormat(brand: TenantBrand): {
+ locale: string;
+ dateFormat: DateFormat;
+ timeFormat: TimeFormat;
+} {
+ return {
+ locale: brand.defaultLocale,
+ dateFormat: brand.dateFormat,
+ timeFormat: brand.timeFormat,
+ };
+}
+
/**
* Pick a readable text color for content sitting ON the brand primary color.
* Uses the YIQ perceived-brightness formula: bright backgrounds (≥150) get the
diff --git a/app/lib/format-date.test.ts b/app/lib/format-date.test.ts
index 6ae932e6d..ab6907753 100644
--- a/app/lib/format-date.test.ts
+++ b/app/lib/format-date.test.ts
@@ -1,32 +1,35 @@
import { describe, it, expect } from 'vitest';
import { formatInspectionDateTime } from '~/lib/format-date';
+/** Today's rendering: US order, 12-hour clock, English. */
+const EN_US = { locale: 'en-US' as const };
+
describe('formatInspectionDateTime (en-US, C-14 part 1)', () => {
it('renders month day · time with a timezone label for a datetime ISO', () => {
- expect(formatInspectionDateTime('2026-06-04T09:00:00Z', new Date('2026-06-10T00:00:00Z'), 'UTC'))
+ expect(formatInspectionDateTime('2026-06-04T09:00:00Z', new Date('2026-06-10T00:00:00Z'), 'UTC', EN_US))
.toBe('Jun 4 · 9:00 AM UTC');
});
it('appends the year when it differs from now', () => {
- expect(formatInspectionDateTime('2025-12-31T15:30:00Z', new Date('2026-06-10T00:00:00Z'), 'UTC'))
+ expect(formatInspectionDateTime('2025-12-31T15:30:00Z', new Date('2026-06-10T00:00:00Z'), 'UTC', EN_US))
.toBe('Dec 31, 2025 · 3:30 PM UTC');
});
it('omits the time block for date-only values (stays UTC, no label)', () => {
- expect(formatInspectionDateTime('2026-06-04', new Date('2026-06-10T00:00:00Z'), 'UTC')).toBe('Jun 4');
+ expect(formatInspectionDateTime('2026-06-04', new Date('2026-06-10T00:00:00Z'), 'UTC', EN_US)).toBe('Jun 4');
});
it('degrades to "no date" on null/garbage', () => {
- expect(formatInspectionDateTime(null, new Date(), 'UTC')).toBe('no date');
- expect(formatInspectionDateTime('not-a-date', new Date(), 'UTC')).toBe('no date');
+ expect(formatInspectionDateTime(null, new Date(), 'UTC', EN_US)).toBe('no date');
+ expect(formatInspectionDateTime('not-a-date', new Date(), 'UTC', EN_US)).toBe('no date');
});
it('renders an instant in the supplied timezone', () => {
const now = new Date('2026-07-15T00:00:00Z');
// 2026-07-15T13:00:00Z is 09:00 EDT in New York
- expect(formatInspectionDateTime('2026-07-15T13:00:00Z', now, 'America/New_York')).toContain('9:00');
+ expect(formatInspectionDateTime('2026-07-15T13:00:00Z', now, 'America/New_York', EN_US)).toContain('9:00');
// ...and 1:00 PM in UTC
- expect(formatInspectionDateTime('2026-07-15T13:00:00Z', now, 'UTC')).toContain('1:00');
+ expect(formatInspectionDateTime('2026-07-15T13:00:00Z', now, 'UTC', EN_US)).toContain('1:00');
});
it('date-only stays UTC regardless of the timezone arg', () => {
const now = new Date('2026-07-15T00:00:00Z');
- expect(formatInspectionDateTime('2026-07-15', now, 'America/New_York')).toBe('Jul 15');
+ expect(formatInspectionDateTime('2026-07-15', now, 'America/New_York', EN_US)).toBe('Jul 15');
});
});
@@ -50,14 +53,87 @@ describe('formatInspectionDateTime — the zone must be named', () => {
it('renders the same instant differently in two zones, which is the whole point', () => {
const instant = '2026-07-15T09:00:00Z';
- expect(formatInspectionDateTime(instant, now, 'UTC')).toBe('Jul 15 · 9:00 AM UTC');
- expect(formatInspectionDateTime(instant, now, 'Asia/Shanghai')).toBe('Jul 15 · 5:00 PM GMT+8');
+ expect(formatInspectionDateTime(instant, now, 'UTC', EN_US)).toBe('Jul 15 · 9:00 AM UTC');
+ expect(formatInspectionDateTime(instant, now, 'Asia/Shanghai', EN_US)).toBe('Jul 15 · 5:00 PM GMT+8');
});
it('treats a blank zone as UTC rather than silently using the viewer\'s', () => {
// Defence in depth behind the type: a value threaded from config can still
// arrive empty at runtime, and the browser's zone is never the right guess
// for a tenant's scheduled time.
- expect(formatInspectionDateTime('2026-07-15T09:00:00Z', now, '')).toBe('Jul 15 · 9:00 AM UTC');
+ expect(formatInspectionDateTime('2026-07-15T09:00:00Z', now, '', EN_US)).toBe('Jul 15 · 9:00 AM UTC');
+ });
+});
+
+/**
+ * #270 — the locale must reach Intl, and the shape must NOT come from it.
+ *
+ * The bug this closes: on a tenant set to `es-419`, a datetime rendered
+ * `Aug 3 · 7:58 AM EDT` — English month abbreviation and English meridiem on a
+ * Spanish page — because this file pinned `locale: 'en-US'` internally. The pin
+ * was invisible from every call site.
+ */
+describe('formatInspectionDateTime — locale and shape are separate axes', () => {
+ const now = new Date('2026-09-01T00:00:00Z');
+ const instant = '2026-09-11T14:30:00Z';
+
+ it('renders a non-English locale in that language', () => {
+ const out = formatInspectionDateTime(instant, now, 'UTC', { locale: 'es-419' });
+ // The regression itself: the English abbreviation must be gone.
+ expect(out).not.toContain('Sep 11');
+ expect(out).not.toMatch(/\bPM\b/);
+ // ...and the Spanish month must be there (es-419 abbreviates as "sept").
+ expect(out.toLowerCase()).toContain('sept');
+ });
+
+ it('keeps the American order under a non-English locale', () => {
+ // Intl alone would give `11 sept` for es-419 — order comes from the enum,
+ // words come from the locale. That combination is the whole point of a
+ // format preference that is not just a locale: there is no locale meaning
+ // "Spanish words, American order".
+ const us = formatInspectionDateTime(instant, now, 'UTC', { locale: 'es-419', dateFormat: 'us' });
+ const eu = formatInspectionDateTime(instant, now, 'UTC', { locale: 'es-419', dateFormat: 'eu' });
+ expect(us.toLowerCase()).toMatch(/^sept\.?\s+11\b/);
+ expect(eu.toLowerCase()).toMatch(/^11\s+sept\.?\b/);
+ });
+
+ it('honours a 24-hour preference', () => {
+ const out = formatInspectionDateTime(instant, now, 'UTC', {
+ locale: 'en-US', dateFormat: 'us', timeFormat: '24h',
+ });
+ expect(out).toContain('14:30');
+ expect(out).not.toMatch(/PM/i);
+ });
+
+ it('honours ISO date order', () => {
+ const out = formatInspectionDateTime(instant, now, 'UTC', {
+ locale: 'en-US', dateFormat: 'iso', timeFormat: '24h',
+ });
+ expect(out).toContain('2026-09-11');
+ });
+
+ it('honours EU date order', () => {
+ const out = formatInspectionDateTime(instant, now, 'UTC', {
+ locale: 'en-US', dateFormat: 'eu', timeFormat: '12h',
+ });
+ expect(out).toBe('11 Sep · 2:30 PM UTC');
+ });
+
+ it('renders byte-identically to today under the defaults', () => {
+ // The regression guard: every existing caller passes nothing new, and the
+ // output must not move. This is what makes the change safe to ship broadly.
+ const out = formatInspectionDateTime(instant, now, 'UTC', {
+ locale: 'en-US', dateFormat: 'us', timeFormat: '12h',
+ });
+ expect(out).toBe('Sep 11 · 2:30 PM UTC');
+ });
+
+ it('keeps ISO whole in the current year, where the other shapes drop the year', () => {
+ // `09-11` is neither ISO nor unambiguous, so the year-eliding rule that
+ // makes dashboard rows compact does not apply to this one shape.
+ const iso = formatInspectionDateTime(instant, now, 'UTC', { locale: 'en-US', dateFormat: 'iso' });
+ expect(iso).toContain('2026-09-11');
+ const us = formatInspectionDateTime(instant, now, 'UTC', { locale: 'en-US', dateFormat: 'us' });
+ expect(us).not.toContain('2026');
});
});
diff --git a/app/lib/format-date.ts b/app/lib/format-date.ts
index 922ff6cca..360687400 100644
--- a/app/lib/format-date.ts
+++ b/app/lib/format-date.ts
@@ -1,12 +1,80 @@
-import { formatDate, formatTime } from './format';
+import { formatTime } from './format';
+import {
+ DEFAULT_DISPLAY_PREFS,
+ type DateFormat,
+ type TimeFormat,
+} from '../../server/lib/session/display-prefs';
-/** C-14 part 1: humanize raw ISO timestamps on dashboard rows. en-US (US-market product).
+/**
+ * The three axes this formatter needs, all supplied by the caller.
+ *
+ * `locale` is REQUIRED, for exactly the reason `timeZone` is (see below). It
+ * used to be pinned to 'en-US' inside this file, with a comment promising that
+ * "Phase A threads the viewer's effective locale through" — which never
+ * happened, so a tenant on `es-419` read `Aug 3 · 7:58 AM EDT`: English month,
+ * English meridiem, on a Spanish page. Nothing at the call sites showed it,
+ * because the pin was three files away from anything a reviewer was looking at.
+ * Naming the locale is now a compile-time obligation; get one from
+ * `useDisplayLocale()` or, on a public surface, the tenant brand.
+ *
+ * `dateFormat` / `timeFormat` are the SHAPE (#270), deliberately independent of
+ * the locale: the locale decides what language "September" is written in, the
+ * enum decides whether the day comes before it and whether 14:30 is spelled
+ * 2:30 PM. Both default to today's rendering, so an un-migrated caller is
+ * byte-identical to before.
+ */
+export interface InspectionDateTimeFormat {
+ /** BCP-47 tag. Blank falls back to 'en-US' rather than the browser's. */
+ locale: string;
+ dateFormat?: DateFormat;
+ timeFormat?: TimeFormat;
+}
+
+/** Numeric date parts read in the target timezone, as strings. */
+function numericParts(d: Date, timeZone: string): { year: string; month: string; day: string } {
+ // 'en-CA' is used only as a stable NUMERIC source here — never for wording.
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ year: 'numeric', month: '2-digit', day: '2-digit', timeZone,
+ }).formatToParts(d);
+ const pick = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? '';
+ return { year: pick('year'), month: pick('month'), day: pick('day') };
+}
+
+/**
+ * Assemble the date in the ORDER the enum names, using the LOCALE's words.
+ *
+ * This is assembled part-by-part rather than handed to `Intl` as an option bag
+ * because an option bag cannot express the requirement: Intl derives the order
+ * from the locale, so `es-419` with `{month:'short',day:'numeric'}` yields
+ * `11 sept`, not the American order the tenant asked for. Order comes from the
+ * enum; only the month WORD is localized.
+ */
+function formatDatePart(
+ d: Date,
+ timeZone: string,
+ locale: string,
+ dateFormat: DateFormat,
+ showYear: boolean,
+): string {
+ const { year, month, day } = numericParts(d, timeZone);
+ if (dateFormat === 'iso') {
+ // ISO 8601 is not localized, and it always carries the year: `09-11` is
+ // neither ISO nor unambiguous, so `showYear` does not apply here.
+ return `${year}-${month}-${day}`;
+ }
+ const monthWord = new Intl.DateTimeFormat(locale, { month: 'short', timeZone }).format(d);
+ const dayNum = String(Number(day));
+ return dateFormat === 'eu'
+ ? `${dayNum} ${monthWord}${showYear ? ` ${year}` : ''}`
+ : `${monthWord} ${dayNum}${showYear ? `, ${year}` : ''}`;
+}
+
+/** C-14 part 1: humanize raw ISO timestamps on dashboard rows.
* `now` is injectable for deterministic tests; callers pass undefined.
*
- * Date/time rendering delegates to the shared formatter (app/lib/format); this
- * wrapper keeps the dashboard-specific composition — drop the year in the current
- * year, and join `date · time` with a short zone label. locale is pinned to
- * 'en-US'; Phase A threads the viewer's effective locale through.
+ * This wrapper owns the dashboard-specific composition — drop the year in the
+ * current year, and join `date · time` with a short zone label. Language,
+ * zone and shape all arrive from the caller (see InspectionDateTimeFormat).
*
* `timeZone` is REQUIRED, and that is the point. It used to be optional, and four
* of fourteen call sites left it off — two of them on the inspector portal, whose
@@ -22,6 +90,7 @@ export function formatInspectionDateTime(
iso: string | null | undefined,
now: Date | undefined,
timeZone: string,
+ fmt: InspectionDateTimeFormat,
): string {
if (!iso) return 'no date';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(iso);
@@ -29,14 +98,21 @@ export function formatInspectionDateTime(
if (isNaN(d.getTime())) return 'no date';
now = now ?? new Date();
const tz = dateOnly ? 'UTC' : timeZone || 'UTC';
- // en-US formatDate always ends in `, YYYY`; strip it when the year matches now.
- const full = formatDate(iso, { locale: 'en-US', timeZone: tz, month: 'short' });
- const yearMatch = full.match(/,\s*(\d{4})$/);
- const year = yearMatch ? Number(yearMatch[1]) : NaN;
- const datePart = year === now.getUTCFullYear() ? full.replace(/,\s*\d{4}$/, '') : full;
+ const locale = fmt.locale || 'en-US';
+ const dateFormat = fmt.dateFormat ?? DEFAULT_DISPLAY_PREFS.dateFormat;
+ const timeFormat: TimeFormat = fmt.timeFormat ?? DEFAULT_DISPLAY_PREFS.timeFormat;
+
+ const { year } = numericParts(d, tz);
+ const showYear = Number(year) !== now.getUTCFullYear();
+ const datePart = formatDatePart(d, tz, locale, dateFormat, showYear);
if (dateOnly) return datePart;
// Include the short zone name so a displayed time-of-day is unambiguous
// (e.g. "9:00 AM EDT") — matters once tenants/users configure a timezone.
- const time = formatTime(iso, { locale: 'en-US', timeZone: tz, timeZoneName: 'short' });
+ const time = formatTime(iso, {
+ locale,
+ timeZone: tz,
+ timeZoneName: 'short',
+ hourCycle: timeFormat === '24h' ? 'h23' : 'h12',
+ });
return `${datePart} · ${time}`;
}
diff --git a/app/lib/format.ts b/app/lib/format.ts
index 668435b58..979107377 100644
--- a/app/lib/format.ts
+++ b/app/lib/format.ts
@@ -62,15 +62,27 @@ export function formatRelativeTime(
return fmt.format(0, "minute");
}
+/**
+ * `hourCycle` is the CLOCK, which is a separate axis from the locale (#270):
+ * en-US implies h12 and en-GB implies h23, but "English words, 24-hour clock"
+ * is a normal field preference and no locale expresses it. Omit it to keep the
+ * locale's own convention.
+ */
export function formatTime(
value: DateInput,
- opts: { locale: string; timeZone?: string; timeZoneName?: "short" | "long" },
+ opts: {
+ locale: string;
+ timeZone?: string;
+ timeZoneName?: "short" | "long";
+ hourCycle?: "h12" | "h23";
+ },
): string {
const d = toDate(value);
if (!d) return "";
return new Intl.DateTimeFormat(opts.locale, {
- hour: "numeric",
+ hour: opts.hourCycle === "h23" ? "2-digit" : "numeric",
minute: "2-digit",
+ ...(opts.hourCycle ? { hourCycle: opts.hourCycle } : {}),
...(opts.timeZone
? { timeZone: opts.timeZone, ...(opts.timeZoneName ? { timeZoneName: opts.timeZoneName } : {}) }
: {}),
diff --git a/app/lib/tenant-brand.server.ts b/app/lib/tenant-brand.server.ts
index d70548dfe..f0dc597f0 100644
--- a/app/lib/tenant-brand.server.ts
+++ b/app/lib/tenant-brand.server.ts
@@ -37,6 +37,9 @@ export async function resolveTenantBrand(
primaryColor: d?.primaryColor ?? null,
logoUrl: d?.logoUrl ?? null,
defaultTimezone: d?.defaultTimezone ?? "UTC",
+ defaultLocale: d?.defaultLocale ?? "en-US",
+ dateFormat: d?.dateFormat ?? "us",
+ timeFormat: d?.timeFormat ?? "12h",
supportEmail: d?.supportEmail ?? null,
companyPhone: d?.companyPhone ?? null,
privacyUrl,
diff --git a/app/routes/agent/dashboard.tsx b/app/routes/agent/dashboard.tsx
index b7532a71e..5ac7cdb86 100644
--- a/app/routes/agent/dashboard.tsx
+++ b/app/routes/agent/dashboard.tsx
@@ -5,6 +5,7 @@ import { requireToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
import { PageHeader, Banner, Select } from "@core/shared-ui";
import { formatInspectionDateTime } from "~/lib/format-date";
+import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
import { propertyGroupKey, inspectionDateValue } from "~/lib/property-groups";
import { agentMayReadRepairList, type AgentRepairAccess } from "~/lib/agent-repair-access";
import { useAgentTimeZoneOverride } from "~/routes/agent-layout";
@@ -90,6 +91,10 @@ export default function AgentDashboardPage() {
// shown as a plain UTC-anchored date with no time/zone (so the resolved tz has
// no visible effect there, which is correct — it avoids a prior-day rollover).
const agentTz = useAgentTimeZoneOverride();
+ // #270 — an agent spans many tenants, so the SHAPE cannot come from one of
+ // them the way the inspector hub's does; the agent's own preference governs
+ // their list, and each row still names its zone.
+ const fmt = useInspectionDateTimeFormat();
// Task 4c: the referral matching a conversion-flow ?welcome=
, if it has
// shown up in this agent's referrals yet (server-side auto-link can lag a
@@ -221,7 +226,7 @@ export default function AgentDashboardPage() {
{r.tenantName}
- {r.clientName || m.agent_portal_dashboard_no_client()}{r.date ? ` · ${formatInspectionDateTime(r.date, undefined, agentTz || r.tenantTimezone)}` : ""}
+ {r.clientName || m.agent_portal_dashboard_no_client()}{r.date ? ` · ${formatInspectionDateTime(r.date, undefined, agentTz || r.tenantTimezone, fmt)}` : ""}
{r.inspectorName ? m.agent_portal_dashboard_with_inspector({ name: r.inspectorName }) : ""}
diff --git a/app/routes/contact-detail.tsx b/app/routes/contact-detail.tsx
index bbe9f9437..e9176cffb 100644
--- a/app/routes/contact-detail.tsx
+++ b/app/routes/contact-detail.tsx
@@ -4,6 +4,7 @@ import { useDisplayTimeZone } from "~/hooks/useSessionContext";
import { requireToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
import { formatInspectionDateTime } from "~/lib/format-date";
+import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
import { formatCents } from "~/lib/hub-blocks";
import { humanizeStatus, capitalize } from "~/lib/status";
import { Breadcrumb } from "~/components/Breadcrumb";
@@ -125,6 +126,7 @@ export default function ContactDetailPage() {
const { detail, access, accessFailed } = useLoaderData();
const { contact, inspections, stats } = detail;
const displayTz = useDisplayTimeZone();
+ const fmt = useInspectionDateTimeFormat();
const archived = !!contact.archivedAt;
return (
@@ -260,7 +262,7 @@ export default function ContactDetailPage() {
{insp.propertyAddress || m.contacts_detail_untitled_inspection()}
- {formatInspectionDateTime(insp.date, undefined, displayTz)} · {humanizeStatus(insp.status)}
+ {formatInspectionDateTime(insp.date, undefined, displayTz, fmt)} · {humanizeStatus(insp.status)}
diff --git a/app/routes/inspector-portal.tsx b/app/routes/inspector-portal.tsx
index 1b3c24d77..2985517f0 100644
--- a/app/routes/inspector-portal.tsx
+++ b/app/routes/inspector-portal.tsx
@@ -4,7 +4,7 @@ import type { Route } from "./+types/inspector-portal";
import { requireToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
import { formatInspectionDateTime } from "~/lib/format-date";
-import { useDisplayTimeZone } from "~/hooks/useSessionContext";
+import { useDisplayTimeZone, useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
import {
deriveBlockStates,
formatCents,
@@ -510,6 +510,7 @@ export default function InspectionHubPage() {
// list PeopleEditor renders. Two different shapes, hence the rename here.
const { inspection, people: peopleCard, services, tenantSlug } = hub;
const displayTz = useDisplayTimeZone();
+ const fmt = useInspectionDateTimeFormat();
const blocks = deriveBlockStates(hub);
const navigate = useNavigate();
const revalidator = useRevalidator();
@@ -720,7 +721,7 @@ export default function InspectionHubPage() {
{humanizeStatus(inspection.status)}
- {formatInspectionDateTime(inspection.date, undefined, displayTz)}
+ {formatInspectionDateTime(inspection.date, undefined, displayTz, fmt)}
{peopleCard.inspector?.name && (
· {peopleCard.inspector.name}
@@ -862,7 +863,7 @@ export default function InspectionHubPage() {
{publishedAt
? m.inspections_hub_report_published_on({
- date: formatInspectionDateTime(new Date(publishedAt * 1000).toISOString(), undefined, displayTz),
+ date: formatInspectionDateTime(new Date(publishedAt * 1000).toISOString(), undefined, displayTz, fmt),
})
: m.inspections_hub_report_published()}
@@ -996,7 +997,7 @@ export default function InspectionHubPage() {
)}
{v.publishedAt && (
- {formatInspectionDateTime(new Date(v.publishedAt * 1000).toISOString(), undefined, displayTz)}
+ {formatInspectionDateTime(new Date(v.publishedAt * 1000).toISOString(), undefined, displayTz, fmt)}
)}
@@ -1028,7 +1029,7 @@ export default function InspectionHubPage() {
unlockedAt={inspection.unlockedAt ?? null}
unlockedByName={inspection.unlockedByName ?? null}
unlockReason={inspection.unlockReason ?? null}
- formatDate={(iso) => formatInspectionDateTime(iso, undefined, displayTz)}
+ formatDate={(iso) => formatInspectionDateTime(iso, undefined, displayTz, fmt)}
/>
)}
diff --git a/app/routes/public/concierge-confirm-token.tsx b/app/routes/public/concierge-confirm-token.tsx
index fbd9968c0..3d1696bdd 100644
--- a/app/routes/public/concierge-confirm-token.tsx
+++ b/app/routes/public/concierge-confirm-token.tsx
@@ -6,6 +6,7 @@ import { ErrorState } from "~/components/ErrorState";
import { ViewerTimeZoneProvider, useViewerTimeZone } from "~/lib/viewer-timezone";
import { ViewerTimeZoneNotice } from "~/components/public/ViewerTimeZoneNotice";
import { m } from "~/paraglide/messages";
+import { getLocale } from "~/paraglide/runtime";
export function meta() {
return [{ title: m.concierge_confirm_meta_title() }];
@@ -71,7 +72,12 @@ export async function action({ params, context }: Route.ActionArgs) {
function ConciergeConfirmBody() {
const { view, status, date } = useLoaderData();
const tz = useViewerTimeZone();
- const displayDate = date ? formatInspectionDateTime(date, undefined, tz) : date;
+ // This surface carries no tenant slug and no session (see the loader), so
+ // there is no tenant locale or shape to read. The page's own UI language is
+ // the only honest answer; the shape stays at the product default.
+ const displayDate = date
+ ? formatInspectionDateTime(date, undefined, tz, { locale: getLocale() })
+ : date;
const nav = useNavigation();
const submitting = nav.state === "submitting";
diff --git a/app/routes/public/portal-inspection.tsx b/app/routes/public/portal-inspection.tsx
index a8d4f2fc3..ca22503ed 100644
--- a/app/routes/public/portal-inspection.tsx
+++ b/app/routes/public/portal-inspection.tsx
@@ -25,7 +25,7 @@ import { useState } from "react";
import type { Route } from "./+types/portal-inspection";
import { createApi } from "~/lib/api-client.server";
import { resolveTenantBrand } from "~/lib/tenant-brand.server";
-import { EMPTY_BRAND } from "~/lib/brand";
+import { brandFormat, EMPTY_BRAND } from "~/lib/brand";
import { formatInspectionDateTime } from "~/lib/format-date";
import ClientPortalHub, {
hubSectionNavHref,
@@ -200,14 +200,14 @@ export async function loader({ params, request, context }: Route.LoaderArgs) {
// portal/report surfaces — and do it in the loader so the formatted string is
// serialized loader data (no client re-format, so no hydration mismatch).
if (overview.date) {
- overview = { ...overview, date: formatInspectionDateTime(overview.date, undefined, brand.defaultTimezone) };
+ overview = { ...overview, date: formatInspectionDateTime(overview.date, undefined, brand.defaultTimezone, brandFormat(brand)) };
}
// Same treatment for the Progress section header date — loadProgressSection
// returns the raw inspections.date; format it in the tenant timezone here so
// receives an already-humanized string (never a bare ISO).
if (progress?.date) {
- progress = { ...progress, date: formatInspectionDateTime(progress.date, undefined, brand.defaultTimezone) };
+ progress = { ...progress, date: formatInspectionDateTime(progress.date, undefined, brand.defaultTimezone, brandFormat(brand)) };
}
// Step 4a — the Notices bell (C3). Rides the loader rather than opening on
diff --git a/app/routes/public/portal.tsx b/app/routes/public/portal.tsx
index 7d186fdf4..3f25fb5d5 100644
--- a/app/routes/public/portal.tsx
+++ b/app/routes/public/portal.tsx
@@ -14,7 +14,7 @@ import type { Route } from "./+types/portal";
import { createApi } from "~/lib/api-client.server";
import { resolveTenantBrand } from "~/lib/tenant-brand.server";
import { formatInspectionDateTime } from "~/lib/format-date";
-import { brandTokens, EMPTY_BRAND, type TenantBrand } from "~/lib/brand";
+import { brandFormat, brandTokens, EMPTY_BRAND, type TenantBrand } from "~/lib/brand";
import InspectionList, { type InspectionRow } from "~/components/portal/InspectionList";
import { PublicLegalFooter } from "~/components/PublicLegalFooter";
import { signOut } from "~/components/portal/sign-out";
@@ -65,7 +65,7 @@ export async function loader({
const inspections = data.inspections.map((row) => ({
...row,
date: row.date
- ? formatInspectionDateTime(row.date, undefined, brand.defaultTimezone)
+ ? formatInspectionDateTime(row.date, undefined, brand.defaultTimezone, brandFormat(brand))
: row.date,
}));
return { authed: true, tenant, email: data.email, inspections, brand };
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 18f35f791..c7e512cc9 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -1,6 +1,6 @@
{
"app/routes/inspection-edit.tsx": 2530,
- "app/routes/inspector-portal.tsx": 1203,
+ "app/routes/inspector-portal.tsx": 1204,
"server/services/inspection/inspection-core.service.ts": 1131,
"server/services/booking.service.ts": 972,
"server/services/inspection/inspection-report.service.ts": 952,
diff --git a/server/lib/validations/public-brand.schema.ts b/server/lib/validations/public-brand.schema.ts
index 93a07c491..24e96a500 100644
--- a/server/lib/validations/public-brand.schema.ts
+++ b/server/lib/validations/public-brand.schema.ts
@@ -18,6 +18,13 @@ export const PublicBrandSchema = z.object({
// Tenant display timezone (IANA; 'UTC' when unset). Public/report surfaces
// anchor displayed inspection dates to this zone.
defaultTimezone: z.string().default('UTC'),
+ // #270 — the tenant's display LANGUAGE and SHAPE. A public surface has no
+ // authenticated user to read a personal override from, and an inspection
+ // date must read the same to all three parties anyway, so these are the
+ // tenant's values and there is no per-viewer variant here.
+ defaultLocale: z.string().default('en-US'),
+ dateFormat: z.enum(['us', 'iso', 'eu']).default('us'),
+ timeFormat: z.enum(['12h', '24h']).default('12h'),
// IA-36 ⑨ — how a client reaches the company when a link stops working.
// A dead-link page that names the company but gives no way to contact it
// tells the reader who failed them, not how to recover.
diff --git a/server/services/branding.service.ts b/server/services/branding.service.ts
index 8816f6dec..f07b01ca2 100644
--- a/server/services/branding.service.ts
+++ b/server/services/branding.service.ts
@@ -5,6 +5,8 @@ import { Errors } from '../lib/errors';
import type { EmailIdentityConfig } from '../lib/email/sender-identity';
import { r2Keys } from '../lib/r2-keys';
import { resolveTenantLegalUrls, type LegalMode } from '../lib/legal-links';
+import { resolveLocale } from '../lib/locale';
+import { resolveDisplayPrefs, type DateFormat, type TimeFormat } from '../lib/session/display-prefs';
export interface IntegrationConfig {
appBaseUrl?: string;
@@ -93,6 +95,9 @@ export class BrandingService {
logoUrl: string | null;
primaryColor: string | null;
defaultTimezone: string;
+ defaultLocale: string;
+ dateFormat: DateFormat;
+ timeFormat: TimeFormat;
supportEmail: string | null;
companyPhone: string | null;
privacyUrl: string | null;
@@ -105,6 +110,9 @@ export class BrandingService {
logoUrl: tenantConfigs.logoUrl,
primaryColor: tenantConfigs.primaryColor,
defaultTimezone: tenantConfigs.defaultTimezone,
+ defaultLocale: tenantConfigs.defaultLocale,
+ dateFormat: tenantConfigs.dateFormat,
+ timeFormat: tenantConfigs.timeFormat,
supportEmail: tenantConfigs.supportEmail,
companyPhone: tenantConfigs.companyPhone,
legalMode: tenantConfigs.legalMode,
@@ -136,6 +144,10 @@ export class BrandingService {
// Public surfaces (portal/report) anchor displayed dates to the tenant
// timezone; NOT NULL DEFAULT 'UTC' so a config-less tenant is 'UTC'.
defaultTimezone: row?.defaultTimezone ?? 'UTC',
+ // #270 — public surfaces render inspection dates in the tenant's
+ // language and shape; there is no viewer to override either.
+ defaultLocale: resolveLocale(row?.defaultLocale),
+ ...resolveDisplayPrefs(null, row),
// IA-36 ⑨ — recovery channel for a reader whose link no longer works.
supportEmail: row?.supportEmail ?? null,
companyPhone: row?.companyPhone ?? null,
From f6493b8a1f6764de02b637a5c34637645c4fdad0 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 10:53:29 +0800
Subject: [PATCH 023/111] feat(#270): route the five hardcoded date renders
through the shared formatter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
All five read the BROWSER's locale and the BROWSER's zone. That is not a
cosmetic inconsistency: on this machine the settings tooltip rendered
"2023/11/15 06:13:20" for an instant every other surface calls Nov 14 — a
different DAY, from the same millisecond, on the same page.
format-date.ts gains formatShapedDate / formatShapedDateTime.
formatInspectionDateTime does not fit these callers: it takes a `now` and elides
the year, which is right for a dashboard row and wrong for an audit stamp. The
new pair takes no `now`, always carries the year, and always attaches the short
zone name.
Which resolution each site uses is a decision, not a detail. The settings panels
and the Stripe delivery log are CHROME — one admin reading their own page — so
the personal override applies. Version history is TENANT-anchored: collaborators
point at a row and say "restore that one", and two dates for one snapshot is the
failure the design forbids.
Plan Risk 3 fired. v.$token.tsx had no brand and nothing in the payload
identified the tenant, so GET /api/public/verify/report/:token now returns
tenantSlug and the loader resolves the brand through resolveTenantBrand — the
one resolver every other public surface already uses. The slug is already a
company's public identifier and the reader is holding that company's report link.
The guard goes in lint:i18n, not lint:tz as the plan said. check-tz-safety is
scoped to three calendar paths and its patterns are only sound there; check-i18n
already scans app/ + server/, already excludes the formatter modules, and
already carried a test asserting the OPPOSITE of this rule ("bare toLocale is
already viewer-responsive"). Two gates demanding different things of one line is
how a fix gets reverted by the other gate. That test is inverted, and the
inversion is the proof.
useDisplayDateFormat / useDisplayTimeFormat are deleted: nothing ever wanted one
axis without the other, and lint:deadcode flagged them. useChromeDateTimeFormat
replaces them as the viewer-side counterpart to useInspectionDateTimeFormat.
The file-size baseline moves for three files, not one: the gate reads the
working tree, so it cannot be satisfied one commit at a time. VersionHistoryPanel
is this commit's; the two settings routes belong to the Settings UI that follows.
---
app/components/collab/VersionHistoryPanel.tsx | 27 +++++++--
.../collab/version-history-panel.test.ts | 31 ++++++++--
.../settings/ConnectionTestStatus.tsx | 34 ++++++++---
.../settings/connection-test-status.test.ts | 29 ++++++++-
.../integrations/StripePaymentsPanel.tsx | 12 +++-
app/hooks/useSessionContext.ts | 24 +++++---
app/lib/format-date.ts | 60 +++++++++++++++++++
app/routes/public/v.$token.tsx | 39 ++++++++----
scripts/check-i18n.mjs | 36 +++++++++++
scripts/file-size-baseline.json | 6 +-
server/api/public/verify.ts | 5 ++
server/lib/verify-data.ts | 15 ++++-
tests/unit/platform/check-i18n.spec.ts | 31 ++++++++--
13 files changed, 304 insertions(+), 45 deletions(-)
diff --git a/app/components/collab/VersionHistoryPanel.tsx b/app/components/collab/VersionHistoryPanel.tsx
index 1842b3463..e435026a1 100644
--- a/app/components/collab/VersionHistoryPanel.tsx
+++ b/app/components/collab/VersionHistoryPanel.tsx
@@ -5,6 +5,8 @@ import { applyItemPatch } from "../../../server/lib/collab/results-doc";
import type { ResultsProjection } from "../../../server/lib/collab/results-doc.types";
import { diffProjections, type FindingDiff, type ScalarField } from "~/lib/collab/snapshot-diff";
import { VersionCompare } from "~/components/collab/VersionCompare";
+import { useDisplayTimeZone, useInspectionDateTimeFormat } from "~/hooks/useSessionContext";
+import { formatShapedDate, type InspectionDateTimeFormat } from "~/lib/format-date";
import { m } from "~/paraglide/messages";
/**
@@ -78,9 +80,21 @@ function reasonLabel(reason: SnapshotReason | undefined, byUserId: string | null
/**
* Tiny dependency-free relative-time formatter ("just now", "2 minutes ago",
- * "3 hours ago", "5 days ago"). Falls back to a locale date for older entries.
+ * "3 hours ago", "5 days ago"). Falls back to an absolute date past a week.
+ *
+ * `timeZone` and `fmt` are REQUIRED for the fallback branch (#270). The old
+ * `new Date(atMs).toLocaleDateString()` read the BROWSER's zone and locale, so
+ * two collaborators on the same document could see two different dates against
+ * the same snapshot — and this list is the one place they point at a version
+ * and say "restore that one". Both resolve from the TENANT
+ * (`useInspectionDateTimeFormat`) for exactly that reason.
*/
-export function formatRelativeTime(atMs: number, now: number = Date.now()): string {
+export function formatRelativeTime(
+ atMs: number,
+ now: number,
+ timeZone: string,
+ fmt: InspectionDateTimeFormat,
+): string {
const diffMs = now - atMs;
if (!Number.isFinite(diffMs) || diffMs < 0) return m.editor_collab_just_now();
const sec = Math.floor(diffMs / 1000);
@@ -91,7 +105,7 @@ export function formatRelativeTime(atMs: number, now: number = Date.now()): stri
if (hr < 24) return m.editor_collab_hours_ago({ hr, s: hr === 1 ? "" : "s" });
const day = Math.floor(hr / 24);
if (day < 7) return m.editor_collab_days_ago({ day, s: day === 1 ? "" : "s" });
- return new Date(atMs).toLocaleDateString();
+ return formatShapedDate(atMs, timeZone, fmt);
}
/** Narrow an `unknown` JSON payload to the snapshot list shape. */
@@ -161,6 +175,11 @@ export function VersionHistoryPanel({
const canCompare = !!currentResults;
+ // Tenant-anchored (#270): every collaborator on this document must read the
+ // same date off this list.
+ const timeZone = useDisplayTimeZone();
+ const fmt = useInspectionDateTimeFormat();
+
const base = `/api/inspections/${inspectionId}/collab`;
// #181 PR-H — open Compare for a row: fetch that snapshot's projection (the
@@ -370,7 +389,7 @@ export function VersionHistoryPanel({
>
- {formatRelativeTime(snap.atMs)}
+ {formatRelativeTime(snap.atMs, Date.now(), timeZone, fmt)}
{reasonLabel(snap.reason, snap.byUserId)}
diff --git a/app/components/collab/version-history-panel.test.ts b/app/components/collab/version-history-panel.test.ts
index fcaea4479..dce0080cb 100644
--- a/app/components/collab/version-history-panel.test.ts
+++ b/app/components/collab/version-history-panel.test.ts
@@ -9,8 +9,18 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createElement, act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
+
+// #270 — the panel reads the TENANT's zone + date shape, and those hooks bottom
+// out in `useRouteLoaderData`, which invariants in this router-free harness.
+vi.mock('~/hooks/useSessionContext', () => ({
+ useDisplayTimeZone: () => 'UTC',
+ useInspectionDateTimeFormat: () => ({ locale: 'en-US', dateFormat: 'us', timeFormat: '12h' }),
+}));
+
import { VersionHistoryPanel, formatRelativeTime } from '~/components/collab/VersionHistoryPanel';
+const UTC_US = { locale: 'en-US', dateFormat: 'us' as const, timeFormat: '12h' as const };
+
let container: HTMLDivElement | null = null;
let root: Root | null = null;
@@ -82,10 +92,23 @@ const SNAPSHOTS = [
describe('formatRelativeTime', () => {
it('formats recent/minute/hour/day buckets', () => {
const now = 10_000_000_000;
- expect(formatRelativeTime(now - 1_000, now)).toBe('just now');
- expect(formatRelativeTime(now - 120_000, now)).toBe('2 minutes ago');
- expect(formatRelativeTime(now - 3_600_000, now)).toBe('1 hour ago');
- expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2 days ago');
+ expect(formatRelativeTime(now - 1_000, now, 'UTC', UTC_US)).toBe('just now');
+ expect(formatRelativeTime(now - 120_000, now, 'UTC', UTC_US)).toBe('2 minutes ago');
+ expect(formatRelativeTime(now - 3_600_000, now, 'UTC', UTC_US)).toBe('1 hour ago');
+ expect(formatRelativeTime(now - 2 * 86_400_000, now, 'UTC', UTC_US)).toBe('2 days ago');
+ });
+
+ // #270 — the >7d fallback was `new Date(atMs).toLocaleDateString()`: the
+ // BROWSER's locale and zone, so two collaborators could read two different
+ // dates off the same snapshot. Shape and zone now come from the tenant.
+ it('renders the older-than-a-week fallback in the tenant date shape and zone', () => {
+ const atMs = Date.UTC(2026, 8, 11, 23, 30); // 2026-09-11T23:30Z
+ const now = atMs + 30 * 86_400_000;
+ expect(formatRelativeTime(atMs, now, 'UTC', UTC_US)).toBe('Sep 11, 2026');
+ expect(formatRelativeTime(atMs, now, 'UTC', { ...UTC_US, dateFormat: 'iso' })).toBe('2026-09-11');
+ // A zone that rolls the instant back a day proves the zone is honoured too.
+ expect(formatRelativeTime(atMs, now, 'America/New_York', UTC_US)).toBe('Sep 11, 2026');
+ expect(formatRelativeTime(atMs, now, 'Asia/Tokyo', UTC_US)).toBe('Sep 12, 2026');
});
});
diff --git a/app/components/settings/ConnectionTestStatus.tsx b/app/components/settings/ConnectionTestStatus.tsx
index abc672c87..cb2553ca6 100644
--- a/app/components/settings/ConnectionTestStatus.tsx
+++ b/app/components/settings/ConnectionTestStatus.tsx
@@ -11,12 +11,29 @@
*/
import { useMemo } from "react";
import type { ConnectionTestResult } from "~/lib/connection-test";
+import { useChromeDateTimeFormat, useDisplayTimeZone } from "~/hooks/useSessionContext";
+import {
+ formatShapedDate,
+ formatShapedDateTime,
+ type InspectionDateTimeFormat,
+} from "~/lib/format-date";
import { m } from "~/paraglide/messages";
export type { ConnectionTestResult };
+/**
+ * Zone + shape are threaded in rather than read from a hook here so these stay
+ * pure. Both come from the CHROME resolution (#270): a connection test is an
+ * admin looking at their own settings page, not a value a client and an agent
+ * read to each other, so the personal override applies.
+ */
+interface Display {
+ timeZone: string;
+ fmt: InspectionDateTimeFormat;
+}
+
/** Compact relative time: "just now", "5m ago", "3h ago", "2d ago", else a date. */
-function relativeTime(epochMs: number, nowMs: number): string {
+function relativeTime(epochMs: number, nowMs: number, d: Display): string {
const diff = Math.max(0, nowMs - epochMs);
const min = Math.floor(diff / 60_000);
if (min < 1) return m.settings_conn_time_just_now();
@@ -25,11 +42,11 @@ function relativeTime(epochMs: number, nowMs: number): string {
if (hr < 24) return m.settings_conn_time_hours({ hr });
const day = Math.floor(hr / 24);
if (day < 7) return m.settings_conn_time_days({ day });
- return new Date(epochMs).toLocaleDateString();
+ return formatShapedDate(epochMs, d.timeZone, d.fmt);
}
-function absoluteTime(epochMs: number): string {
- return new Date(epochMs).toLocaleString();
+function absoluteTime(epochMs: number, d: Display): string {
+ return formatShapedDateTime(epochMs, d.timeZone, d.fmt);
}
export function ConnectionTestStatus({
@@ -44,6 +61,7 @@ export function ConnectionTestStatus({
nowMs?: number;
}) {
const now = nowMs ?? Date.now();
+ const display: Display = { timeZone: useDisplayTimeZone(), fmt: useChromeDateTimeFormat() };
const mine = useMemo(
() =>
results
@@ -75,8 +93,8 @@ export function ConnectionTestStatus({
{m.settings_conn_last_tested()}{" "}
-
- {relativeTime(latest.testedAt, now)}
+
+ {relativeTime(latest.testedAt, now, display)}
{latest.provider ? ` · ${latest.provider}` : ""}
@@ -98,8 +116,8 @@ export function ConnectionTestStatus({
{r.ok ? "✓" : "✗"}
-
- {relativeTime(r.testedAt, now)}
+
+ {relativeTime(r.testedAt, now, display)}
{r.detail ? ` — ${r.detail}` : ""}
diff --git a/app/components/settings/connection-test-status.test.ts b/app/components/settings/connection-test-status.test.ts
index cbce11fca..efd8c4483 100644
--- a/app/components/settings/connection-test-status.test.ts
+++ b/app/components/settings/connection-test-status.test.ts
@@ -9,9 +9,19 @@
* Plain createRoot + act harness (no router) — the component renders no
+ {/* #270 — the hint states what this does NOT reach: an inspection is
+ read out loud between three people who cannot see each other's
+ screens, so its dates follow the COMPANY. */}
+
+
+ {m.settings_profile_format_hint()}
+
+
{form.errors && (
{form.errors[0]}
diff --git a/app/routes/settings-workspace.tsx b/app/routes/settings-workspace.tsx
index b8577ff20..7952e16a7 100644
--- a/app/routes/settings-workspace.tsx
+++ b/app/routes/settings-workspace.tsx
@@ -18,6 +18,7 @@ import { AccessDenied } from "~/components/AccessDenied";
import { Select } from "@core/shared-ui";
import { TIMEZONE_SELECT_OPTIONS, getBrowserTimeZone, onboardingTzPrefill } from "~/lib/timezones";
import { LOCALE_OPTIONS, CURRENCY_OPTIONS } from "~/lib/locales";
+import { DateTimeFormatFields } from "~/components/settings/DateTimeFormatFields";
import { m } from "~/paraglide/messages";
/* ------------------------------------------------------------------ */
@@ -39,6 +40,8 @@ interface Branding {
defaultTimezone?: string | null;
defaultLocale?: string | null;
currency?: string | null;
+ dateFormat?: string | null;
+ timeFormat?: string | null;
}
/* ------------------------------------------------------------------ */
@@ -116,6 +119,10 @@ export async function action({ request, context }: Route.ActionArgs) {
// Tenant display locale (BCP-47) + currency (ISO 4217). Only sent when present.
if (typeof v.defaultLocale === "string" && v.defaultLocale) body.defaultLocale = v.defaultLocale;
if (typeof v.currency === "string" && v.currency) body.currency = v.currency;
+ // #270 — an absent key must leave the stored preference alone (which is why
+ // the API schema carries no `.default()` for these).
+ if (typeof v.dateFormat === "string" && v.dateFormat) body.dateFormat = v.dateFormat;
+ if (typeof v.timeFormat === "string" && v.timeFormat) body.timeFormat = v.timeFormat;
const api = createApi(context, { token });
// Body is runtime-assembled from Zod-validated form values matching UpdateBrandingSchema;
@@ -205,6 +212,7 @@ export default function SettingsWorkspacePage() {
{ id: "branding", label: m.settings_workspace_branding_heading() },
{ id: "timezone", label: m.settings_workspace_timezone_heading() },
{ id: "locale-currency", label: m.settings_workspace_locale_currency_heading() },
+ { id: "datetime-format", label: m.settings_workspace_datetime_format_heading() },
{ id: "report-style", label: m.settings_workspace_report_style_heading() },
{ id: "referral", label: m.settings_workspace_referral_heading() },
{ id: "report-features", label: m.settings_workspace_report_features_heading() },
@@ -325,6 +333,20 @@ export default function SettingsWorkspacePage() {
+ {/* Date & time format (#270) */}
+
+
{/* Report style */}
diff --git a/messages/en/settings.json b/messages/en/settings.json
index 449538904..680282a54 100644
--- a/messages/en/settings.json
+++ b/messages/en/settings.json
@@ -138,7 +138,7 @@
"settings_workspace_timezone_select_label": "Company timezone",
"settings_workspace_timezone_detected": "Detected from your browser. Save to confirm, or pick another.",
"settings_workspace_locale_currency_heading": "Locale & Currency",
- "settings_workspace_locale_currency_subtitle": "Controls how dates, times, numbers, and money are formatted across reports and the dashboard. Individual users can override the language/locale in their profile; currency is company-wide.",
+ "settings_workspace_locale_currency_subtitle": "Controls the LANGUAGE dates, times, and numbers are written in, and the money they are billed in. The shape of a date — whether the day comes before the month, and whether 14:30 is written 2:30 PM — is the separate setting below. Individual users can override the language in their profile; currency is company-wide.",
"settings_workspace_locale_select_label": "Company locale",
"settings_workspace_currency_select_label": "Currency",
"settings_workspace_report_style_heading": "Report style",
@@ -243,5 +243,12 @@
"settings_profile_signature_upload_unreadable": "That file could not be read as an image.",
"settings_profile_signature_crop_aria": "Crop signature",
"settings_profile_signature_crop_save": "Save signature",
- "settings_profile_photo_choose": "Choose photo"
+ "settings_profile_photo_choose": "Choose photo",
+ "settings_workspace_datetime_format_heading": "Date & Time Format",
+ "settings_workspace_datetime_format_subtitle": "The shape every date and time is written in across your company — including on reports and on the pages your clients and agents read. Language is the separate setting above: it decides what \"September\" is called, this decides whether the day comes before it.",
+ "settings_workspace_dateformat_select_label": "Date format",
+ "settings_workspace_timeformat_select_label": "Time format",
+ "settings_profile_dateformat_label": "Your date format",
+ "settings_profile_timeformat_label": "Your clock",
+ "settings_profile_format_hint": "Applies to your own dashboards, lists, and calendar. Inspection dates, report dates, and appointment times always use the company format — you, your client, and the agent read the same one out loud on the phone."
}
diff --git a/messages/es-419/settings.json b/messages/es-419/settings.json
index 006f618aa..3014e97da 100644
--- a/messages/es-419/settings.json
+++ b/messages/es-419/settings.json
@@ -1,3 +1,10 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "settings_workspace_datetime_format_heading": "Formato de fecha y hora",
+ "settings_workspace_datetime_format_subtitle": "La forma en que se escribe cada fecha y hora en toda su empresa, incluidos los informes y las páginas que leen sus clientes y agentes. El idioma es la configuración separada de arriba: decide cómo se llama \"septiembre\"; esta decide si el día va antes.",
+ "settings_workspace_dateformat_select_label": "Formato de fecha",
+ "settings_workspace_timeformat_select_label": "Formato de hora",
+ "settings_profile_dateformat_label": "Su formato de fecha",
+ "settings_profile_timeformat_label": "Su reloj",
+ "settings_profile_format_hint": "Se aplica a sus propios paneles, listas y calendario. Las fechas de inspección, las fechas de los informes y las horas de las citas siempre usan el formato de la empresa: usted, su cliente y el agente leen la misma en voz alta por teléfono."
}
diff --git a/server/api/admin/branding.ts b/server/api/admin/branding.ts
index 4073e48e6..0ec6909e0 100644
--- a/server/api/admin/branding.ts
+++ b/server/api/admin/branding.ts
@@ -134,7 +134,9 @@ const adminBrandingRoutes = createApiRouter()
billingUrl: branding.billingUrl || null,
defaultTimezone: branding.defaultTimezone || 'UTC',
defaultLocale: branding.defaultLocale || 'en-US',
- currency: branding.currency || 'USD'
+ currency: branding.currency || 'USD',
+ dateFormat: branding.dateFormat || 'us',
+ timeFormat: branding.timeFormat || '12h'
};
return c.json({ success: true, data: { branding: formattedBranding } }, 200);
@@ -185,7 +187,9 @@ const adminBrandingRoutes = createApiRouter()
billingUrl: result.billingUrl || null,
defaultTimezone: result.defaultTimezone || 'UTC',
defaultLocale: result.defaultLocale || 'en-US',
- currency: result.currency || 'USD'
+ currency: result.currency || 'USD',
+ dateFormat: result.dateFormat || 'us',
+ timeFormat: result.timeFormat || '12h'
};
return c.json({ success: true, data: { branding: formattedResult } }, 200);
diff --git a/server/api/profile.ts b/server/api/profile.ts
index 5b8fcd4a7..93ecd0d89 100644
--- a/server/api/profile.ts
+++ b/server/api/profile.ts
@@ -12,6 +12,7 @@ import { inspectorSignature } from '../lib/inspector-signature';
import { r2Keys } from '../lib/r2-keys';
import { isValidTimeZone } from '../lib/tz';
import { isValidLocale } from '../lib/locale';
+import { DATE_FORMATS, TIME_FORMATS } from '../lib/session/display-prefs';
import { getDrizzle } from '../lib/route-helpers';
import { r2Put } from '../lib/r2/objects';
@@ -43,6 +44,10 @@ const getProfileRoute = createRoute(withMcpMetadata({
signaturePreviewHtml: z.string(),
timezone: z.string().nullable(),
locale: z.string().nullable(),
+ // #270 — null means "inherit the company setting", the
+ // same convention as timezone/locale above.
+ dateFormat: z.string().nullable(),
+ timeFormat: z.string().nullable(),
})),
},
},
@@ -62,6 +67,13 @@ export const PatchProfileSchema = z.object({
signatureEnabled: z.boolean().optional().describe('Whether the inspector business-card footer is added to outbound emails'),
timezone: z.string().refine((v) => v === '' || isValidTimeZone(v), 'Invalid timezone').optional().describe('Per-user display timezone (IANA). Empty string clears the override (inherit tenant).'),
locale: z.string().refine((v) => v === '' || isValidLocale(v), 'Invalid locale').optional().describe('Per-user display locale (BCP-47). Empty string clears the override (inherit tenant).'),
+ // #270 — SHAPE, not language. `.optional()` with NO `.default()` on purpose:
+ // a default here would make an omitted key indistinguishable from an
+ // explicit one and silently rewrite a preference the caller never mentioned.
+ // Guarded by tests/unit/session/format-prefs-write-path.spec.ts, which
+ // asserts the KEY IS ABSENT rather than asserting its value.
+ dateFormat: z.enum(['', ...DATE_FORMATS]).optional().describe('Per-user date order (us|iso|eu). Empty string clears the override (inherit tenant).'),
+ timeFormat: z.enum(['', ...TIME_FORMATS]).optional().describe('Per-user clock (12h|24h). Empty string clears the override (inherit tenant).'),
});
const patchProfileRoute = createRoute(withMcpMetadata({
@@ -136,6 +148,8 @@ const profileRoutes = createApiRouter()
signatureEnabled: users.signatureEnabled,
timezone: users.timezone,
locale: users.locale,
+ dateFormat: users.dateFormat,
+ timeFormat: users.timeFormat,
// The drawn signature itself. Settings said "Signature saved" and
// showed the reader nothing — so the one thing they might want to
// check, that the right mark was captured, was the one thing the
@@ -189,6 +203,9 @@ const profileRoutes = createApiRouter()
if (body.timezone !== undefined) updates.timezone = body.timezone === '' ? null : body.timezone;
// Per-user locale override: empty string clears it (NULL = inherit tenant).
if (body.locale !== undefined) updates.locale = body.locale === '' ? null : body.locale;
+ // #270 — per-user date/time SHAPE override, same '' = clear convention.
+ if (body.dateFormat !== undefined) updates.dateFormat = body.dateFormat === '' ? null : body.dateFormat;
+ if (body.timeFormat !== undefined) updates.timeFormat = body.timeFormat === '' ? null : body.timeFormat;
// DB-12 / IA-26 — slug write removed; inspector booking slugs are frozen.
// Agent slug writes go through POST /api/agent/profile (separate endpoint).
diff --git a/server/lib/validations/admin/settings.ts b/server/lib/validations/admin/settings.ts
index a14c7d353..9f2284881 100644
--- a/server/lib/validations/admin/settings.ts
+++ b/server/lib/validations/admin/settings.ts
@@ -2,6 +2,7 @@ import { z } from '@hono/zod-openapi';
import { createApiResponseSchema } from '../shared.schema';
import { isValidTimeZone } from '../../tz';
import { isValidLocale } from '../../locale';
+import { DATE_FORMATS, TIME_FORMATS } from '../../session/display-prefs';
/**
* Validation schema for the branding configuration update.
@@ -53,6 +54,14 @@ export const UpdateBrandingSchema = z.object({
// Tenant transaction/display currency (ISO 4217). Constrained to the
// supported set; tenant-scoped only (no per-user override).
currency: z.enum(['USD']).optional().openapi({ example: 'USD' }).describe('Tenant currency (ISO 4217).'),
+ // #270 — date/time SHAPE, a separate axis from `defaultLocale`: the locale
+ // decides what language "September" is written in, these decide whether the
+ // day comes before it and whether 14:30 is spelled 2:30 PM. Tenant-level and
+ // NOT nullable — this is the bottom of the resolution chain. `.optional()`
+ // with no `.default()`: a default would make an omitted key overwrite a
+ // stored preference the caller never mentioned.
+ dateFormat: z.enum(DATE_FORMATS).optional().openapi({ example: 'us' }).describe('Tenant default date order (us|iso|eu).'),
+ timeFormat: z.enum(TIME_FORMATS).optional().openapi({ example: '12h' }).describe('Tenant default clock (12h|24h).'),
// IA-100 — whether archiving a contact also revokes the report links they
// still hold. Off by default; see the column comment for why archiving is
// treated as list hygiene rather than offboarding.
@@ -82,6 +91,8 @@ export const BrandingResponseSchema = createApiResponseSchema(z.object({
defaultTimezone: z.string().describe('Tenant default IANA timezone (e.g. America/New_York); UTC when unset.'),
defaultLocale: z.string().describe('Tenant default display locale (BCP-47, e.g. es-419); en-US when unset.'),
currency: z.string().describe('Tenant currency (ISO 4217, e.g. USD); USD when unset.'),
+ dateFormat: z.string().describe('Tenant default date order (us|iso|eu); us when unset.'),
+ timeFormat: z.string().describe('Tenant default clock (12h|24h); 12h when unset.'),
archiveRevokesAccess: z.boolean().optional()
.describe('Whether archiving a contact also revokes the report links they still hold. False by default: archiving is list hygiene, not offboarding.'),
}).describe('TODO describe branding field for the OpenInspection MCP integration'),
diff --git a/server/services/branding.service.ts b/server/services/branding.service.ts
index f07b01ca2..4f16bd2e2 100644
--- a/server/services/branding.service.ts
+++ b/server/services/branding.service.ts
@@ -49,7 +49,11 @@ export class BrandingService {
billingUrl: '',
defaultTimezone: 'UTC',
defaultLocale: 'en-US',
- currency: 'USD'
+ currency: 'USD',
+ // #270 — the bottom of the resolution chain; these reproduce
+ // today's rendering exactly for a tenant with no config row.
+ dateFormat: 'us',
+ timeFormat: '12h'
};
}
diff --git a/tests/unit/session/format-prefs-write-path.spec.ts b/tests/unit/session/format-prefs-write-path.spec.ts
new file mode 100644
index 000000000..84f99ea24
--- /dev/null
+++ b/tests/unit/session/format-prefs-write-path.spec.ts
@@ -0,0 +1,69 @@
+/**
+ * #270 — the WRITE path for the date/time format preferences.
+ *
+ * These assert the shape of what a PATCH body parses into, not what a handler
+ * renders, because the defect this guards against is invisible at the render
+ * layer: `.partial()` KEEPS `.default()`, so a schema field carrying a default
+ * turns an omitted key into an explicitly-sent one, and the update silently
+ * overwrites a preference the caller never mentioned. That has already caused
+ * real data loss in this repo once (label data).
+ *
+ * So every assertion here is about the ABSENCE of a key. Asserting on a value
+ * would pass against exactly the broken schema it is supposed to catch.
+ */
+import { describe, it, expect } from 'vitest';
+import { PatchProfileSchema } from '../../../server/api/profile';
+import { UpdateBrandingSchema } from '../../../server/lib/validations/admin/settings';
+
+describe('tenant branding format preferences', () => {
+ it('omits the format keys entirely when the caller does not send them', () => {
+ const parsed = UpdateBrandingSchema.parse({ companyName: 'Acme Inspections' });
+ expect(Object.keys(parsed)).not.toContain('dateFormat');
+ expect(Object.keys(parsed)).not.toContain('timeFormat');
+ expect('dateFormat' in parsed).toBe(false);
+ expect('timeFormat' in parsed).toBe(false);
+ });
+
+ it('accepts the three date shapes and the two clocks', () => {
+ for (const dateFormat of ['us', 'iso', 'eu']) {
+ expect(UpdateBrandingSchema.parse({ dateFormat }).dateFormat).toBe(dateFormat);
+ }
+ for (const timeFormat of ['12h', '24h']) {
+ expect(UpdateBrandingSchema.parse({ timeFormat }).timeFormat).toBe(timeFormat);
+ }
+ });
+
+ it('rejects a value outside the enum rather than storing it', () => {
+ expect(UpdateBrandingSchema.safeParse({ dateFormat: 'dd/mm/yyyy' }).success).toBe(false);
+ expect(UpdateBrandingSchema.safeParse({ timeFormat: '13h' }).success).toBe(false);
+ // The tenant value is the BOTTOM of the resolution chain, so unlike the
+ // per-user override there is no "inherit" state to express.
+ expect(UpdateBrandingSchema.safeParse({ dateFormat: '' }).success).toBe(false);
+ });
+});
+
+describe('per-user format override', () => {
+ it('omits the format keys entirely when the caller does not send them', () => {
+ const parsed = PatchProfileSchema.parse({ name: 'Dana' });
+ expect('dateFormat' in parsed).toBe(false);
+ expect('timeFormat' in parsed).toBe(false);
+ });
+
+ it('keeps an empty string distinct from an absent key', () => {
+ // '' is the CLEAR signal (handler writes NULL = inherit the tenant);
+ // absent means "do not touch". Collapsing the two is the whole bug.
+ const cleared = PatchProfileSchema.parse({ dateFormat: '', timeFormat: '' });
+ expect(cleared.dateFormat).toBe('');
+ expect(cleared.timeFormat).toBe('');
+ const untouched = PatchProfileSchema.parse({});
+ expect(untouched.dateFormat).toBeUndefined();
+ expect('dateFormat' in untouched).toBe(false);
+ });
+
+ it('accepts the enum values and rejects anything else', () => {
+ expect(PatchProfileSchema.parse({ dateFormat: 'eu' }).dateFormat).toBe('eu');
+ expect(PatchProfileSchema.parse({ timeFormat: '24h' }).timeFormat).toBe('24h');
+ expect(PatchProfileSchema.safeParse({ dateFormat: 'US' }).success).toBe(false);
+ expect(PatchProfileSchema.safeParse({ timeFormat: '24' }).success).toBe(false);
+ });
+});
From 55e14eba438f04a1115012c0428cf276c06f738f Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 11:22:33 +0800
Subject: [PATCH 025/111] fix(reports): bind inspection_results.report_id at
creation
`uq_results_report` replaced `uq_results_inspection`, but it is a unique
index on a NULLABLE column: SQLite accepts any number of NULLs. Both
creation paths wrote the results row without a `report_id`, so nothing
errored and every per-report read matched no document or a sibling's.
`createPrimaryReport` now returns its id, and both paths create the
report BEFORE the results row that has to name it.
---
scripts/file-size-baseline.json | 2 +-
server/lib/inspection/reports.ts | 14 ++++-
.../inspection/inspection-core.service.ts | 17 +++---
.../inspection-create-results-row.spec.ts | 61 +++++++++++++++++++
4 files changed, 83 insertions(+), 11 deletions(-)
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index f3be41723..7dc2df097 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -1,7 +1,7 @@
{
"app/routes/inspection-edit.tsx": 2530,
"app/routes/inspector-portal.tsx": 1204,
- "server/services/inspection/inspection-core.service.ts": 1131,
+ "server/services/inspection/inspection-core.service.ts": 1132,
"server/services/booking.service.ts": 972,
"server/services/inspection/inspection-report.service.ts": 952,
"server/durable-objects/inspection-doc.ts": 928,
diff --git a/server/lib/inspection/reports.ts b/server/lib/inspection/reports.ts
index 2633a3527..f1c0e2316 100644
--- a/server/lib/inspection/reports.ts
+++ b/server/lib/inspection/reports.ts
@@ -67,6 +67,13 @@ export async function resolvePrimaryReportId(
* Non-fatal by design. Both callers have already written the canonical
* `inspections` row, and throwing here would lose it over a row that a backfill
* can add later.
+ *
+ * Returns the new report's id, or null when the insert failed. Callers need it:
+ * the `inspection_results` row born alongside it must carry `report_id`, and a
+ * results row left NULL is invisible rather than loud — `uq_results_report` is a
+ * unique index on a nullable column, so SQLite permits any number of NULLs, and
+ * every read that asks "which document belongs to this report" silently matches
+ * nothing or matches a sibling.
*/
export async function createPrimaryReport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -74,10 +81,11 @@ export async function createPrimaryReport(
tenantId: string,
inspectionId: string,
templateId: string | null,
-): Promise {
+): Promise {
+ const id = crypto.randomUUID();
try {
await db.insert(reports).values({
- id: crypto.randomUUID(),
+ id,
tenantId,
inspectionId,
kind: 'primary',
@@ -92,8 +100,10 @@ export async function createPrimaryReport(
status: REPORT_STATUS.IN_PROGRESS,
createdAt: new Date(),
});
+ return id;
} catch (err) {
logger.error('primary report create failed', { inspectionId },
err instanceof Error ? err : undefined);
+ return null;
}
}
diff --git a/server/services/inspection/inspection-core.service.ts b/server/services/inspection/inspection-core.service.ts
index e83bfca81..14411e23c 100644
--- a/server/services/inspection/inspection-core.service.ts
+++ b/server/services/inspection/inspection-core.service.ts
@@ -449,6 +449,10 @@ export class InspectionCoreService extends InspectionSubService {
// must never burn a free tenant's lifetime slot.
await this.planQuota?.consumeInspection(tenantId);
await this.sdb.insert(inspections, newInspection);
+ // Before the results row, because that row has to NAME it: a NULL
+ // `report_id` is accepted silently and reads match a sibling document
+ // or none. See `lib/inspection/reports.ts`.
+ const primaryReportId = await createPrimaryReport(db, tenantId, id, data.templateId ?? null);
// Every inspection starts with a results row.
//
// The collaborative editor's Durable Object writes findings by UPDATEing
@@ -462,6 +466,7 @@ export class InspectionCoreService extends InspectionSubService {
id: crypto.randomUUID(),
tenantId,
inspectionId: id,
+ reportId: primaryReportId,
data: {},
lastSyncedAt: createdAt,
});
@@ -560,11 +565,6 @@ export class InspectionCoreService extends InspectionSubService {
}
}
- // Every order gets its primary report. Without it the collab route —
- // which resolves an inspection to its primary and fails closed — cannot
- // open the editor at all for anything created from here on.
- await createPrimaryReport(db, tenantId, id, data.templateId ?? null);
-
return {
...newInspection,
clientName: clientNameInput,
@@ -670,10 +670,14 @@ export class InspectionCoreService extends InspectionSubService {
reinspectionRound: round,
});
+ // Its own ORDER, so its own primary report — before the row naming it.
+ const primaryReportId = await createPrimaryReport(db, tenantId, id, null);
+
await db.insert(inspectionResults).values({
id: crypto.randomUUID(),
tenantId,
inspectionId: id,
+ reportId: primaryReportId,
data: seeded as unknown as object,
lastSyncedAt: createdAt,
});
@@ -697,9 +701,6 @@ export class InspectionCoreService extends InspectionSubService {
logger.error('inspection-people copy from reinspection create failed', { inspectionId: id }, err instanceof Error ? err : undefined);
}
- // A re-inspection is its own ORDER, so it gets its own primary report.
- await createPrimaryReport(db, tenantId, id, null);
-
const created = await db.select().from(inspections).where(eq(inspections.id, id)).get();
return created as unknown as Inspection;
}
diff --git a/tests/unit/inspections/inspection-create-results-row.spec.ts b/tests/unit/inspections/inspection-create-results-row.spec.ts
index 18f43f4a4..c3be8dc6c 100644
--- a/tests/unit/inspections/inspection-create-results-row.spec.ts
+++ b/tests/unit/inspections/inspection-create-results-row.spec.ts
@@ -73,6 +73,67 @@ describe('createInspection — results row', () => {
expect(Object.keys(data)).toHaveLength(0);
});
+ it('binds the row to the primary report, not to the inspection alone', async () => {
+ // One order can now deliver several documents, and `inspection_results`
+ // is per REPORT — `uq_results_report` replaced `uq_results_inspection`.
+ // But that index is unique on a NULLABLE column, so a row written
+ // without `report_id` is accepted, and any number of them are: nothing
+ // errors, and every per-report read either matches no document or
+ // matches a sibling's. Creation is the only place the binding can be
+ // guaranteed rather than guessed at.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await inspectionSvc.createInspection(TENANT, { propertyAddress: '5 Findings Way' } as any);
+
+ const inspection = await testDb.select().from(schema.inspections).get();
+ const report = await testDb
+ .select()
+ .from(schema.reports)
+ .where(eq(schema.reports.inspectionId, inspection!.id))
+ .get();
+ const results = await testDb
+ .select()
+ .from(schema.inspectionResults)
+ .where(eq(schema.inspectionResults.inspectionId, inspection!.id))
+ .get();
+
+ expect(report, 'the order was born without a primary report').toBeTruthy();
+ expect(
+ results!.reportId,
+ 'the results row carries no report_id — the document belongs to no report',
+ ).toBe(report!.id);
+ });
+
+ it('binds a re-inspection results row to the re-inspection own primary report', async () => {
+ // The second creation path, and the one that looks most like the first
+ // — which is exactly why it is the one that gets fixed in only one
+ // place. A re-inspection is its own order with its own primary report.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const baseline = await inspectionSvc.createInspection(TENANT, { propertyAddress: '6 Findings Way' } as any);
+ // `createReinspection` refuses an unpublished baseline, and it reads the
+ // latest published snapshot rather than the live row.
+ await testDb.insert(schema.reportVersions).values({
+ id: 'ver-baseline-1', tenantId: TENANT, inspectionId: baseline.id, versionNumber: 1,
+ snapshotJson: JSON.stringify({ inspection: {}, data: {}, units: [] }),
+ publishedAt: new Date(), publishedBy: 'tester', createdAt: new Date(),
+ } as never);
+
+ const reinspection = await inspectionSvc.createReinspection(TENANT, baseline.id, { selectedItemIds: [] });
+
+ const report = await testDb
+ .select()
+ .from(schema.reports)
+ .where(eq(schema.reports.inspectionId, reinspection.id))
+ .get();
+ const results = await testDb
+ .select()
+ .from(schema.inspectionResults)
+ .where(eq(schema.inspectionResults.inspectionId, reinspection.id))
+ .get();
+
+ expect(report).toBeTruthy();
+ expect(results!.reportId).toBe(report!.id);
+ });
+
it('gives each inspection its own row', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await inspectionSvc.createInspection(TENANT, { propertyAddress: '3 Findings Way' } as any);
From 0eec265f303f5966ae234cb74404c86c22a03ff1 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 11:57:16 +0800
Subject: [PATCH 026/111] feat(reports): per-report list on the order page,
with a delete that names what it destroys
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One order delivers several documents, but the page showed a single report
status pill derived from the inspection row — so an order carrying three
deliverables looked exactly like an order carrying one.
The list rides the existing hub payload rather than a second round trip.
Deletion is the one irreversible action here: a report owns its findings
document, its Yjs state and its version chain, and with no foreign keys
nothing notices the orphans. The confirmation names the report and says
that the content already filled into it is destroyed.
Two refusals, decided by ONE server function the endpoint enforces and
the payload reports, so the card cannot offer what the API refuses:
- the primary report (the collab route fails closed without one, so
deleting it makes the whole order uneditable);
- a published report (delivered, and its signed versions are what let a
client verify the document they hold).
Also: ConfirmDialog's "Cancel"/"Delete" were bare literals, shipping
untranslated chrome from all ten of its call sites. Fixed at source.
And the hub facade's return type is now DERIVED from its delegate — the
hand-copy had rotted past `services`, `communication` and `unlockedAt`.
---
app/components/ConfirmDialog.tsx | 15 +-
.../inspector-portal/ReportsCard.tsx | 150 ++++++++++++++
.../inspector-portal/reports-card.test.tsx | 130 ++++++++++++
app/lib/inspection-order-actions.ts | 23 +++
app/routes/inspector-portal.tsx | 14 ++
messages/en/inspections.json | 15 ++
scripts/file-size-baseline.json | 6 +-
server/api/inspections.ts | 6 +-
server/api/inspections/reports.ts | 48 +++++
server/lib/inspection/reports.ts | 160 +++++++++++++-
server/lib/validations/inspection/read.ts | 11 +
server/services/inspection.service.ts | 55 +----
.../inspection/inspection-publish.service.ts | 5 +
tests/unit/inspections/report-delete.spec.ts | 195 ++++++++++++++++++
14 files changed, 779 insertions(+), 54 deletions(-)
create mode 100644 app/components/inspector-portal/ReportsCard.tsx
create mode 100644 app/components/inspector-portal/reports-card.test.tsx
create mode 100644 server/api/inspections/reports.ts
create mode 100644 tests/unit/inspections/report-delete.spec.ts
diff --git a/app/components/ConfirmDialog.tsx b/app/components/ConfirmDialog.tsx
index 8c6a275c5..0602b44d1 100644
--- a/app/components/ConfirmDialog.tsx
+++ b/app/components/ConfirmDialog.tsx
@@ -1,7 +1,16 @@
import { Modal } from "@core/shared-ui";
+import { m } from "~/paraglide/messages";
+/**
+ * Both button labels are TRANSLATED, and the confirm label defaults rather than
+ * being hardcoded. They used to be the bare strings "Cancel" and "Delete" —
+ * which meant this one component silently shipped untranslated chrome to every
+ * one of its call sites, in the middle of dialogs whose title and message were
+ * translated. A shared component is the worst place to leave a literal: it does
+ * not look like ten omissions, it looks like one.
+ */
export function ConfirmDialog({
- open, title, message, confirmLabel = "Delete", tone = "danger", busy = false, onConfirm, onCancel,
+ open, title, message, confirmLabel, tone = "danger", busy = false, onConfirm, onCancel,
}: {
open: boolean;
title: string;
@@ -29,7 +38,7 @@ export function ConfirmDialog({
onClick={onCancel}
className="px-4 py-2 rounded-md border border-ih-border text-[13px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors"
>
- Cancel
+ {m.common_cancel()}
- {confirmLabel}
+ {confirmLabel ?? m.common_delete()}
>
}
diff --git a/app/components/inspector-portal/ReportsCard.tsx b/app/components/inspector-portal/ReportsCard.tsx
new file mode 100644
index 000000000..f65950bae
--- /dev/null
+++ b/app/components/inspector-portal/ReportsCard.tsx
@@ -0,0 +1,150 @@
+import { useState } from "react";
+import { useFetcher } from "react-router";
+import { Card, Pill } from "@core/shared-ui";
+import { BlockHeading } from "./BlockHeading";
+import { ConfirmDialog } from "~/components/ConfirmDialog";
+import { m } from "~/paraglide/messages";
+import type { action } from "~/routes/inspector-portal";
+
+/**
+ * Mirrors the `reports` entry of `InspectionHubSchema`. Kept as a named export
+ * so the route's payload interface points at THIS rather than repeating the
+ * shape — a second hand-written copy of a payload is how `invoice.payUrl` went
+ * missing on the frontend for a release.
+ */
+export interface ReportRow {
+ id: string;
+ kind: "primary" | "ancillary";
+ title: string;
+ status: string;
+ publishedAt: string | null;
+ versionCount: number;
+ hasContent: boolean;
+ canDelete: boolean;
+ deleteBlockedReason: "primary" | "published" | null;
+}
+
+/**
+ * The order's deliverables.
+ *
+ * One order, several reports: a standard inspection publishes today and the
+ * radon report publishes on Thursday, each with its own document, its own
+ * signature chain and its own notification. Until this card existed the page
+ * showed a single report status pill derived from the inspection row, so an
+ * order carrying three documents looked exactly like an order carrying one.
+ *
+ * `canDelete` is READ, never re-derived. The rule lives in one function on the
+ * server (`reportDeleteBlock`) which the DELETE endpoint also enforces, so this
+ * card cannot offer an action the API refuses — and the disabled control states
+ * the reason rather than failing silently when clicked.
+ */
+export function ReportsCard({
+ reports,
+ canManage,
+ formatDate,
+}: {
+ reports: ReportRow[];
+ canManage: boolean;
+ formatDate: (iso: string) => string;
+}) {
+ const deleteFetcher = useFetcher();
+ const [deleting, setDeleting] = useState(null);
+
+ const busy = deleteFetcher.state !== "idle";
+ const done = deleteFetcher.state === "idle" ? deleteFetcher.data : undefined;
+ const error = done && "ok" in done && !done.ok && done.intent === "report-delete"
+ ? done.error
+ : undefined;
+
+ return (
+
+
+
+ {reports.length === 0 ? (
+ {m.inspections_hub_reports_empty()}
+ ) : (
+
+ {reports.map((report) => {
+ const published = report.publishedAt;
+ return (
+
+
+
+ {report.title}
+ {report.kind === "primary" && (
+ {m.inspections_hub_reports_primary()}
+ )}
+
+ {published
+ ? m.inspections_hub_reports_status_published()
+ : m.inspections_hub_reports_status_in_progress()}
+
+
+
+ {published && m.inspections_hub_reports_published_on({ date: formatDate(published) })}
+ {published && report.versionCount > 0 && " · "}
+ {report.versionCount === 1 && m.inspections_hub_reports_versions_one()}
+ {report.versionCount > 1
+ && m.inspections_hub_reports_versions_other({ count: report.versionCount })}
+
+
+
+ {canManage && (
+ setDeleting(report)}
+ disabled={!report.canDelete || busy}
+ // Disabled controls give no title on hover in every
+ // browser, so the reason also rides `aria-label` —
+ // a greyed-out button that will not say why is the
+ // silent no-op this card exists to avoid.
+ title={blockedReason(report) ?? undefined}
+ aria-label={blockedReason(report) ?? undefined}
+ className="shrink-0 text-[12px] font-bold text-ih-fg-3 enabled:hover:text-ih-bad-fg enabled:hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ {m.inspections_hub_reports_delete()}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {error && {error}
}
+
+ {/* Names the report AND what is destroyed with it. A report is not a
+ row: it carries the content somebody filled in and its own
+ editing history, and none of it comes back. */}
+ setDeleting(null)}
+ onConfirm={() => {
+ if (!deleting) return;
+ deleteFetcher.submit(
+ { intent: "report-delete", reportId: deleting.id },
+ { method: "post" },
+ );
+ setDeleting(null);
+ }}
+ />
+
+ );
+}
+
+function blockedReason(report: ReportRow): string | null {
+ if (report.deleteBlockedReason === "primary") return m.inspections_hub_reports_blocked_primary();
+ if (report.deleteBlockedReason === "published") return m.inspections_hub_reports_blocked_published();
+ return null;
+}
diff --git a/app/components/inspector-portal/reports-card.test.tsx b/app/components/inspector-portal/reports-card.test.tsx
new file mode 100644
index 000000000..cfb061262
--- /dev/null
+++ b/app/components/inspector-portal/reports-card.test.tsx
@@ -0,0 +1,130 @@
+// @vitest-environment happy-dom
+/**
+ * The order's report list, and the one irreversible control on it.
+ *
+ * Two things are pinned. First, the delete confirmation NAMES what is lost:
+ * the report by title, and that the content already filled into it is
+ * destroyed. A generic "are you sure?" is the same dialog whether it is about
+ * to discard an empty draft or a day of somebody's fieldwork.
+ *
+ * Second, the card never re-derives who may delete what. `canDelete` and
+ * `deleteBlockedReason` come from the same server function the DELETE endpoint
+ * enforces, so a blocked row is disabled AND says why — the failure mode being
+ * guarded against is a button that looks live, does nothing, and explains
+ * nothing.
+ */
+import { describe, it, expect } from "vitest";
+import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
+import { createRoutesStub } from "react-router";
+
+import { ReportsCard, type ReportRow } from "~/components/inspector-portal/ReportsCard";
+
+const PRIMARY: ReportRow = {
+ id: "rep-primary", kind: "primary", title: "Inspection Report", status: "in_progress",
+ publishedAt: null, versionCount: 0, hasContent: false,
+ canDelete: false, deleteBlockedReason: "primary",
+};
+const SEWER: ReportRow = {
+ id: "rep-sewer", kind: "ancillary", title: "Sewer Scope", status: "in_progress",
+ publishedAt: null, versionCount: 0, hasContent: true,
+ canDelete: true, deleteBlockedReason: null,
+};
+const RADON: ReportRow = {
+ id: "rep-radon", kind: "ancillary", title: "Radon Testing", status: "published",
+ publishedAt: "2026-08-03T12:00:00.000Z", versionCount: 1, hasContent: false,
+ canDelete: false, deleteBlockedReason: "published",
+};
+
+function renderCard(reports: ReportRow[], canManage = true) {
+ const calls: Record[] = [];
+ const Stub = createRoutesStub([
+ {
+ path: "/hub",
+ Component: () => (
+ `on ${iso.slice(0, 10)}`} />
+ ),
+ action: async ({ request }) => {
+ const form = await request.formData();
+ calls.push(Object.fromEntries(form) as Record);
+ return { ok: true, intent: "report-delete", error: undefined };
+ },
+ },
+ ]);
+ render( );
+ return { calls };
+}
+
+const deleteButtonFor = (title: string) =>
+ screen.getAllByTestId("hub-report-row")
+ .find((row) => row.textContent?.includes(title))!
+ .querySelector("button")!;
+
+describe("ReportsCard", () => {
+ it("lists every deliverable on the order", () => {
+ renderCard([PRIMARY, SEWER, RADON]);
+ expect(screen.getAllByTestId("hub-report-row")).toHaveLength(3);
+ expect(screen.getByText("Sewer Scope")).toBeTruthy();
+ expect(screen.getByText("Radon Testing")).toBeTruthy();
+ });
+
+ it("names the report and what is destroyed with it", async () => {
+ renderCard([PRIMARY, SEWER]);
+ fireEvent.click(deleteButtonFor("Sewer Scope"));
+
+ const body = await screen.findByText(/Sewer Scope has information filled out in it/);
+ // The title alone is not "naming what is lost" — the sentence has to say
+ // the entered content goes, or the dialog is decoration.
+ expect(body.textContent).toMatch(/destroys that content/i);
+ expect(body.textContent).toMatch(/cannot be undone/i);
+ });
+
+ it("is honest when there is nothing filled in yet", async () => {
+ renderCard([PRIMARY, { ...SEWER, hasContent: false }]);
+ fireEvent.click(deleteButtonFor("Sewer Scope"));
+ expect(await screen.findByText(/has nothing filled out in it yet/)).toBeTruthy();
+ });
+
+ it("submits the delete only after the confirmation is accepted", async () => {
+ const { calls } = renderCard([PRIMARY, SEWER]);
+ fireEvent.click(deleteButtonFor("Sewer Scope"));
+ expect(calls, "opening the dialog already deleted the report").toHaveLength(0);
+
+ // Scoped to the dialog: the rows carry "Delete" buttons too, and a
+ // bare query that happened to grab a row button would pass while
+ // testing nothing about the confirmation.
+ fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Delete" }));
+ await waitFor(() => expect(calls).toHaveLength(1));
+ expect(calls[0]).toMatchObject({ intent: "report-delete", reportId: "rep-sewer" });
+ });
+
+ it("deletes nothing when the confirmation is cancelled", async () => {
+ const { calls } = renderCard([PRIMARY, SEWER]);
+ fireEvent.click(deleteButtonFor("Sewer Scope"));
+ fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Cancel" }));
+ await waitFor(() => expect(screen.queryByText(/cannot be undone/i)).toBeNull());
+ expect(calls).toHaveLength(0);
+ });
+
+ it("disables a blocked row AND says why", () => {
+ renderCard([PRIMARY, RADON]);
+
+ const primaryBtn = deleteButtonFor("Inspection Report");
+ expect(primaryBtn.hasAttribute("disabled")).toBe(true);
+ expect(primaryBtn.getAttribute("aria-label")).toMatch(/primary report cannot be deleted/i);
+
+ const radonBtn = deleteButtonFor("Radon Testing");
+ expect(radonBtn.hasAttribute("disabled")).toBe(true);
+ expect(radonBtn.getAttribute("aria-label")).toMatch(/published report cannot be deleted/i);
+ });
+
+ it("offers no delete control at all without the manage capability", () => {
+ renderCard([PRIMARY, SEWER], false);
+ expect(screen.queryByRole("button", { name: "Delete" })).toBeNull();
+ });
+
+ it("shows the empty state rather than an empty list", () => {
+ renderCard([]);
+ expect(screen.queryByTestId("hub-reports-list")).toBeNull();
+ expect(screen.getByText(/No reports on this order yet/)).toBeTruthy();
+ });
+});
diff --git a/app/lib/inspection-order-actions.ts b/app/lib/inspection-order-actions.ts
index 37c76ca23..bee7da349 100644
--- a/app/lib/inspection-order-actions.ts
+++ b/app/lib/inspection-order-actions.ts
@@ -163,6 +163,29 @@ export async function handleRelockReport(
return toActionResult(res, "relock-report", m.hub_gate_relock_failed());
}
+/**
+ * `report-delete` — destroy one deliverable and its document.
+ *
+ * The refusals (primary, published) are NOT re-checked here. They are enforced
+ * server-side and surfaced through the payload's `canDelete`, so this handler
+ * relays whatever the API says: a second copy of the rule in the browser is a
+ * copy that can disagree with the one that actually decides.
+ */
+export async function handleReportDelete(
+ api: Api,
+ inspectionId: string,
+ formData: FormData,
+): Promise<{ ok: boolean; intent: "report-delete"; error: string | undefined }> {
+ const reportId = String(formData.get("reportId") ?? "").trim();
+ if (!reportId) {
+ return { ok: false, intent: "report-delete", error: m.inspections_hub_error_report_delete() };
+ }
+ const res = await api.inspections[":id"].reports[":reportId"].$delete({
+ param: { id: inspectionId, reportId },
+ });
+ return toActionResult(res, "report-delete", m.inspections_hub_error_report_delete());
+}
+
/**
* A money field that was left blank means "no override", which is a different
* thing from zero — a free line is a real thing an operator may want. `''` and
diff --git a/app/routes/inspector-portal.tsx b/app/routes/inspector-portal.tsx
index 2985517f0..8b4437cf6 100644
--- a/app/routes/inspector-portal.tsx
+++ b/app/routes/inspector-portal.tsx
@@ -55,9 +55,11 @@ import {
handleServiceRemove,
handleUnlockReport,
handleRelockReport,
+ handleReportDelete,
} from "~/lib/inspection-order-actions";
import { ScheduleCard, type TeamMember } from "~/components/inspector-portal/ScheduleCard";
import { ServicesCard, type CatalogService } from "~/components/inspector-portal/ServicesCard";
+import { ReportsCard, type ReportRow } from "~/components/inspector-portal/ReportsCard";
import { OrderDetailsCard } from "~/components/inspector-portal/OrderDetailsCard";
import { InvoiceCard } from "~/components/inspector-portal/InvoiceCard";
import { GateToggle } from "~/components/inspector-portal/GateToggle";
@@ -129,6 +131,8 @@ interface HubData extends HubPayload {
priceOverride?: number | null;
}>;
agreements: Array<{ id: string; name: string }>;
+ // One order, several deliverables. Optional so an older payload still renders.
+ reports?: ReportRow[];
communication?: { delivered: number; needsAttention: number; unread: number; rulesActive: number };
}
@@ -320,6 +324,7 @@ export async function action({ request, params, context }: Route.ActionArgs) {
if (intent === "service-add") return handleServiceAdd(api, id, formData);
if (intent === "service-price") return handleServicePrice(api, id, formData);
if (intent === "service-remove") return handleServiceRemove(api, id, formData);
+ if (intent === "report-delete") return handleReportDelete(api, id, formData);
if (intent === "unlock-report") return handleUnlockReport(api, id, formData);
if (intent === "relock-report") return handleRelockReport(api, id);
@@ -806,6 +811,15 @@ export default function InspectionHubPage() {
price box. ------------------------------------------------- */}
+ {/* 3b. Reports — what gets DELIVERED. The order-wide report pill above
+ answers "is the report out"; with several deliverables on one order
+ that question no longer has one answer. ------------------- */}
+ formatInspectionDateTime(iso, undefined, displayTz, fmt)}
+ />
+
{/* 4. Signing requests — the paperwork the visit needs -------- */}
diff --git a/messages/en/inspections.json b/messages/en/inspections.json
index de7a24dca..13a603aee 100644
--- a/messages/en/inspections.json
+++ b/messages/en/inspections.json
@@ -116,6 +116,21 @@
"inspections_hub_services_remove": "Remove",
"inspections_hub_services_remove_title": "Remove this service?",
"inspections_hub_services_remove_body": "{name} will no longer be billed on this inspection. The service stays in your catalog.",
+ "inspections_hub_block_reports": "Reports",
+ "inspections_hub_reports_empty": "No reports on this order yet. They are generated from the services sold, at the point the work is scheduled to begin.",
+ "inspections_hub_reports_primary": "Primary",
+ "inspections_hub_reports_status_in_progress": "In progress",
+ "inspections_hub_reports_status_published": "Published",
+ "inspections_hub_reports_published_on": "Published {date}",
+ "inspections_hub_reports_versions_one": "1 signed version",
+ "inspections_hub_reports_versions_other": "{count} signed versions",
+ "inspections_hub_reports_delete": "Delete",
+ "inspections_hub_reports_delete_title": "Delete this report?",
+ "inspections_hub_reports_delete_filled": "{name} has information filled out in it. Deleting the report destroys that content — its findings, notes and photos — along with its editing history. The service line stays on the order, so what the client is billed does not change. This cannot be undone.",
+ "inspections_hub_reports_delete_empty": "{name} has nothing filled out in it yet. Deleting it removes the report and its document from this order. The service line stays, so what the client is billed does not change. This cannot be undone.",
+ "inspections_hub_reports_blocked_primary": "The primary report cannot be deleted — every order keeps one, and without it the order cannot be edited.",
+ "inspections_hub_reports_blocked_published": "A published report cannot be deleted — it has been delivered, and its signed versions are what let a client verify the document they hold.",
+ "inspections_hub_error_report_delete": "Could not delete the report.",
"inspections_hub_block_details": "Order details",
"inspections_hub_details_edit": "Edit details",
"inspections_hub_details_reference": "Reference number",
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 7dc2df097..fbb81d24b 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -1,6 +1,6 @@
{
"app/routes/inspection-edit.tsx": 2530,
- "app/routes/inspector-portal.tsx": 1204,
+ "app/routes/inspector-portal.tsx": 1218,
"server/services/inspection/inspection-core.service.ts": 1132,
"server/services/booking.service.ts": 972,
"server/services/inspection/inspection-report.service.ts": 952,
@@ -10,15 +10,15 @@
"server/api/sms.ts": 843,
"app/components/portal/sections/ReportView.tsx": 813,
"app/routes/settings-communication.tsx": 777,
- "server/services/inspection.service.ts": 774,
"server/api/admin/admin-settings.ts": 742,
+ "server/services/inspection.service.ts": 741,
"server/api/inspections/report-delivery.ts": 736,
"app/routes/settings-communication-templates.tsx": 731,
"server/services/inspection/inspection-analytics.service.ts": 729,
"app/routes/template-edit.tsx": 719,
"server/index.ts": 693,
"app/components/media-studio/PhotoAnnotator.tsx": 692,
- "server/services/inspection/inspection-publish.service.ts": 675,
+ "server/services/inspection/inspection-publish.service.ts": 680,
"app/hooks/usePhotoOps.ts": 661,
"server/lib/messaging/providers/telnyx-compliance.ts": 657,
"app/components/editor/ItemEditor.tsx": 637,
diff --git a/server/api/inspections.ts b/server/api/inspections.ts
index b17dd11ca..47301b8bd 100644
--- a/server/api/inspections.ts
+++ b/server/api/inspections.ts
@@ -39,6 +39,7 @@ import complianceRoutes from './inspections/compliance';
import peopleRoutes from './inspections/people';
import communicationRoutes from './inspections/communication';
import inspectionServiceRoutes from './inspections/services';
+import inspectionReportRoutes from './inspections/reports';
export const inspectionsRoutes = createApiRouter()
.route('/', bulkRoutes)
@@ -69,6 +70,9 @@ export const inspectionsRoutes = createApiRouter()
.route('/', peopleRoutes)
// IA-87 — POST/PATCH/DELETE /:id/services: the service lines on an
// inspection were write-once at creation until this router existed.
- .route('/', inspectionServiceRoutes);
+ .route('/', inspectionServiceRoutes)
+ // DELETE /:id/reports/:reportId — one order delivers several reports, and
+ // removing one destroys its document. The list itself rides the hub payload.
+ .route('/', inspectionReportRoutes);
export type InspectionsApi = typeof inspectionsRoutes;
diff --git a/server/api/inspections/reports.ts b/server/api/inspections/reports.ts
new file mode 100644
index 000000000..3482cb030
--- /dev/null
+++ b/server/api/inspections/reports.ts
@@ -0,0 +1,48 @@
+// The write face for the `reports` entity — currently one verb, and the only
+// irreversible one in per-deliverable delivery.
+//
+// Reading the list is not here on purpose: the order page already fetches one
+// aggregate payload (`GET /{id}/hub`) and reports are part of what that page
+// is, so a second round trip would buy nothing but a second thing to keep in
+// sync. Adding a report by hand is the exception path named in the design and
+// has no endpoint yet — reports are GENERATED from the sold service lines.
+//
+// Auth is owner/manager, matching the sibling `services` router rather than the
+// people routes: this destroys a document somebody may have spent a day filling
+// in, which is not an inspector's call to make alone.
+import { createRoute, z } from '@hono/zod-openapi';
+import { createApiRouter } from '../../lib/openapi-router';
+import { requireRole } from '../../lib/middleware/rbac';
+import { getDrizzle, getTenantId } from '../../lib/route-helpers';
+import { deleteReport } from '../../lib/inspection/reports';
+import { SuccessResponseSchema } from '../../lib/validations/shared.schema';
+import { withMcpMetadata } from '../../lib/route-metadata-standards';
+
+const ReportParam = z.object({
+ id: z.string().min(1).describe('Inspection the report belongs to.'),
+ reportId: z.string().min(1).describe('reports.id of the deliverable to delete.'),
+});
+
+const inspectionReportRoutes = createApiRouter()
+ // DELETE /api/inspections/:id/reports/:reportId
+ .openapi(createRoute(withMcpMetadata({
+ method: 'delete', path: '/{id}/reports/{reportId}',
+ tags: ['inspections'],
+ summary: 'Delete one deliverable from an inspection',
+ middleware: [requireRole('owner', 'manager')] as const,
+ request: { params: ReportParam },
+ responses: {
+ 200: { content: { 'application/json': { schema: SuccessResponseSchema } }, description: 'Report and its document deleted' },
+ 404: { description: 'Report not found on this inspection in this tenant' },
+ 409: { description: 'Refused: the report is the primary one, or it has been published' },
+ },
+ operationId: 'deleteInspectionReport',
+ description: 'Permanently deletes one report and everything belonging only to it — its findings document, the collaborative Yjs state, and its version rows. The billing line that produced it is untouched. Refused for the primary report (every order keeps one; without it the order cannot be edited) and for a published report (it has been delivered and its signed versions are what let a client verify what they hold).',
+ }, { scopes: ['write'], tier: 'primary' })), async (c) => {
+ const tenantId = getTenantId(c);
+ const { id, reportId } = c.req.valid('param');
+ await deleteReport(getDrizzle(c), tenantId, id, reportId);
+ return c.json({ success: true });
+ });
+
+export default inspectionReportRoutes;
diff --git a/server/lib/inspection/reports.ts b/server/lib/inspection/reports.ts
index f1c0e2316..0fa4c81ed 100644
--- a/server/lib/inspection/reports.ts
+++ b/server/lib/inspection/reports.ts
@@ -6,11 +6,13 @@
* WHICH. This module is where callers that still address things by inspection
* resolve that.
*/
-import { and, asc, eq } from 'drizzle-orm';
+import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import type { DrizzleD1Database } from 'drizzle-orm/d1';
-import { reports } from '../db/schema';
+import { inspectionResults, reports, reportVersions } from '../db/schema';
+import { Errors } from '../errors';
import { logger } from '../logger';
-import { REPORT_STATUS } from '../status/report-status';
+import { safeISODate } from '../date';
+import { REPORT_STATUS, isReportPublished } from '../status/report-status';
/**
* Every deliverable on one order, in the order a person would read them.
@@ -107,3 +109,155 @@ export async function createPrimaryReport(
return null;
}
}
+
+/**
+ * Why a report may not be deleted, or null when it may.
+ *
+ * ONE function, because whether an actor may do something is decided where it
+ * is ENFORCED and merely read by the UI — a page that re-derives the rule can
+ * offer a button the API refuses. The delete endpoint and the hub payload both
+ * call this.
+ *
+ * - `primary`: every order must keep one. The collab route resolves an
+ * inspection to its primary and fails CLOSED without one, so deleting it does
+ * not remove a document — it makes the whole order uneditable.
+ * - `published`: it has been delivered. A published report owns `report_versions`
+ * rows carrying `content_hash`/`prev_hash`/`signature` — tamper-evidence a
+ * client can check through the public verifier — and a link somebody already
+ * holds. Deleting that is not "removing a draft", it is destroying the
+ * evidence that the delivered document is the one we signed.
+ */
+export type ReportDeleteBlock = 'primary' | 'published';
+
+export function reportDeleteBlock(
+ report: Pick,
+): ReportDeleteBlock | null {
+ if (report.kind === 'primary') return 'primary';
+ if (isReportPublished(report.status)) return 'published';
+ return null;
+}
+
+/** One deliverable, shaped for the order page's report list. */
+export interface ReportListItem {
+ id: string;
+ kind: 'primary' | 'ancillary';
+ title: string;
+ status: string;
+ publishedAt: string | null;
+ /** Published versions this report owns — part of what a delete would destroy. */
+ versionCount: number;
+ /** True when its document has been written into: the "information you already filled out". */
+ hasContent: boolean;
+ canDelete: boolean;
+ deleteBlockedReason: ReportDeleteBlock | null;
+}
+
+/**
+ * The order's deliverables, with everything the list and its delete
+ * confirmation need — and nothing else.
+ *
+ * Deliberately projects columns rather than `select()`: `inspection_results`
+ * carries `ydoc_state`, a Yjs binary blob per report, and an aggregate payload
+ * that drags several of those across the wire to decide whether to render a
+ * bullet is a page that gets slower with every report sold.
+ */
+export async function listReportsForHub(
+ db: DrizzleD1Database,
+ tenantId: string,
+ inspectionId: string,
+): Promise {
+ const rows = await listReports(db, tenantId, inspectionId);
+ if (rows.length === 0) return [];
+ const ids = rows.map((r) => r.id);
+
+ const versionRows = await db.select({
+ reportId: reportVersions.reportId,
+ count: sql`count(*)`,
+ }).from(reportVersions)
+ .where(and(eq(reportVersions.tenantId, tenantId), inArray(reportVersions.reportId, ids)))
+ .groupBy(reportVersions.reportId)
+ .all();
+ const versionCounts = new Map(versionRows.map((v) => [v.reportId, Number(v.count)]));
+
+ // `data` is the projected findings map, small next to `ydoc_state`; its
+ // emptiness is what "you already filled this out" actually means.
+ const resultRows = await db.select({
+ reportId: inspectionResults.reportId,
+ data: inspectionResults.data,
+ }).from(inspectionResults)
+ .where(and(eq(inspectionResults.tenantId, tenantId), inArray(inspectionResults.reportId, ids)))
+ .all();
+ const written = new Set(
+ resultRows
+ .filter((r) => {
+ const parsed = typeof r.data === 'string' ? JSON.parse(r.data) as unknown : r.data;
+ return !!parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0;
+ })
+ .map((r) => r.reportId),
+ );
+
+ return rows.map((r) => {
+ const blocked = reportDeleteBlock(r);
+ return {
+ id: r.id,
+ kind: r.kind,
+ title: r.title,
+ status: r.status,
+ publishedAt: safeISODate(r.publishedAt) ?? null,
+ versionCount: versionCounts.get(r.id) ?? 0,
+ hasContent: written.has(r.id),
+ canDelete: blocked === null,
+ deleteBlockedReason: blocked,
+ };
+ });
+}
+
+/**
+ * Delete one report and everything that belongs only to it.
+ *
+ * The one irreversible action in this feature. A report is not a row: it owns
+ * an `inspection_results` document (the findings AND the Yjs state two people
+ * may have been typing into) and its own `report_versions` chain. There are no
+ * foreign keys by policy, so delete-ordering is this function's responsibility
+ * and nothing else will notice the orphans it leaves.
+ *
+ * The BILLING LINE stays. `inspection_services` is what the client was charged
+ * for; deleting the deliverable does not un-sell the work, and the invoice is
+ * authoritative over the line sum regardless.
+ */
+export async function deleteReport(
+ db: DrizzleD1Database,
+ tenantId: string,
+ inspectionId: string,
+ reportId: string,
+): Promise {
+ const row = await db.select().from(reports)
+ .where(and(
+ eq(reports.id, reportId),
+ eq(reports.tenantId, tenantId),
+ eq(reports.inspectionId, inspectionId),
+ ))
+ .get();
+ if (!row) throw Errors.NotFound('Report not found');
+
+ const blocked = reportDeleteBlock(row);
+ if (blocked === 'primary') {
+ throw Errors.Conflict(
+ 'The primary report cannot be deleted. Every order keeps one, and without it the order cannot be edited at all.',
+ );
+ }
+ if (blocked === 'published') {
+ throw Errors.Conflict(
+ 'A published report cannot be deleted. It has been delivered, and its signed versions are what let a client verify the document they hold.',
+ );
+ }
+
+ await db.delete(inspectionResults)
+ .where(and(eq(inspectionResults.tenantId, tenantId), eq(inspectionResults.reportId, reportId)));
+ await db.delete(reportVersions)
+ .where(and(eq(reportVersions.tenantId, tenantId), eq(reportVersions.reportId, reportId)));
+ await db.delete(reports)
+ .where(and(eq(reports.id, reportId), eq(reports.tenantId, tenantId)));
+
+ logger.info('report deleted', { inspectionId, reportId, title: row.title });
+}
diff --git a/server/lib/validations/inspection/read.ts b/server/lib/validations/inspection/read.ts
index 1ad351d7c..d6ee33bd4 100644
--- a/server/lib/validations/inspection/read.ts
+++ b/server/lib/validations/inspection/read.ts
@@ -197,6 +197,17 @@ export const InspectionHubSchema = z.object({
ready: z.boolean().describe('True when every required defect field is filled'),
blockingCount: z.number().describe('Count of defects blocking publish'),
}).describe('Report-status gate summary (reuses computePublishReadiness)'),
+ reports: z.array(z.object({
+ id: z.string().describe('reports.id'),
+ kind: z.enum(['primary', 'ancillary']).describe("'primary' is the one a client means by \"my report\"; there is exactly one."),
+ title: z.string().describe('Report title, snapshotted from the service line that produced it'),
+ status: z.string().describe('in_progress | published'),
+ publishedAt: z.string().nullable().describe('ISO instant THIS deliverable went out; null while unpublished'),
+ versionCount: z.number().describe('Signed versions this report owns — part of what deleting it would destroy'),
+ hasContent: z.boolean().describe('Whether its document has been written into ("information you already filled out")'),
+ canDelete: z.boolean().describe('Decided server-side by the same function the DELETE endpoint enforces, so the UI cannot offer an action the API refuses'),
+ deleteBlockedReason: z.enum(['primary', 'published']).nullable().describe('Why deletion is refused, for the disabled control reason; null when it is allowed'),
+ })).describe("The order's deliverables. One order, several reports — each with its own document, signature chain and notification."),
communication: z.object({
delivered: z.number().describe('Platform notices delivered (status sent, due rows only).'),
needsAttention: z.number().describe('Platform notices skipped or failed — the count that auto-expands the block.'),
diff --git a/server/services/inspection.service.ts b/server/services/inspection.service.ts
index 025004669..9c7ab5a48 100644
--- a/server/services/inspection.service.ts
+++ b/server/services/inspection.service.ts
@@ -499,50 +499,17 @@ export class InspectionService {
* tenantId. `tenantSlug` is passed through verbatim for building
* `/report/:tenantSlug/:id` style links on the page.
*/
- async getInspectionHub(inspectionId: string, tenantId: string, tenantSlug: string): Promise<{
- inspection: {
- id: string;
- propertyAddress: string;
- clientName: string | null;
- clientEmail: string | null;
- clientPhone: string | null;
- clientContactId: string | null;
- status: string;
- reportStatus: string;
- date: string | null;
- inspectorId: string | null;
- templateId: string | null;
- price: number;
- paymentStatus: string;
- paymentRequired: boolean;
- agreementRequired: boolean;
- coverPhoto: string | null;
- referredByAgentId: string | null;
- sellingAgentId: string | null;
- createdAt: string | null;
- };
- tenantSlug: string;
- people: Awaited>;
- services: Array<{ id: string; name: string; priceCents: number }>;
- agreements: Array<{ id: string; name: string }>;
- agreementRequests: Array<{
- id: string;
- status: string;
- clientEmail: string;
- signedAt: string | null;
- createdAt: string | null;
- agreementName: string | null;
- signersTotal: number;
- signersSigned: number;
- }>;
- invoice: {
- id: string; status: string; amountCents: number;
- /** Cumulative amount received; null when partial with no recorded figure. */
- amountPaidCents: number | null;
- currency: string; sentAt: string | null; paidAt: string | null;
- } | null;
- publishReadiness: { ready: boolean; blockingCount: number };
- } | null> {
+ async getInspectionHub(
+ inspectionId: string,
+ tenantId: string,
+ tenantSlug: string,
+ ): Promise>> {
+ // DERIVED, never re-declared. This signature used to be a hand-copy of
+ // the delegate's, and it rotted exactly the way a hand-copy does: it
+ // was still promising a `services` array of `{ id, name, priceCents }`
+ // and no `communication`, `unlockedAt` or `reports` long after the
+ // delegate returned all of them — so callers typed against the facade
+ // could not see fields the endpoint had been sending for months.
return this.publish.getInspectionHub(inspectionId, tenantId, tenantSlug);
}
diff --git a/server/services/inspection/inspection-publish.service.ts b/server/services/inspection/inspection-publish.service.ts
index e2777bd67..e45aae68f 100644
--- a/server/services/inspection/inspection-publish.service.ts
+++ b/server/services/inspection/inspection-publish.service.ts
@@ -26,6 +26,7 @@ import {
type PublishReadiness,
} from './shared';
import { communicationCounts } from '../../lib/communication-counts';
+import { listReportsForHub, type ReportListItem } from '../../lib/inspection/reports';
import { InspectionSubService } from './base';
import { CredentialService } from '../credential.service';
import type { InspectionService } from '../inspection.service';
@@ -326,6 +327,8 @@ export class InspectionPublishService extends InspectionSubService {
} | null;
publishReadiness: { ready: boolean; blockingCount: number };
communication: { delivered: number; needsAttention: number; unread: number };
+ /** The order's deliverables. One order, several reports. */
+ reports: ReportListItem[];
} | null> {
const db = this.getDrizzle();
@@ -435,6 +438,7 @@ export class InspectionPublishService extends InspectionSubService {
]);
const communication = await communicationCounts(db, tenantId, inspectionId);
+ const reportList = await listReportsForHub(db, tenantId, inspectionId);
// Task 8 — resolve the referrer's display name for the Order details
// card. Soft reference: a deleted contact resolves null, and the card
@@ -522,6 +526,7 @@ export class InspectionPublishService extends InspectionSubService {
blockingCount: readiness.blockingDefects.length,
},
communication,
+ reports: reportList,
};
}
diff --git a/tests/unit/inspections/report-delete.spec.ts b/tests/unit/inspections/report-delete.spec.ts
new file mode 100644
index 000000000..28cdd45d5
--- /dev/null
+++ b/tests/unit/inspections/report-delete.spec.ts
@@ -0,0 +1,195 @@
+/**
+ * Deleting a report is the one irreversible action in per-deliverable delivery.
+ *
+ * A report is not a row. It owns an `inspection_results` document — the
+ * findings AND the Yjs state two people may have been typing into — and its own
+ * `report_versions` chain. There are no foreign keys by policy, so nothing in
+ * the database notices the orphans a naive delete leaves, and nothing notices a
+ * delete that reaches into a SIBLING report's document either.
+ *
+ * The two refusals are the load-bearing part. The primary report is what the
+ * collab route resolves an inspection to, failing closed without one, so
+ * deleting it does not remove a document — it makes the order uneditable. A
+ * published report has been delivered and its signed versions are what let a
+ * client verify the document they hold.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { eq } from 'drizzle-orm';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import type { DrizzleD1Database } from 'drizzle-orm/d1';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import { deleteReport, listReportsForHub, reportDeleteBlock } from '../../../server/lib/inspection/reports';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+
+const TENANT = '00000000-0000-0000-0000-0000000000d1';
+const INSPECTION = 'insp-delete';
+
+let db: BetterSQLite3Database;
+const asD1 = () => db as unknown as DrizzleD1Database;
+
+beforeEach(async () => {
+ const fx = createTestDb();
+ db = fx.db;
+ await setupSchema(fx.sqlite);
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Delete Co', slug: 'delete-co', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ } as never);
+ await db.insert(schema.inspections).values({
+ id: INSPECTION, tenantId: TENANT, propertyAddress: '9 Deliverable Way',
+ date: '2026-08-04', status: 'scheduled', reportStatus: 'in_progress',
+ paymentStatus: 'unpaid', price: 0, agreementRequired: false, paymentRequired: false,
+ createdAt: new Date(),
+ } as never);
+});
+
+/** A report plus the document that belongs to it, bound on `report_id`. */
+async function seedReport(
+ id: string,
+ over: Partial = {},
+ data: Record = {},
+) {
+ await db.insert(schema.reports).values({
+ id, tenantId: TENANT, inspectionId: INSPECTION, kind: 'ancillary',
+ title: id, status: 'in_progress', createdAt: new Date(), sortOrder: 0, ...over,
+ } as never);
+ await db.insert(schema.inspectionResults).values({
+ id: `res-${id}`, tenantId: TENANT, inspectionId: INSPECTION, reportId: id,
+ data, lastSyncedAt: new Date(),
+ } as never);
+ return id;
+}
+
+describe('deleting a report', () => {
+ it('removes the report and the document that belongs only to it', async () => {
+ await seedReport('rep-primary', { kind: 'primary', title: 'Inspection Report' });
+ await seedReport('rep-sewer', { title: 'Sewer Scope' }, { 'item-1': { rating: 'defect' } });
+
+ await deleteReport(asD1(), TENANT, INSPECTION, 'rep-sewer');
+
+ const remaining = await db.select().from(schema.reports).all();
+ expect(remaining.map((r) => r.id)).toEqual(['rep-primary']);
+ const results = await db.select().from(schema.inspectionResults).all();
+ expect(
+ results.map((r) => r.reportId),
+ 'the deleted report left its document behind, bound to nothing',
+ ).toEqual(['rep-primary']);
+ });
+
+ it('leaves a sibling report document untouched', async () => {
+ // The reason `inspection_results.report_id` has to be bound at creation.
+ // A delete keyed on inspection_id alone takes every document on the
+ // order with it, and NOTHING reports an error — the sewer report simply
+ // opens empty the next time somebody looks at it.
+ await seedReport('rep-primary', { kind: 'primary' }, { 'item-a': { rating: 'ok' } });
+ await seedReport('rep-radon', { title: 'Radon Testing' });
+
+ await deleteReport(asD1(), TENANT, INSPECTION, 'rep-radon');
+
+ const survivor = await db.select().from(schema.inspectionResults)
+ .where(eq(schema.inspectionResults.reportId, 'rep-primary')).get();
+ expect(survivor, "the primary report's document was collateral damage").toBeTruthy();
+ const data = typeof survivor!.data === 'string'
+ ? JSON.parse(survivor!.data) as Record
+ : survivor!.data as Record;
+ expect(Object.keys(data)).toEqual(['item-a']);
+ });
+
+ it('takes its version chain with it, leaving no orphans', async () => {
+ await seedReport('rep-primary', { kind: 'primary' });
+ await seedReport('rep-sewer');
+ await db.insert(schema.reportVersions).values({
+ id: 'ver-1', tenantId: TENANT, inspectionId: INSPECTION, reportId: 'rep-sewer',
+ versionNumber: 1, snapshotJson: '{}', publishedAt: new Date(), publishedBy: 'u1',
+ createdAt: new Date(),
+ } as never);
+
+ await deleteReport(asD1(), TENANT, INSPECTION, 'rep-sewer');
+
+ expect(await db.select().from(schema.reportVersions).all()).toHaveLength(0);
+ });
+
+ it('refuses the primary report — the order would become uneditable', async () => {
+ await seedReport('rep-primary', { kind: 'primary' });
+ await expect(deleteReport(asD1(), TENANT, INSPECTION, 'rep-primary'))
+ .rejects.toThrow(/primary report cannot be deleted/i);
+ expect(await db.select().from(schema.reports).all()).toHaveLength(1);
+ });
+
+ it('refuses a published report — it has been delivered and signed', async () => {
+ await seedReport('rep-primary', { kind: 'primary' });
+ await seedReport('rep-radon', { title: 'Radon Testing', status: 'published', publishedAt: new Date() });
+
+ await expect(deleteReport(asD1(), TENANT, INSPECTION, 'rep-radon'))
+ .rejects.toThrow(/published report cannot be deleted/i);
+ expect(await db.select().from(schema.reports).all()).toHaveLength(2);
+ });
+
+ it('leaves the billing line that produced it alone', async () => {
+ // Deleting the deliverable does not un-sell the work, and the invoice is
+ // authoritative over the line sum regardless.
+ await db.insert(schema.services).values({
+ id: 'svc-sewer', tenantId: TENANT, name: 'Sewer Scope', price: 20000,
+ active: true, sortOrder: 1, createdAt: new Date(),
+ } as never);
+ await db.insert(schema.inspectionServices).values({
+ id: 'line-sewer', tenantId: TENANT, inspectionId: INSPECTION, serviceId: 'svc-sewer',
+ nameSnapshot: 'Sewer Scope', priceSnapshot: 20000, active: true,
+ } as never);
+ await seedReport('rep-primary', { kind: 'primary' });
+ await seedReport('rep-sewer', { inspectionServiceId: 'line-sewer' });
+
+ await deleteReport(asD1(), TENANT, INSPECTION, 'rep-sewer');
+
+ const lines = await db.select().from(schema.inspectionServices).all();
+ expect(lines).toHaveLength(1);
+ expect(lines[0]!.active).toBe(true);
+ });
+
+ it('404s for a report belonging to another inspection', async () => {
+ await seedReport('rep-primary', { kind: 'primary' });
+ await expect(deleteReport(asD1(), TENANT, 'some-other-inspection', 'rep-primary'))
+ .rejects.toThrow(/not found/i);
+ });
+});
+
+describe('what the list tells the UI', () => {
+ it('answers canDelete with the same rule the endpoint enforces', async () => {
+ await seedReport('rep-primary', { kind: 'primary', title: 'Inspection Report', sortOrder: 0 });
+ await seedReport('rep-sewer', { title: 'Sewer Scope', sortOrder: 1 }, { 'item-1': {} });
+ await seedReport('rep-radon', { title: 'Radon Testing', sortOrder: 2, status: 'published', publishedAt: new Date() });
+
+ const list = await listReportsForHub(asD1(), TENANT, INSPECTION);
+
+ expect(list.map((r) => r.title)).toEqual(['Inspection Report', 'Sewer Scope', 'Radon Testing']);
+ expect(list.map((r) => r.canDelete)).toEqual([false, true, false]);
+ expect(list.map((r) => r.deleteBlockedReason)).toEqual(['primary', null, 'published']);
+ // "Information you already filled out" is the phrase the confirmation
+ // has to be honest about, so it comes from the document, not a guess.
+ expect(list.map((r) => r.hasContent)).toEqual([false, true, false]);
+ });
+
+ it('counts the signed versions a delete would destroy', async () => {
+ await seedReport('rep-primary', { kind: 'primary' });
+ await seedReport('rep-sewer');
+ for (const n of [1, 2]) {
+ await db.insert(schema.reportVersions).values({
+ id: `ver-${n}`, tenantId: TENANT, inspectionId: INSPECTION, reportId: 'rep-sewer',
+ versionNumber: n, snapshotJson: '{}', publishedAt: new Date(), publishedBy: 'u1',
+ createdAt: new Date(),
+ } as never);
+ }
+
+ const list = await listReportsForHub(asD1(), TENANT, INSPECTION);
+ expect(list.find((r) => r.id === 'rep-sewer')!.versionCount).toBe(2);
+ expect(list.find((r) => r.id === 'rep-primary')!.versionCount).toBe(0);
+ });
+
+ it('is the single source of the rule — the pure predicate agrees', async () => {
+ expect(reportDeleteBlock({ kind: 'primary', status: 'in_progress' })).toBe('primary');
+ expect(reportDeleteBlock({ kind: 'ancillary', status: 'published' })).toBe('published');
+ expect(reportDeleteBlock({ kind: 'ancillary', status: 'in_progress' })).toBeNull();
+ });
+});
From 5e0c7d1442e0b2a41746b4aca651e4273c0a88e5 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:16:46 +0800
Subject: [PATCH 027/111] i18n(es-419): translate editor.json (135 keys)
---
messages/es-419/editor.json | 137 +++++++++++++++++++++++++++++++++++-
1 file changed, 136 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/editor.json b/messages/es-419/editor.json
index 006f618aa..ad89d5d0c 100644
--- a/messages/es-419/editor.json
+++ b/messages/es-419/editor.json
@@ -1,3 +1,138 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "editor_additem_title": "Agregar elemento",
+ "editor_additem_type_rich": "Calificación + comentarios",
+ "editor_additem_type_boolean": "Sí / No",
+ "editor_additem_type_text": "Texto corto",
+ "editor_additem_type_textarea": "Texto largo",
+ "editor_additem_type_number": "Número",
+ "editor_additem_type_select": "Opción única",
+ "editor_additem_type_multi_select": "Opción múltiple",
+ "editor_additem_type_date": "Fecha",
+ "editor_additem_type_photo_only": "Solo fotos",
+ "editor_additem_label_label": "Etiqueta",
+ "editor_additem_label_placeholder": "Nuevo elemento",
+ "editor_additem_type_label": "Tipo",
+ "editor_addmedia_title": "Agregar multimedia",
+ "editor_addmedia_take_photo": "Tomar foto",
+ "editor_addmedia_add_from_library": "Agregar desde la biblioteca",
+ "editor_addmedia_video_offline_title": "La carga de video requiere conexión",
+ "editor_addmedia_video": "Video",
+ "editor_addmedia_requires_connection": "Requiere conexión",
+ "editor_addsection_title": "Agregar sección",
+ "editor_addsection_placeholder": "Título de la sección (p. ej. Techo)",
+ "editor_batch_selected_count": "{count} seleccionados",
+ "editor_batch_select_all": "Seleccionar todo",
+ "editor_batch_set_rating_aria": "Asignar calificación a los elementos seleccionados",
+ "editor_batch_exit": "Salir",
+ "editor_scope_common": "Común",
+ "editor_scope_switch_aria": "Alcance de la inspección: {label}. Cambiar alcance",
+ "editor_scope_switch_title": "Cambiar el alcance de la inspección",
+ "editor_scope_listbox_aria": "Alcance de la inspección",
+ "editor_scope_common_hint": "Áreas compartidas / comunes",
+ "editor_scope_common_area_hint": "Área común",
+ "editor_burst_camera_aria": "Cámara en ráfaga",
+ "editor_burst_close_aria": "Cerrar cámara",
+ "editor_burst_captured_count": "{count} capturadas",
+ "editor_burst_switch_camera_aria": "Cambiar de cámara",
+ "editor_burst_captured_frame_alt": "Fotograma capturado",
+ "editor_burst_discard_frame_aria": "Descartar este fotograma",
+ "editor_burst_discard_all": "Descartar todo",
+ "editor_burst_capture_aria": "Capturar (toque para una sola, mantenga para ráfaga)",
+ "editor_burst_progress": "{count} / 30",
+ "editor_burst_shoot": "Disparar",
+ "editor_burst_uploading": "Subiendo...",
+ "editor_canned_search_placeholder": "Buscar defectos…",
+ "editor_canned_search_aria": "Buscar defectos",
+ "editor_canned_no_match": "Ningún defecto coincide con “{query}” — agréguelo como defecto personalizado más abajo.",
+ "editor_canned_no_prebuilt": "No hay comentarios predefinidos para esta pestaña.",
+ "editor_canned_from_library": "De su biblioteca",
+ "editor_canned_any_severity": "cualquier gravedad",
+ "editor_canned_tap_to_use": "toque para usar como defecto personalizado",
+ "editor_canned_custom_badge": "agregado por el inspector",
+ "editor_canned_add_custom": "+ Agregar defecto personalizado",
+ "editor_clone_scope_rating": "Solo calificación",
+ "editor_clone_scope_rating_notes": "Calificación + notas",
+ "editor_clone_scope_all": "Todo",
+ "editor_clone_last": "Clonar el anterior",
+ "editor_comment_library_title": "Biblioteca de comentarios",
+ "editor_comment_library_filter_label": "Filtro",
+ "editor_comment_library_filter_auto": "Auto",
+ "editor_comment_library_all": "Todo",
+ "editor_comment_library_sort_label": "Ordenar",
+ "editor_comment_library_sort_relevance": "Relevancia",
+ "editor_comment_library_sort_recent": "Uso reciente",
+ "editor_comment_library_sort_created": "Agregados recientemente",
+ "editor_comment_library_sort_frequent": "Más usadas",
+ "editor_comment_library_sort_alpha": "A–Z",
+ "editor_comment_library_context": "Contexto:",
+ "editor_comment_library_clear_filter_aria": "Borrar filtro",
+ "editor_comment_library_sev_satisfactory": "Satisfactorio",
+ "editor_comment_library_sev_monitor": "Vigilar",
+ "editor_comment_library_sev_defect": "Defecto",
+ "editor_comment_library_sev_my_snippets": "Mis fragmentos",
+ "editor_comment_library_sev_aria": "Filtro de gravedad de comentarios",
+ "editor_comment_library_search_placeholder": "Buscar comentarios...",
+ "editor_comment_library_count": "{count} comentarios",
+ "editor_comment_list_empty": "Ningún comentario coincide con el filtro actual.",
+ "editor_typeahead_kind_defect": "Defecto",
+ "editor_typeahead_kind_info": "Información",
+ "editor_typeahead_kind_limitations": "Limitación",
+ "editor_commercial_subtype_office": "Oficina",
+ "editor_commercial_subtype_retail": "Comercio minorista",
+ "editor_commercial_subtype_hospitality": "Hotelería",
+ "editor_commercial_subtype_industrial": "Industrial",
+ "editor_commercial_subtype_institutional": "Institucional",
+ "editor_commercial_subtype_mixed_use": "Uso mixto",
+ "editor_commercial_tier_light": "Comercial ligero",
+ "editor_commercial_tier_full": "PCA completo",
+ "editor_commercial_subtype_label": "Subtipo comercial",
+ "editor_commercial_saving": "(guardando…)",
+ "editor_commercial_subtype_placeholder": "Seleccione el subtipo…",
+ "editor_commercial_tier_label": "Nivel del informe",
+ "editor_commercial_pca_description": "El PCA completo agrega la carta de remisión ASTM E2018, dos tablas de costos, la aprobación del revisor y el apéndice fotográfico.",
+ "editor_cost_items_title": "Elementos de costo",
+ "editor_cost_action_repair": "Reparar",
+ "editor_cost_action_replace": "Reemplazar",
+ "editor_cost_action_further_study": "Estudio adicional",
+ "editor_cost_method_lump_sum": "Suma global",
+ "editor_cost_method_unit": "Costo unitario",
+ "editor_cost_bucket_immediate": "Inmediato (0–1 años)",
+ "editor_cost_bucket_short_term": "Corto plazo (1–5 años)",
+ "editor_cost_bucket_long_term": "Largo plazo (reserva)",
+ "editor_cost_add_item": "+ Agregar elemento de costo",
+ "editor_cost_total_immediate": "Inmediato",
+ "editor_cost_total_short_term": "Corto plazo",
+ "editor_cost_total_long_term": "Largo plazo",
+ "editor_cost_running_total": "Total acumulado",
+ "editor_cost_empty": "Aún no hay nada registrado — agregue una línea para iniciar la Opinión de Costo.",
+ "editor_cost_field_system": "Sistema",
+ "editor_cost_field_component": "Componente",
+ "editor_cost_field_location": "Ubicación",
+ "editor_cost_field_action": "Acción",
+ "editor_cost_field_cost_method": "Método de costo",
+ "editor_cost_field_qty": "Cant.",
+ "editor_cost_field_uom": "UM",
+ "editor_cost_unit_cost": "Costo unitario",
+ "editor_cost_lump_sum": "Suma global",
+ "editor_cost_field_bucket": "Grupo",
+ "editor_cost_placeholder_system": "p. ej. techo",
+ "editor_cost_placeholder_component": "p. ej. membrana",
+ "editor_cost_placeholder_uom": "sf, ea…",
+ "editor_cost_reserve_schedule": "Programa de reservas (Tabla 2) — vida útil esperada / efectiva / restante (años)",
+ "editor_cost_field_eul": "EUL",
+ "editor_cost_field_eff_age": "Edad efect.",
+ "editor_cost_field_rul": "RUL",
+ "editor_cost_field_suggested_remedy": "Solución sugerida",
+ "editor_cost_below_threshold": "por debajo del umbral de $3,000",
+ "editor_customdefect_title_placeholder": "Título del defecto — p. ej. Mancha de agua en el entablado",
+ "editor_customdefect_title_aria": "Título del defecto personalizado",
+ "editor_customdefect_narrative_placeholder": "Narrativa para el informe (opcional)",
+ "editor_customdefect_narrative_aria": "Narrativa del defecto personalizado",
+ "editor_customdefect_category_aria": "Categoría del defecto personalizado",
+ "editor_customdefect_category_safety": "Seguridad",
+ "editor_customdefect_category_recommendation": "Recomendación",
+ "editor_customdefect_category_maintenance": "Mantenimiento",
+ "editor_customdefect_save_to_library": "Guardar en mi biblioteca",
+ "editor_customdefect_add": "Agregar defecto"
}
From a55de95cc342ae709c72b6379b6b551cb9a2d244 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:20:10 +0800
Subject: [PATCH 028/111] i18n(es-419): translate editor-2.json (205 keys)
---
messages/es-419/editor-2.json | 207 +++++++++++++++++++++++++++++++++-
1 file changed, 206 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/editor-2.json b/messages/es-419/editor-2.json
index 006f618aa..58b5ec29c 100644
--- a/messages/es-419/editor-2.json
+++ b/messages/es-419/editor-2.json
@@ -1,3 +1,208 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "editor_uploading": "Subiendo…",
+ "editor_shortcuts_heading": "Atajos de teclado",
+ "editor_shortcuts_label": "Atajos",
+ "editor_defect_location_label": "Ubicación",
+ "editor_defect_location_placeholder": "p. ej. baño principal, esquina NE del sótano",
+ "editor_defect_trade_label": "Oficio",
+ "editor_defect_trade_select_placeholder": "— seleccione —",
+ "editor_defect_deadline_label": "Fecha límite",
+ "editor_defect_timeframe_label": "Plazo",
+ "editor_header_property_fallback": "Inspección",
+ "editor_header_save_saving": "Guardando...",
+ "editor_header_save_saved": "Guardado",
+ "editor_header_save_error": "Error",
+ "editor_header_search_placeholder": "Buscar en el informe...",
+ "editor_header_version_history": "Historial de versiones",
+ "editor_header_theme_label": "Tema: {scheme}",
+ "editor_header_theme_field_suffix": " (exteriores de alto contraste)",
+ "editor_header_settings": "Configuración del informe",
+ "editor_header_preview_full_title": "Ver el informe completo (todas las secciones) en una pestaña nueva",
+ "editor_header_preview": "Vista previa",
+ "editor_header_preview_pdf_title": "Ver el PDF real generado en el servidor (el entregable exacto para el cliente) en una pestaña nueva",
+ "editor_header_preview_pdf": "Vista previa del PDF",
+ "editor_header_preview_title": "Vea el informe como lo verá el cliente",
+ "editor_header_preview_actions_aria": "Opciones de vista previa",
+ "editor_header_preview_report": "Informe web",
+ "editor_header_preview_pdf_short": "PDF",
+ "editor_header_more": "Más acciones",
+ "editor_header_sign_title": "Firmar esta inspección ahora",
+ "editor_header_sign": "Firmar ahora",
+ "editor_header_publish": "Publicar",
+ "editor_footer_shortcut_rate": "Calificar elemento",
+ "editor_footer_shortcut_nav": "Siguiente / anterior",
+ "editor_footer_shortcut_library": "Abrir la biblioteca",
+ "editor_footer_shortcut_photo": "Capturar foto",
+ "editor_footer_shortcut_voice": "Nota de voz",
+ "editor_footer_shortcut_repeat": "Repetir la calificación",
+ "editor_footer_shortcut_speed": "Modo rápido",
+ "editor_footer_shortcut_next_defect": "Siguiente defecto",
+ "editor_footer_shortcut_next_field": "Siguiente campo",
+ "editor_footer_shortcut_sidebar": "Mostrar u ocultar la barra lateral",
+ "editor_footer_shortcut_help": "Esta ayuda",
+ "editor_footer_presence_editing": " — editando {id}",
+ "editor_footer_status_connected": "Conectado",
+ "editor_footer_status_reconnecting": "Reconectando…",
+ "editor_footer_status_connecting": "Conectando…",
+ "editor_fullscreen_exit_title": "Salir de pantalla completa (Esc)",
+ "editor_fullscreen_enter_title": "Pantalla completa (F)",
+ "editor_fullscreen_exit_aria": "Salir de pantalla completa",
+ "editor_fullscreen_enter_aria": "Entrar en pantalla completa",
+ "editor_settings_save_error": "Error -- inténtelo de nuevo",
+ "editor_settings_save_changes": "Guardar cambios",
+ "editor_settings_legend_template": "Plantilla",
+ "editor_settings_field_template": "Plantilla de inspección",
+ "editor_settings_legend_report_rules": "Reglas del informe",
+ "editor_settings_legend_appearance": "Apariencia del informe",
+ "editor_settings_required_defect_fields": "Campos obligatorios del defecto al publicar",
+ "editor_settings_required_inherit": "Heredar (predeterminado de la empresa)",
+ "editor_settings_appearance_summary": "Usar un preajuste de estilo diferente",
+ "editor_settings_appearance_help": "Anula el valor predeterminado de la empresa o de la plantilla solo para este informe.",
+ "editor_settings_appearance_inherit": "Heredar el predeterminado",
+ "editor_settings_required_none": "Ninguno — solo advertir",
+ "editor_settings_required_location": "Ubicación obligatoria",
+ "editor_settings_required_trade": "Oficio recomendado obligatorio",
+ "editor_settings_required_both": "Ubicación + oficio obligatorios",
+ "editor_settings_required_defect_help": "Anula el valor predeterminado del espacio de trabajo solo para esta inspección.",
+ "editor_settings_legend_cover": "Foto de portada del informe",
+ "editor_settings_cover_empty": "Aún no hay fotos — suba una abajo, o agregue fotos a un elemento de la inspección y elija una aquí.",
+ "editor_settings_cover_current": "Portada actual — haga clic para borrarla",
+ "editor_settings_cover_set": "Establecer como portada",
+ "editor_settings_cover_set_labeled": "Establecer como portada ({label})",
+ "editor_settings_cover_badge": "PORTADA",
+ "editor_settings_cover_photo_alt": "Foto",
+ "editor_settings_cover_upload": "Subir foto de portada",
+ "editor_settings_cover_hint": "Se muestra en la portada del informe. Haga clic en la foto seleccionada para borrarla.",
+ "editor_settings_cover_uploaded_label": "Subida",
+ "editor_settings_legend_photo_uploads": "Carga de fotos",
+ "editor_settings_original_quality": "Cargas en calidad original",
+ "editor_settings_original_quality_help": "Omite el redimensionado en el dispositivo y la eliminación de metadatos. Archivos más grandes; las cargas conservan los datos de ubicación de la cámara.",
+ "editor_dock_menu_label": "Herramientas del inspector",
+ "editor_dock_open": "Abrir las herramientas del inspector",
+ "editor_dock_close": "Cerrar las herramientas del inspector",
+ "editor_dock_speed_mode": "Modo rápido",
+ "editor_dock_burst_camera": "Cámara en ráfaga",
+ "editor_dock_photo_studio": "Estudio de fotos",
+ "editor_item_tab_information": "Información",
+ "editor_item_tab_limitations": "Limitaciones",
+ "editor_item_tab_defects": "Defectos",
+ "editor_item_photos_none": "Aún no hay fotos",
+ "editor_item_photo_count_one": "{count} foto",
+ "editor_item_photo_count_other": "{count} fotos",
+ "editor_item_photo_queued_suffix": " · {count} en cola",
+ "editor_item_contradiction_one": "La calificación contradice un comentario marcado",
+ "editor_item_contradiction_other": "La calificación contradice {count} comentarios marcados",
+ "editor_item_contradiction_item": "“{title}” sigue diciendo que todo está bien",
+ "editor_item_uncheck": "Desmarcarlo",
+ "editor_item_notes_label": "Notas",
+ "editor_item_notes_chars": "{count} caracteres",
+ "editor_item_notes_placeholder": "Agregue notas — escriba para ver comentarios recomendados, / para la biblioteca",
+ "editor_item_recommended": "Recomendados ▾",
+ "editor_item_add_photo": "Agregar foto",
+ "editor_item_add_defect_photo_aria": "Agregar foto a este defecto",
+ "editor_item_defect_photo_count_one": "{count} foto · agregar",
+ "editor_item_defect_photo_count_other": "{count} fotos · agregar",
+ "editor_item_photos_label": "Fotos",
+ "editor_item_photo_queued_badge": "EN COLA",
+ "editor_hud_press": "Presione ",
+ "editor_hud_toggle": " para alternar, ",
+ "editor_hud_close": " para cerrar",
+ "editor_hud_col_navigate": "Navegar",
+ "editor_hud_col_rating": "Calificación",
+ "editor_hud_col_content": "Contenido",
+ "editor_hud_col_view": "Vista",
+ "editor_hud_nav_prev_next": "Elemento siguiente / anterior",
+ "editor_hud_nav_next": "Elemento siguiente",
+ "editor_hud_nav_prev": "Elemento anterior",
+ "editor_hud_nav_section": "Ir a la sección",
+ "editor_hud_nav_palette": "Paleta de comandos",
+ "editor_hud_nav_palette_win": "Paleta de comandos (Win)",
+ "editor_hud_rate_satisfactory": "Satisfactorio",
+ "editor_hud_rate_monitor": "Vigilar",
+ "editor_hud_rate_defect": "Defecto",
+ "editor_hud_rate_not_inspected": "No inspeccionado",
+ "editor_hud_rate_not_present": "No presente",
+ "editor_hud_rate_clear": "Borrar la calificación",
+ "editor_hud_rate_na": "Marcar como No aplica",
+ "editor_hud_content_library": "Abrir la Biblioteca de comentarios",
+ "editor_hud_content_snippet": "Insertar fragmento",
+ "editor_hud_content_tag": "Agregar etiqueta",
+ "editor_hud_content_save_snippet": "Guardar el actual como fragmento",
+ "editor_hud_view_three_pane": "Diseño de tres paneles",
+ "editor_hud_view_focus": "Modo de enfoque",
+ "editor_hud_footer": "Los atajos marcados con Cmd requieren la tecla meta de la plataforma en Mac. Algunos atajos pueden estar inactivos hasta que esa función esté disponible.",
+ "editor_mobile_more": "Más acciones",
+ "editor_mobile_drawer_sections": "Secciones",
+ "editor_mobile_drawer_items": "Elementos",
+ "editor_progress_rated": "{rated}/{total} calificados",
+ "editor_progress_defect_one": "{count} defecto",
+ "editor_progress_defect_other": "{count} defectos",
+ "editor_progress_monitor": "{count} para vigilar",
+ "editor_progress_eta": "ETA {minutes}min",
+ "editor_property_field_year_built": "Año de construcción",
+ "editor_property_field_sqft": "Pies²",
+ "editor_property_field_foundation": "Cimentación",
+ "editor_property_field_lot_size": "Tamaño del lote",
+ "editor_property_field_bedrooms": "Dormitorios",
+ "editor_property_field_bathrooms": "Baños",
+ "editor_property_field_unit": "Unidad / suite",
+ "editor_property_field_county": "Condado",
+ "editor_property_field_building_area": "Área del edificio (pies²)",
+ "editor_property_group_facts": "Datos de la propiedad",
+ "editor_property_group_general": "General",
+ "editor_property_progress": "Información de la propiedad · {filled} de {total} campos completos",
+ "editor_property_complete": "Completo",
+ "editor_property_fallback": "Información de la propiedad",
+ "editor_property_prefilled": "Precargado",
+ "editor_property_autofill_button": "Obtener los datos de la propiedad",
+ "editor_property_autofill_loading": "Obteniendo…",
+ "editor_property_autofill_filled": "Se completaron {count} campo(s) desde los registros públicos: {fields}",
+ "editor_property_autofill_none": "No hay campos nuevos que completar — los datos ya parecen completos.",
+ "editor_property_autofill_not_found": "No se encontraron registros públicos para esta dirección.",
+ "editor_property_autofill_unconfigured": "El autocompletado de propiedades no está configurado en este espacio de trabajo.",
+ "editor_property_autofill_failed": "No se pudieron obtener los datos de la propiedad. Inténtelo de nuevo.",
+ "editor_gate_missing": "Faltan: ",
+ "editor_gate_none": "(ninguno)",
+ "editor_gate_unresolved": " · Tokens sin resolver: ",
+ "editor_gate_jump": "Ir",
+ "editor_gate_warn_title_one": "¿Publicar con advertencias? — {count} defecto incompleto",
+ "editor_gate_warn_title_other": "¿Publicar con advertencias? — {count} defectos incompletos",
+ "editor_gate_block_title_one": "No se puede publicar — {count} defecto requiere atención",
+ "editor_gate_block_title_other": "No se puede publicar — {count} defectos requieren atención",
+ "editor_gate_warnings_banner": "Advertencias — no bloquean la publicación",
+ "editor_gate_publish_anyway": "Publicar de todos modos",
+ "editor_publish_title": "Publicar el informe",
+ "editor_publish_publishing": "Publicando…",
+ "editor_publish_now": "Publicar ahora",
+ "editor_publish_body": "Publicar finalizará esta inspección y pondrá el informe a disposición de los clientes.",
+ "editor_publish_warning": "Advertencia: solo se han calificado {rated} de {total} elementos ({pct}% completo).",
+ "editor_publish_stat_items_rated": "Elementos calificados",
+ "editor_publish_stat_completion": "Avance",
+ "editor_publish_stat_status": "Estado",
+ "editor_publish_autosign": "Firmar automáticamente este informe al publicar",
+ "editor_finish_fieldwork": "Finalizar el trabajo de campo",
+ "editor_finish_fieldwork_pending": "Finalizando…",
+ "editor_finish_fieldwork_error": "No se pudo marcar el trabajo de campo como completo.",
+ "editor_publish_not_completed_prompt": "El trabajo en sitio no está marcado como completo. Publicar no lo requiere: puede hacer ambas cosas o solo publicar.",
+ "editor_publish_mark_complete_and_publish": "Marcar como completo y publicar",
+ "editor_publish_just_publish": "Solo publicar",
+ "editor_rating_aria": "Calificación",
+ "editor_recrop_title": "¿Recortar de nuevo esta foto?",
+ "editor_recrop_confirm": "Recortar y borrar",
+ "editor_recrop_body": "Volver a recortar quitará la anotación existente en esta foto (sus marcas están ligadas al recorte anterior).",
+ "editor_repair_label": "Elementos de reparación",
+ "editor_repair_attach": "+ Adjuntar elemento de reparación",
+ "editor_repair_remove_aria": "Quitar {name}",
+ "editor_repair_search_placeholder": "Buscar elementos de reparación…",
+ "editor_repair_search_aria": "Buscar elementos de reparación",
+ "editor_repair_empty": "No hay elementos de reparación que coincidan. Agregue algunos en Biblioteca → Elementos de reparación.",
+ "editor_savetpl_title_new": "Guardar como plantilla nueva",
+ "editor_savetpl_title_back": "Guardar la estructura en la plantilla",
+ "editor_savetpl_confirm_new": "Crear plantilla",
+ "editor_savetpl_confirm_back": "Guardar en la plantilla",
+ "editor_savetpl_body_new": "Crea una plantilla nueva a partir de la estructura actual de esta inspección. Queda disponible para inspecciones futuras; esta inspección no cambia.",
+ "editor_savetpl_body_back": "Sobrescribe la plantilla de origen con la estructura actual de esta inspección. Las inspecciones futuras creadas a partir de ella adoptan el cambio; los informes ya publicados conservan su instantánea congelada.",
+ "editor_savetpl_name_label": "Nombre de la plantilla",
+ "editor_savetpl_name_placeholder": "Plantilla personalizada"
}
From 49d70968c09d0175936e303dfe1924e8c3bc6175 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:22:57 +0800
Subject: [PATCH 029/111] i18n(es-419): translate editor-3.json (102 keys)
---
messages/es-419/editor-3.json | 104 +++++++++++++++++++++++++++++++++-
1 file changed, 103 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/editor-3.json b/messages/es-419/editor-3.json
index 006f618aa..b8462d69f 100644
--- a/messages/es-419/editor-3.json
+++ b/messages/es-419/editor-3.json
@@ -1,3 +1,105 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "editor_sectionpicker_title": "Ir a la sección",
+ "editor_sectionpicker_placeholder": "Ir a la sección...",
+ "editor_sectionpicker_item_count": "{count} elementos",
+ "editor_sectionpicker_no_match": "Ninguna sección coincide",
+ "editor_siderail_close_panel": "Cerrar el panel",
+ "editor_siderail_tab_preview": "Vista previa",
+ "editor_siderail_tab_library": "Biblioteca",
+ "editor_siderail_tab_photos": "Fotos",
+ "editor_siderail_notes": "Notas",
+ "editor_siderail_comments": "Comentarios",
+ "editor_siderail_preview_empty": "Seleccione un elemento para ver una vista previa en vivo.",
+ "editor_siderail_search_placeholder": "Buscar comentarios…",
+ "editor_siderail_filtered_to": "Filtrado por: {label}",
+ "editor_siderail_photos_empty": "Abra una inspección para explorar las fotos.",
+ "editor_siderail_download_title": "Descargar {name}",
+ "editor_signmodal_title": "Firma del inspector",
+ "editor_signmodal_body": "Firme esta inspección. La firma se guardará y puede incluirse en el informe publicado.",
+ "editor_signmodal_save": "Guardar firma",
+ "editor_signmodal_failed": "No se pudo guardar la firma. Inténtelo de nuevo.",
+ "editor_speedmode_aria_label": "Calificar rápidamente los elementos de la inspección",
+ "editor_speedmode_exit": "Salir del modo rápido",
+ "editor_speedmode_gesture_hint": "Deslice para navegar · Mantenga presionado para saltar",
+ "editor_speedmode_prev": "Anterior",
+ "editor_speedmode_footer_press": "Presione",
+ "editor_speedmode_footer_or": "o",
+ "editor_speedmode_footer_exit": "para salir",
+ "editor_speedmode_coach_aria": "Consejos del modo rápido",
+ "editor_speedmode_coach_title": "Modo rápido",
+ "editor_speedmode_coach_rate": "Califique el elemento actual",
+ "editor_speedmode_coach_swipe": "Deslice a la izquierda / derecha para cambiar de elemento",
+ "editor_speedmode_coach_longpress": "Mantenga presionado para saltar a una sección",
+ "editor_speedmode_coach_start": "Toque en cualquier lugar para comenzar",
+ "editor_speedmode_undo_changed": "Cambiado a {rating}.",
+ "editor_speedmode_undo_rated": "Calificado como {rating}.",
+ "editor_speedmode_jumpto_title": "Ir a",
+ "editor_speedmode_no_items": "Sin elementos",
+ "editor_speedmode_sections_unavailable": "La lista de secciones no está disponible.",
+ "editor_tagchiprow_more": "+ más",
+ "editor_tagchiprow_open_library_aria": "Abrir la biblioteca de etiquetas",
+ "editor_tagpicker_title": "Etiquetas",
+ "editor_unitprogress_summary_aria": "{completed} de {total} unidades completas",
+ "editor_unitprogress_count": "{completed}/{total} unidades",
+ "editor_unitprogress_status_complete": "completa",
+ "editor_unitprogress_status_in_progress": "en curso",
+ "editor_unitprogress_dot_title": "{label} — {status}",
+ "editor_unitsmanager_title": "Unidades",
+ "editor_unitsmanager_mode_heading": "Modo de inspección",
+ "editor_unitsmanager_mode_per_unit": "Por unidad",
+ "editor_unitsmanager_mode_tagged": "Etiquetado",
+ "editor_unitsmanager_mode_per_unit_desc": "Cada unidad se inspecciona como su propio subinforme.",
+ "editor_unitsmanager_mode_tagged_desc": "Un solo informe compartido; los hallazgos llevan etiquetas de ubicación.",
+ "editor_unitsmanager_switch_to_tagged": "Cambiar a modo etiquetado",
+ "editor_unitsmanager_switch_to_per_unit": "Cambiar a modo por unidad",
+ "editor_unitsmanager_no_units": "Aún no hay unidades. Agregue una abajo o cree un conjunto en lote.",
+ "editor_unitsmanager_new_unit_name": "Nombre de la unidad nueva",
+ "editor_unitsmanager_bulk_create_heading": "Creación en lote",
+ "editor_unitsmanager_lossy_title": "¿Cambiar a modo etiquetado?",
+ "editor_unitsmanager_switch_flatten": "Cambiar y aplanar",
+ "editor_unitsmanager_lossy_body_1": "Esto aplana los hallazgos de cada unidad de vuelta al alcance común y",
+ "editor_unitsmanager_lossy_delete_one": "elimina {count} fila de unidad",
+ "editor_unitsmanager_lossy_delete_many": "elimina las {count} filas de unidad",
+ "editor_unitsmanager_lossy_body_2": ". Las etiquetas de las unidades se conservan como etiquetas de ubicación, pero el desglose por unidad no se puede restaurar.",
+ "editor_unitsmanager_rename_aria": "Cambiar el nombre de {name}",
+ "editor_unitsmanager_duplicate_title": "Duplicar unidad",
+ "editor_unitsmanager_duplicate_aria": "Duplicar {name}",
+ "editor_unitsmanager_remove_title": "Quitar unidad",
+ "editor_unitsmanager_remove_aria": "Quitar {name}",
+ "editor_unitsmanager_bulk_grid_label": "Pisos × columnas",
+ "editor_unitsmanager_bulk_csv_label": "Pegar CSV",
+ "editor_unitsmanager_bulk_method_aria": "Método de creación en lote",
+ "editor_unitsmanager_floors_label": "Pisos",
+ "editor_unitsmanager_floors_aria": "Número de pisos",
+ "editor_unitsmanager_units_per_floor_label": "Unidades / piso",
+ "editor_unitsmanager_units_per_floor_aria": "Unidades por piso",
+ "editor_unitsmanager_start_at_label": "Comenzar en",
+ "editor_unitsmanager_start_at_aria": "Comenzar la numeración en",
+ "editor_unitsmanager_grid_hint": "Defina los pisos y las unidades",
+ "editor_unitsmanager_creates_one": "Crea {count} unidad",
+ "editor_unitsmanager_creates_many": "Crea {count} unidades",
+ "editor_unitsmanager_create_units": "Crear unidades",
+ "editor_unitsmanager_csv_placeholder": "label,floor\n101,1\n102,1\nVestíbulo,",
+ "editor_unitsmanager_csv_aria": "Unidades en CSV",
+ "editor_unitsmanager_csv_hint": "Una unidad por línea: label,floor",
+ "editor_unitsmanager_row_one": "{count} fila",
+ "editor_unitsmanager_row_many": "{count} filas",
+ "editor_structuredelete_noun_section": "sección",
+ "editor_structuredelete_noun_item": "elemento",
+ "editor_structuredelete_title": "¿Eliminar {noun} “{title}”?",
+ "editor_structuredelete_confirm": "Eliminar {noun}",
+ "editor_structuredelete_body": "Esto elimina {parts} asociados con {noun}. Esta acción no se puede deshacer.",
+ "editor_structuredelete_items_one": "elemento",
+ "editor_structuredelete_items_many": "elementos",
+ "editor_structuredelete_ratings_one": "calificación",
+ "editor_structuredelete_ratings_many": "calificaciones",
+ "editor_structuredelete_notes_one": "nota",
+ "editor_structuredelete_notes_many": "notas",
+ "editor_structuredelete_photos_one": "foto",
+ "editor_structuredelete_photos_many": "fotos",
+ "editor_unsavedchanges_title": "Cambios sin guardar",
+ "editor_unsavedchanges_stay": "Quedarse",
+ "editor_unsavedchanges_leave": "Salir",
+ "editor_unsavedchanges_body": "Tiene cambios sin guardar. ¿Seguro que quiere salir?"
}
From b3f5ef3e16e085281b9f0b60c3d1cf183f33ca44 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:28:55 +0800
Subject: [PATCH 030/111] i18n(es-419): translate editor-4.json (153 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Also narrows one glossary ban that this module proves false-fires: the
Repair Items row ruled out the word *recomendaciones* outright, but ASTM
E2018 has a "1.5 Recommendations" section (here and in pca-report.json)
and public.json says "grouped under Recommendations". The prohibition is
real but cannot be a machine ban, and a gate with false positives gets
bypassed — so it moves to the Why column as a rule. The Estimate row gets
the same treatment in prose before someone machine-bans *estimado* and
breaks "Costo estimado".
Adds the editor-family and ASTM PCA term tables (29 rows) so the seven
remaining waves inherit the decisions rather than re-arguing them, plus a
rule that a format literal a parser matches on stays English — the units
CSV hint shows `label,floor` and parseUnitCsv compares against exactly
that.
---
docs/developers/i18n-glossary.md | 63 ++++++++++++-
messages/es-419/editor-4.json | 155 ++++++++++++++++++++++++++++++-
2 files changed, 215 insertions(+), 3 deletions(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index a4144edd0..72bf6b950 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -68,6 +68,14 @@ catalogue.
6. **Numbers, dates, money and addresses are formatted by code**
(`app/lib/format.ts`, `app/lib/money.ts`), never spelled into a message. Do
not hardcode a currency symbol or a date pattern in a translated string.
+7. **A format literal a parser matches on stays English.** Some strings show the
+ user an input format that code then compares against, character for
+ character. `editor_unitsmanager_csv_placeholder` and `_csv_hint` show
+ `label,floor`, and `parseUnitCsv` (`server/lib/unit-pattern.ts`) skips a
+ header row only when it reads exactly that. Translating the hint to
+ `etiqueta,piso` would teach a Spanish user to type a header the parser then
+ imports as a unit named "etiqueta". Translate the prose around such a
+ literal; leave the literal alone.
---
@@ -84,7 +92,7 @@ catalogue.
| Template | plantilla | — | Standard software Spanish. |
| Finding | hallazgo | — | Rare in the UI (a repair-request column, a metrics chart). Distinct from *defecto*: a hallazgo is observed, a defecto is judged. |
| Defect | defecto | — | The severity level. See the rating table. |
-| Repair Items | elementos de reparación | recomendaciones | English forbids "Recommendations" for this feature; the Spanish ban mirrors it exactly. |
+| Repair Items | elementos de reparación | — | English forbids "Recommendations" **for this feature**, and so does Spanish — but that prohibition cannot be a machine ban. English uses the same word legitimately elsewhere: the ASTM PCA report's "1.5 Recommendations" section, the "Recommendation" custom-defect category, and the agent portal's "grouped under Recommendations". A blanket ban on *recomendaciones* false-fires on all of them, and a gate with false positives gets bypassed. Rule, not gate: never name **this feature** *recomendaciones*. |
| Repair Request | solicitud de reparación | — | The client-facing document built from repair items. |
| Canned Comment | comentario predefinido | comentario enlatado | "Enlatado" is a literal calque of the English idiom and reads as a joke. |
| Notes | notas | apuntes | Inspector free text. Keep distinct from *comentarios*. |
@@ -94,7 +102,7 @@ catalogue.
| Schedule (noun) | agenda | — | |
| Schedule (verb) | programar | — | The verb. For the *status* "Scheduled" see the Status labels section — it is *Programado*, masculine, and that section explains why. |
| Invoice | factura | — | |
-| Estimate | presupuesto | — | Not *estimado*, which reads as a guess rather than a priced offer. |
+| Estimate | presupuesto | — | The **noun** for a priced offer. Not *estimado*, which reads as a guess. The ban is on the noun only and is deliberately not machine-enforced: *estimado* is the ordinary adjective and the only right word in "Estimated cost" → *Costo estimado* and "Estimated monthly cost" → *Costo mensual estimado*. Banning the string would false-fire on both. |
| Agreement | acuerdo | — | The document itself stays in English; this is the word for it in chrome. |
| Trade | oficio | — | The contractor discipline. English also says "contractor type" and "recommended contractor" for adjacent things — translate each as written (rule 1). |
| Contractor | contratista | — | |
@@ -262,6 +270,57 @@ These are a few hundred keys between them. One word each.
| Sending… | Enviando… | — | |
| Uploading… | Subiendo… | — | |
+## The editor and field workflow
+
+Fixed while translating the four `editor*.json` files (595 keys). Several of
+these words also appear in `media`, `settings*`, `misc`, `contacts` and
+`components`, so they are decided here rather than re-argued there.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Speed mode | modo rápido | — | The one-item-at-a-time rating flow. Not *modo de velocidad*. |
+| Burst camera | cámara en ráfaga | — | *Ráfaga* is the standard photographic term. |
+| Snippet | fragmento | — | A saved piece of note text. Distinct from *comentario predefinido*, which is a library entry. |
+| Unit (of a multi-unit property) | unidad | — | The per-unit inspection mode. Not the unit of measure — that is *UM* in the cost table. |
+| Cost item | elemento de costo | — | A line in the PCA Opinion of Cost. Parallel to *elemento de reparación*. |
+| Sign-off | aprobación | — | The commercial reviewer's approval. *Firmar* is the signature act; the sign-off is the recorded approval, and the two appear side by side on the compliance panel. |
+| Location | ubicación | — | Where a defect is. Not *localización*. |
+| Version history | historial de versiones | — | |
+| Restore | Restaurar | — | Bringing back a saved version. Distinct from *Recuperar* (recover one value). |
+| Rename | Cambiar nombre | — | Sentence case, no article, because it is a menu item. The aria form takes one: *Cambiar el nombre de {name}*. |
+| Cover photo | foto de portada | — | "Set as cover" → *Establecer como portada*. |
+| Completion | Avance | — | A percentage stat. *Completitud* is a mathematics word; *avance* is what a progress figure is called. |
+| Saved | Guardado | — | The save-state indicator, masculine singular by the status-label rule. |
+| Connected / Connecting… | Conectado / Conectando… | — | Collaboration presence. |
+| inspector-added | agregado por el inspector | — | The badge on a defect the inspector wrote rather than the library. Lowercase, as in English. |
+| Unrated | Sin calificar | — | |
+| Batch mode | modo por lotes | — | Multi-select rating. Bulk create is *creación en lote*. |
+
+## Commercial PCA (ASTM E2018)
+
+Commercial report vocabulary. The section numbers are part of the string and
+never change; the same headings appear in `editor-4.json` and `pca-report.json`
+character for character, so the consistency check binds them together.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| PCA / PSQ / PCR / EUL / RUL | PCA / PSQ / PCR / EUL / RUL | — | Acronyms of the standard. Left as written — they are how the document names itself, and expanding them in Spanish would not match the report a client receives. |
+| Transmittal Letter | Carta de remisión | — | The cover letter ASTM E2018 requires. *Remisión* is the term used for a document formally forwarded to a client. |
+| Opinion of Cost | Opinión de Costo | — | The ASTM name for the cost tables. Capitalised because it names a document part. |
+| 1.1 General Description | 1.1 Descripción general | — | |
+| 1.2 General Physical Condition | 1.2 Condición física general | — | |
+| 1.5 Recommendations | 1.5 Recomendaciones | — | See the Repair Items row: this is the legitimate use of the word. |
+| 2.1 Purpose | 2.1 Propósito | — | |
+| 2.3 Limitations & Exceptions | 2.3 Limitaciones y excepciones | — | |
+| 2.4 General Property Reconnaissance | 2.4 Reconocimiento general de la propiedad | — | |
+| Additional Considerations | Consideraciones adicionales | — | |
+| Immediate / Short-term / Long-term | Inmediato / Corto plazo / Largo plazo | — | The three cost buckets. Masculine singular — they label a bucket, like a status. |
+| Conforms / Does not conform | Conforme / No conforme | — | The ASTM conformance statement. |
+
## Register enforcement
Every entry here is wrong in `es-419` in every context, which is what makes it
diff --git a/messages/es-419/editor-4.json b/messages/es-419/editor-4.json
index 006f618aa..34f0f462c 100644
--- a/messages/es-419/editor-4.json
+++ b/messages/es-419/editor-4.json
@@ -1,3 +1,156 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "editor_route_meta_title": "Editar inspección - OpenInspection",
+ "editor_route_save_failed": "Error al guardar — su último cambio NO llegó al servidor.",
+ "editor_route_entered_next_section": "Entró a la siguiente sección: {section}",
+ "editor_route_photos_batch_capped": "Se agregaron las primeras 20 fotos; agregue el resto en otro lote.",
+ "editor_route_photos_partial_upload": "Se subieron {success} de {total} fotos — {failed} fallaron.",
+ "editor_route_photos_added": "+{count} foto{s}{toDefect}",
+ "editor_route_photo_upload_failed": "Error al subir la foto — su foto NO llegó al servidor.",
+ "editor_route_defect_library_copy_failed": "Se guardó el defecto, pero la copia en la biblioteca falló — inténtelo de nuevo desde Notas › Guardar como fragmento.",
+ "editor_route_select_item_hint": "Seleccione un elemento de la lista para empezar a editar",
+ "editor_route_navigate_hint_press": "Presione",
+ "editor_route_navigate_hint_navigate": "para navegar",
+ "editor_route_select_an_item": "Seleccione un elemento",
+ "editor_route_mobile_begin": "Toque [☰ Secciones] abajo para comenzar",
+ "editor_route_drawer_sections": "Secciones",
+ "editor_route_drawer_items": "Elementos",
+ "editor_route_drawer_preview": "Vista previa",
+ "editor_route_empty_template_title": "Esta inspección no tiene contenido de plantilla",
+ "editor_route_empty_template_desc": "Aplique una plantilla para obtener secciones, elementos y comentarios predefinidos — o importe su plantilla de Spectora.",
+ "editor_route_choose_template": "Elegir una plantilla",
+ "editor_route_crop_photo": "Recortar foto",
+ "editor_route_save_crop": "Guardar el recorte",
+ "editor_route_manage_units": "Administrar las unidades",
+ "editor_route_units": "Unidades",
+ "editor_route_cost_items_title": "Elementos de costo",
+ "editor_route_cost_items": "Elementos de costo",
+ "editor_route_item_filter": "Filtro de elementos",
+ "editor_route_filter_all": "Todo",
+ "editor_route_filter_unrated": "Sin calificar",
+ "editor_route_filter_issues": "Problemas",
+ "editor_route_filter_flagged": "Marcados",
+ "editor_route_exit_batch_mode": "Salir del modo por lotes",
+ "editor_route_batch_mode": "Modo por lotes (B)",
+ "editor_route_batch_select_items": "Seleccionar elementos por lotes",
+ "editor_route_expand_photo_rail": "Expandir la barra de fotos",
+ "editor_route_collapse_photo_rail": "Contraer la barra de fotos",
+ "editor_route_batch_rated": "Calificación aplicada a {count} elemento{s}: {label}",
+ "editor_shared_badge_required": "obligatorio",
+ "editor_shared_badge_safety": "seguridad",
+ "editor_shared_drag_label": "Arrastrar {label}",
+ "editor_shared_drag_to_reorder": "Arrastre para reordenar",
+ "editor_shared_edit_label": "Editar {label}",
+ "editor_shared_menu_rename": "Cambiar nombre",
+ "editor_shared_menu_duplicate": "Duplicar",
+ "editor_shared_menu_move_up": "Subir",
+ "editor_shared_menu_move_down": "Bajar",
+ "editor_shared_add_item": "+ Agregar elemento",
+ "editor_shared_add_section": "+ Agregar sección",
+ "editor_shared_inspection_details": "Detalles de la inspección",
+ "editor_shared_section_rated": "{rated} de {total} calificados",
+ "editor_shared_section_unrated": "{unrated} sin calificar",
+ "editor_shared_section_defects": "{defects} defecto{s}",
+ "editor_shared_section_options": "Opciones de la sección {title}",
+ "editor_shared_item_name_aria": "Nombre del elemento",
+ "editor_shared_section_name_aria": "Nombre de la sección",
+ "editor_collab_autosaved_before_reconnect": "Guardado automáticamente antes de una reconexión",
+ "editor_collab_autosaved": "Guardado automáticamente",
+ "editor_collab_just_now": "hace un momento",
+ "editor_collab_minutes_ago": "hace {min} minuto{s}",
+ "editor_collab_hours_ago": "hace {hr} hora{s}",
+ "editor_collab_days_ago": "hace {day} día{s}",
+ "editor_collab_saving": "Guardando...",
+ "editor_collab_save_version_now": "Guardar la versión ahora",
+ "editor_collab_version_history": "Historial de versiones",
+ "editor_collab_history_desc": "Versiones guardadas de este informe. Restaurar reemplaza el contenido actual con la versión seleccionada.",
+ "editor_collab_loading_versions": "Cargando versiones...",
+ "editor_collab_load_failed": "No se pudo cargar el historial de versiones.",
+ "editor_collab_try_again": "Reintentar",
+ "editor_collab_no_versions": "Aún no hay versiones guardadas",
+ "editor_collab_compare": "Comparar",
+ "editor_collab_restore": "Restaurar",
+ "editor_collab_restore_confirm_title": "¿Restaurar esta versión?",
+ "editor_collab_restoring": "Restaurando...",
+ "editor_collab_restore_version": "Restaurar la versión",
+ "editor_collab_restore_confirm_body": "Restaurar reemplaza el contenido actual del informe con la versión seleccionada. El estado actual se guarda primero como una versión nueva, así que esto es reversible.",
+ "editor_collab_restore_failed": "Error al restaurar",
+ "editor_collab_selected_version": "Versión seleccionada",
+ "editor_collab_version_n": "Versión #{seq}",
+ "editor_collab_current": "Actual",
+ "editor_collab_compare_versions": "Comparar versiones",
+ "editor_collab_compare_desc": " · recupere un valor individual o restaure la versión completa abajo.",
+ "editor_collab_no_differences": "No hay diferencias entre estas versiones.",
+ "editor_collab_empty_value": "(vacío)",
+ "editor_collab_not_present": "(no presente)",
+ "editor_collab_removed": "(quitado)",
+ "editor_collab_added_since": "Agregado desde esta versión",
+ "editor_collab_removed_since": "Quitado desde esta versión",
+ "editor_collab_recover_title": "Escribir el valor anterior de nuevo en el informe actual",
+ "editor_collab_recover_value": "Recuperar este valor",
+ "editor_collab_photos_comments_changed": "Fotos / comentarios modificados",
+ "editor_collab_nested_summary_prefix": "{summary}. ",
+ "editor_collab_nested_note": "Use “Restaurar la versión completa” abajo para revertir estos cambios anidados — la recuperación individual no está disponible para fotos, defectos ni comentarios personalizados.",
+ "editor_collab_footer_desc": "Recuperar un solo valor lo escribe en el informe en vivo. Restaurar la versión completa reemplaza todo el contenido actual con esta versión (su estado actual se guarda primero como una versión nueva, así que es reversible).",
+ "editor_collab_working": "Procesando…",
+ "editor_collab_restore_entire_version": "Restaurar la versión completa",
+ "editor_compliance_role_field_observer": "Observador de campo (§7.5)",
+ "editor_compliance_role_pcr_reviewer": "Revisor del PCR (§7.6)",
+ "editor_compliance_conformance_heading": "Conformidad con ASTM {standard}",
+ "editor_compliance_conforms": "Conforme",
+ "editor_compliance_does_not_conform": "No conforme",
+ "editor_compliance_check_pcr": "Aprobación del Revisor del PCR registrada",
+ "editor_compliance_check_psq": "PSQ recibido, o rechazado y divulgado",
+ "editor_compliance_check_doc_review": "Lista de verificación de Revisión de documentos iniciada",
+ "editor_compliance_signed": "Firmado",
+ "editor_compliance_not_signed": "No firmado",
+ "editor_compliance_license_suffix": " — Licencia {license}",
+ "editor_compliance_removing": "Quitando…",
+ "editor_compliance_person_id": "ID de la persona",
+ "editor_compliance_name": "Nombre",
+ "editor_compliance_license_optional": "Licencia (opcional)",
+ "editor_compliance_dual_role": "Rol doble (la misma persona firma ambos)",
+ "editor_compliance_signing": "Firmando…",
+ "editor_compliance_sign_off": "Aprobar",
+ "editor_compliance_remove_signoff_title": "Quitar la aprobación",
+ "editor_compliance_remove_signoff_msg": "¿Quitar la aprobación de {role}? Esto no se puede deshacer.",
+ "editor_compliance_status": "Estado",
+ "editor_compliance_psq_status": "Estado del PSQ",
+ "editor_compliance_psq_q_known_deficiencies": "Deficiencias físicas o daños conocidos",
+ "editor_compliance_psq_q_pending_violations": "Infracciones pendientes de código, incendios o zonificación",
+ "editor_compliance_psq_q_environmental": "Problemas ambientales conocidos",
+ "editor_compliance_psq_q_improvements": "Mejoras de capital planificadas o presupuestadas",
+ "editor_compliance_status_sent": "Enviado",
+ "editor_compliance_received": "Recibido",
+ "editor_compliance_status_declined": "Rechazado",
+ "editor_compliance_decline_psq_title": "Rechazar el PSQ",
+ "editor_compliance_decline": "Rechazar",
+ "editor_compliance_decline_body": "Rechazar el PSQ divulga la omisión en la sección de Desviaciones del informe (ASTM §11.4.3). Motivo:",
+ "editor_compliance_decline_placeholder": "Punto de contacto no disponible, se negó, …",
+ "editor_compliance_doc_requested": "Solicitado",
+ "editor_compliance_doc_reviewed": "Revisado",
+ "editor_compliance_doc_na": "N/A",
+ "editor_compliance_notes": "Notas",
+ "editor_compliance_no_docs": "Aún no hay documentos registrados.",
+ "editor_compliance_load_checklist": "Cargar la lista de verificación estándar",
+ "editor_compliance_reliance_userReliance": "Confianza — partes con derecho (§4.2.1)",
+ "editor_compliance_reliance_pointInTime": "Limitación al momento de la evaluación (§4.2.3)",
+ "editor_compliance_reliance_siteSpecific": "Alcance específico del sitio (§4.2.4)",
+ "editor_compliance_reliance_heading": "Confianza y limitaciones",
+ "editor_compliance_reliance_note": "Texto estándar precargado de ASTM §4.2.1–4.2.4. Edite cualquier campo para anular cómo se muestra en el informe; los cambios se guardan al salir del campo.",
+ "editor_compliance_panel_heading": "Cumplimiento (PCA completo)",
+ "editor_compliance_dual_signoff": "Aprobación doble",
+ "editor_compliance_psq_heading": "Cuestionario previo al estudio (§8.5)",
+ "editor_compliance_doc_review_heading": "Revisión de documentos (§8.6)",
+ "editor_pca_heading": "Narrativa del informe",
+ "editor_pca_saving": "(guardando…)",
+ "editor_pca_block_transmittal": "Carta de remisión",
+ "editor_pca_block_general_description": "1.1 Descripción general",
+ "editor_pca_block_physical_condition": "1.2 Condición física general",
+ "editor_pca_block_recommendations": "1.5 Recomendaciones",
+ "editor_pca_block_purpose": "2.1 Propósito",
+ "editor_pca_block_scope": "2.2 Alcance del trabajo (la metodología se incluye aquí)",
+ "editor_pca_block_limitations": "2.3 Limitaciones y excepciones",
+ "editor_pca_block_reconnaissance": "2.4 Reconocimiento general de la propiedad",
+ "editor_pca_block_additional": "Consideraciones adicionales"
}
From 85ee3a9e0fe24bfc0474a3dcdf4b0a1879640e5a Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:34:04 +0800
Subject: [PATCH 031/111] i18n(es-419): translate reports.json (258 keys)
---
messages/es-419/reports.json | 260 ++++++++++++++++++++++++++++++++++-
1 file changed, 259 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/reports.json b/messages/es-419/reports.json
index 006f618aa..20d56d4be 100644
--- a/messages/es-419/reports.json
+++ b/messages/es-419/reports.json
@@ -1,3 +1,261 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "portal_report_lock_heading": "Su informe aún no está disponible",
+ "portal_report_lock_agreement_body": "Firme el acuerdo de inspección para desbloquear su informe.",
+ "portal_report_lock_payment_body": "Complete el pago para desbloquear su informe.",
+ "portal_report_lock_agreement_cta": "Firmar el acuerdo",
+ "portal_report_lock_payment_cta": "Completar el pago",
+ "report_defect_trade_label": "Oficio recomendado:",
+ "report_defect_timeframe_label": "Plazo:",
+ "report_meta_title": "Informe - {name} - OpenInspection",
+ "report_meta_title_fallback": "Inspección",
+ "report_word_export_error_generic": "No se pudo iniciar la exportación a Word. Inténtelo de nuevo.",
+ "report_word_export_error_missing_id": "Falta exportId",
+ "report_word_export_error_not_found": "Exportación no encontrada",
+ "report_gate_meta_title": "Acceso al informe - OpenInspection",
+ "report_gate_status_payment": "Pago pendiente",
+ "report_gate_status_agreement": "Firma del acuerdo pendiente",
+ "report_gate_message_payment": "Su informe de inspección está listo, pero la factura aún no se ha pagado. Complete el pago para ver el informe -- los datos de contacto de su inspector aparecen abajo.",
+ "report_gate_message_agreement": "Su informe de inspección está listo, pero el acuerdo de inspección aún no se ha firmado. Firme el acuerdo para ver el informe.",
+ "report_gate_cta_pay": "Pagar {amount} ahora",
+ "report_gate_heading": "Su informe está casi listo.",
+ "report_gate_meta_amount_due": "Monto adeudado",
+ "report_gate_meta_property": "Propiedad",
+ "report_gate_meta_scheduled": "Programado",
+ "report_gate_meta_inspector": "Inspector",
+ "report_gate_meta_email": "Correo electrónico",
+ "report_gate_meta_phone": "Teléfono",
+ "report_gate_meta_license": "Licencia",
+ "report_gate_secured_by_stripe": "Protegido por Stripe · {company}",
+ "report_verify_meta_title": "Verificar el informe - OpenInspection",
+ "report_verify_agreement_text": "Este enlace es un token de verificación de acuerdo.",
+ "report_verify_agreement_link": "Ver la verificación del acuerdo",
+ "report_verify_error_unavailable": "Servicio de verificación no disponible",
+ "report_verify_notfound_heading": "No encontrado",
+ "report_verify_notfound_body": "Ningún informe o acuerdo coincide con este token de verificación.",
+ "report_verify_error_heading": "Error",
+ "report_verify_legacy_heading": "⚠️ Versión heredada",
+ "report_verify_legacy_body": "La integridad no se puede verificar criptográficamente — este informe se publicó antes de que se introdujera la firma.",
+ "report_verify_version": "Versión v{version} del informe",
+ "report_verify_published_suffix": " · Publicado el {date}",
+ "report_verify_notpublished_heading": "No publicado",
+ "report_verify_notpublished_body": "Este informe no está publicado.",
+ "report_verify_failed_heading": "❌ Verificación fallida",
+ "report_verify_failed_body": "No se pudo verificar la firma criptográfica de este informe. El contenido puede haber sido alterado.",
+ "report_verify_verified_heading": "✅ Verificado",
+ "report_verify_detail_property": "Propiedad",
+ "report_verify_detail_content_hash": "Hash del contenido",
+ "report_verify_detail_algorithm": "Algoritmo",
+ "report_verify_download_pdf": "Descargar el PDF firmado v{version}",
+ "report_verify_offline_meta_title": "Verificación sin conexión - OpenInspection",
+ "report_verify_offline_heading": "Verificación sin conexión",
+ "report_verify_offline_intro_before": "Suelte su ",
+ "report_verify_offline_intro_after": " abajo. Toda la verificación criptográfica se ejecuta en su navegador con la API Web Crypto — esta página no transmite el zip de vuelta a nuestro servidor.",
+ "report_verify_offline_dropzone": "Suelte evidence.zip aquí, o haga clic para seleccionarlo",
+ "report_verify_offline_verifying": "Verificando...",
+ "report_verify_offline_footer": "Para verificar sin conexión sin confiar en este servidor, use Ver código fuente en esta página, guárdela localmente junto con evidence.zip y abra el archivo HTML local en una sesión nueva del navegador. Toda la criptografía usa la API Web Crypto integrada del navegador; no se realizan solicitudes de red externas durante la verificación.",
+ "report_verify_offline_error_not_zip": "Suelte un archivo evidence.zip.",
+ "report_verify_offline_error_missing_files": "Falta audit-trail.json o public-key.pem en el zip.",
+ "report_verify_offline_result_valid": "✓ Se verificaron los {count} eventos de la cadena.",
+ "report_verify_offline_result_invalid": "✗ Se encontraron {count} error(es)",
+ "portal_landing_meta_title": "Portal del cliente - OpenInspection",
+ "portal_brand_logo_alt": "Empresa",
+ "portal_brand_eyebrow_fallback": "Portal del cliente",
+ "portal_landing_authed_heading": "Mis inspecciones",
+ "portal_landing_signed_in_as": "Sesión iniciada como {email}",
+ "portal_signout": "Cerrar sesión",
+ "portal_landing_signin_heading": "Inicie sesión en su portal",
+ "portal_landing_signin_subtitle": "Ingrese su correo electrónico y le enviaremos un enlace seguro para iniciar sesión.",
+ "portal_landing_sent_title": "Revise su correo electrónico para ver el enlace de inicio de sesión.",
+ "portal_landing_sent_body": "Si una cuenta coincide con esa dirección, el enlace va en camino. Vence en 15 minutos.",
+ "portal_landing_sent_recovery": "¿No recibió el correo en unos minutos? Revise su carpeta de spam y asegúrese de haber usado el mismo correo que su inspector tiene registrado. ¿Sigue sin funcionar? Comuníquese con su empresa de inspección.",
+ "portal_landing_email_label": "Correo electrónico",
+ "portal_landing_email_placeholder": "nombre@ejemplo.com",
+ "portal_landing_submit_pending": "Enviando…",
+ "portal_landing_submit": "Envíenme un enlace de inicio de sesión",
+ "portal_auth_meta_title": "Iniciando sesión - OpenInspection",
+ "portal_auth_expired_heading": "Este enlace ha vencido",
+ "portal_auth_expired_body": "Los enlaces de inicio de sesión vencen después de 15 minutos. Solicite uno nuevo para continuar.",
+ "portal_auth_expired_cta": "Solicitar un enlace nuevo",
+ "portal_inspection_meta_title": "Inspección - OpenInspection",
+ "portal_inspection_doc_upload_error": "Error al subir. Inténtelo de nuevo.",
+ "portal_inspection_doc_delete_error": "No se pudo eliminar el documento. Inténtelo de nuevo.",
+ "portal_hub_nav_overview": "Vista general",
+ "portal_hub_nav_report": "Informe",
+ "portal_hub_nav_agreement": "Acuerdo",
+ "portal_hub_nav_payment": "Pago",
+ "portal_hub_nav_progress": "Progreso",
+ "portal_hub_nav_messages": "Mensajes",
+ "portal_hub_nav_repair": "Solicitud de reparación",
+ "portal_hub_nav_documents": "Documentos",
+ "portal_address_fallback": "Inspección",
+ "portal_status_appointment_label": "Cita",
+ "portal_status_agreement_label": "Acuerdo",
+ "portal_status_payment_label": "Pago",
+ "portal_status_report_label": "Informe",
+ "portal_status_progress_label": "Progreso",
+ "portal_status_messages_label": "Mensajes",
+ "portal_status_agreement_signed": "Firmado",
+ "portal_status_agreement_unsigned": "No firmado",
+ "portal_status_report_published": "Publicado",
+ "portal_status_report_unpublished": "No publicado",
+ "portal_status_messages_unread": "{count} sin leer",
+ "portal_status_messages_none": "No hay mensajes nuevos",
+ "portal_list_empty_title": "Aún no hay inspecciones",
+ "portal_list_empty_description": "Las inspecciones compartidas con usted aparecerán aquí.",
+ "portal_list_report_published": "Informe publicado",
+ "portal_list_report_pending": "Informe pendiente",
+ "portal_agreement_error_sign_failed": "Error al firmar. Inténtelo de nuevo.",
+ "portal_agreement_error_decline_failed": "Error al rechazar. Inténtelo de nuevo.",
+ "portal_agreement_error_draw_signature": "Dibuje su firma antes de enviar.",
+ "portal_agreement_notfound_title": "Acuerdo no encontrado",
+ "portal_agreement_none_title": "Sin acuerdo",
+ "portal_agreement_none_body": "Ningún acuerdo requiere su firma.",
+ "portal_agreement_declined_title": "Gracias",
+ "portal_agreement_declined_body": "Se notificó al inspector que usted rechazó este acuerdo.",
+ "portal_agreement_eyebrow": "Documento para firma",
+ "portal_agreement_for_signer": "Para {name}",
+ "portal_agreement_signature_x_of_y": "Firma {current} de {total}",
+ "portal_agreement_any_one_completes": " · cualquier firma lo completa",
+ "portal_agreement_signed_title": "Firmado correctamente",
+ "portal_agreement_already_signed_title": "Ya está firmado",
+ "portal_agreement_waiting_others": "Gracias. Estamos a la espera de otro{plural} ({signed} de {total} ya firmaron).",
+ "portal_agreement_thanks_signed": "Gracias por firmar este acuerdo.",
+ "portal_agreement_download_pdf": "Descargar como PDF",
+ "portal_agreement_print_hint": "En el cuadro de diálogo de impresión, elija \"Guardar como PDF\" como destino.",
+ "portal_agreement_verify_link": "Verificar esta firma",
+ "portal_agreement_verify_hint": "Use este enlace en cualquier momento para confirmar que esta firma no ha sido alterada.",
+ "portal_agreement_draw_prompt": "Dibuje su firma abajo:",
+ "portal_agreement_signing_pending": "Firmando...",
+ "portal_agreement_sign_submit": "Firmar el acuerdo",
+ "portal_agreement_cancel_decline": "Cancelar el rechazo",
+ "portal_agreement_decline_toggle": "Rechazar este acuerdo",
+ "portal_agreement_reason_label": "Motivo (opcional)",
+ "portal_agreement_reason_placeholder": "Explique al inspector el motivo...",
+ "portal_agreement_declining_pending": "Enviando...",
+ "portal_agreement_decline_submit": "Rechazar el acuerdo",
+ "portal_messages_inspection_label": "Inspección: {address}",
+ "portal_messages_empty_title": "Aún no hay mensajes",
+ "portal_messages_empty_body": "Envíe el primero abajo.",
+ "portal_messages_compose_placeholder": "Escriba su mensaje...",
+ "portal_messages_send_pending": "Enviando...",
+ "portal_messages_send": "Enviar",
+ "portal_progress_notfound_title": "Inspección no encontrada",
+ "portal_progress_notfound_body": "Este enlace de observación no es válido o ha vencido.",
+ "portal_progress_inspector_label": "Inspector: {name}",
+ "portal_repair_gate_no_access_title": "Se requiere acceso",
+ "portal_repair_gate_no_access_body": "Necesita un token válido o iniciar sesión para ver esta página.",
+ "portal_repair_gate_not_published_title": "Informe no publicado",
+ "portal_repair_gate_not_published_body": "El informe debe estar publicado antes de que pueda crear una solicitud de reparación.",
+ "portal_repair_gate_forbidden_title": "Función no disponible",
+ "portal_repair_gate_forbidden_body": "El generador de solicitudes de reparación no está habilitado para esta empresa de inspección.",
+ "portal_repair_gate_error_title": "Algo salió mal",
+ "portal_repair_gate_error_body": "No se pudo cargar el generador de solicitudes de reparación. Inténtelo de nuevo.",
+ "portal_repair_eyebrow": "Generador de solicitudes de reparación",
+ "portal_repair_heading": "Seleccione los elementos que quiere incluir",
+ "portal_repair_subtitle": "Marque los defectos por los que quiere solicitar reparación o crédito. Agregue montos y notas para cada uno.",
+ "portal_repair_sort_by": "Ordenar por:",
+ "portal_repair_sort_section": "Sección",
+ "portal_repair_sort_category": "Categoría",
+ "portal_repair_sort_severity": "Gravedad",
+ "portal_repair_category_safety": "Seguridad",
+ "portal_repair_category_recommendation": "Recomendación",
+ "portal_repair_category_maintenance": "Mantenimiento",
+ "portal_repair_deselect_all": "Deseleccionar todo",
+ "portal_repair_select_all": "Seleccionar todo",
+ "portal_repair_empty": "No se encontraron defectos calificados para reparación en este informe.",
+ "portal_repair_items_selected": "Selección: {count} elemento{plural}",
+ "portal_repair_requested": "solicitado",
+ "portal_repair_copy_share": "Copiar el enlace para compartir",
+ "portal_repair_copied": "¡Copiado!",
+ "portal_repair_copy_failed": "Error al copiar",
+ "portal_invoice_paid_stamp": "Pagado",
+ "portal_invoice_processing_stamp": "Procesando",
+ "portal_invoice_status_processing": "procesando",
+ "portal_invoice_finalizing_short": "Finalizando el recibo",
+ "portal_invoice_eyebrow": "Factura",
+ "portal_invoice_field_from": "De",
+ "portal_invoice_field_bill_to": "Facturar a",
+ "portal_invoice_field_issued": "Emisión",
+ "portal_invoice_field_due": "Vencimiento",
+ "portal_invoice_due_on_receipt": "A la recepción",
+ "portal_invoice_col_description": "Descripción",
+ "portal_invoice_col_amount": "Monto",
+ "portal_invoice_no_line_items": "No hay partidas.",
+ "portal_invoice_subtotal": "Subtotal",
+ "portal_invoice_discount": "Descuento",
+ "portal_invoice_total": "Total",
+ "portal_invoice_amount_paid": "Monto pagado",
+ "portal_invoice_balance": "Saldo",
+ "portal_invoice_balance_due": "Saldo pendiente",
+ "portal_invoice_payment_received": "Pago recibido — gracias.",
+ "portal_invoice_finalizing": "Estamos finalizando su recibo; su factura pagada aparecerá aquí en breve.",
+ "portal_invoice_keep_receipt": "Conserve este recibo para sus registros.",
+ "portal_pay_this_invoice": "Pagar esta factura",
+ "portal_pay_starting_checkout": "Iniciando el pago seguro…",
+ "portal_pay_amount": "Pagar {amount}",
+ "portal_pay_secured_no_signature": "Protegido por Stripe · No se requiere firma",
+ "portal_pay_already_paid": "Esta factura ya fue pagada. Actualice la página para ver su recibo.",
+ "portal_pay_unavailable_before": "El pago seguro con tarjeta en línea no está disponible en este momento. Comuníquese con",
+ "portal_pay_unavailable_after": "para coordinar el pago.",
+ "portal_pay_inspector_fallback": "su inspector",
+ "portal_pay_error_generic": "No se pudo completar el pago. Inténtelo de nuevo.",
+ "portal_pay_processing": "Procesando…",
+ "portal_pay_secured": "Protegido por Stripe",
+ "report_view_cover_unavailable": "Foto de portada no disponible",
+ "report_view_not_published_title": "Este informe no está publicado",
+ "report_view_not_published_message": "Este informe no está publicado. Comuníquese con su inspector si cree que esto es un error.",
+ "report_view_unavailable_title": "Informe no disponible",
+ "report_view_load_error": "No pudimos cargar este informe en este momento. Inténtelo de nuevo en unos instantes.",
+ "report_view_logo_alt": "Logotipo",
+ "report_view_cert_with_company": "{company} · Informe de inspección certificado",
+ "report_view_cert": "Informe de inspección certificado",
+ "report_view_build_repair": "Crear una solicitud de reparación",
+ "agent_report_actions_workspace_hint": "Ya tiene una cuenta de agente con este correo — le enviaremos un enlace seguro para iniciar sesión en su espacio de trabajo.",
+ "agent_report_actions_workspace_cta": "Envíenme un enlace de inicio de sesión",
+ "agent_report_actions_workspace_pending": "Enviando…",
+ "agent_report_actions_workspace_sent": "Revise su correo — le enviamos un enlace de inicio de sesión de un solo uso a su bandeja de entrada. Vence en 15 minutos.",
+ "agent_report_actions_workspace_error": "No pudimos enviar su enlace de inicio de sesión en este momento. Inténtelo de nuevo en unos instantes.",
+ "agent_report_actions_signup_hint": "Cree una cuenta de agente gratuita para hacer seguimiento de las referencias en cada inspección que envíe.",
+ "agent_report_actions_signup_cta": "Cree su cuenta de agente gratuita",
+ "report_view_print": "Imprimir",
+ "report_view_inspector": "Inspector: {name}",
+ "report_view_na": "N/A",
+ "report_view_stat_total": "Total",
+ "report_view_filter_all": "Todo",
+ "report_view_filter_defects": "Solo defectos",
+ "report_view_filter_summary": "Resumen",
+ "report_view_section_items": "{count} elementos",
+ "report_view_recommend": "Recomendación: {value}",
+ "report_view_estimated_cost_label": "Costo estimado:",
+ "report_view_add_to_repair": "Agregar a la solicitud de reparación",
+ "report_view_items_inspected": "{count} elementos inspeccionados",
+ "report_view_defect_count": "{count} defecto{plural}",
+ "report_view_all_clear": "Todo en orden",
+ "report_view_disclaimer": "Aviso legal",
+ "report_view_download_pdf": "Descargar el PDF",
+ "portal_payment_no_invoice_title": "Aún no hay factura",
+ "portal_payment_no_invoice_body": "Todavía no hay una factura para esta inspección.",
+ "portal_payment_download_pdf": "Descargar el PDF",
+ "portal_payment_questions": "¿Preguntas? Comuníquese con {name}.",
+ "portal_payment_privacy_policy": "Política de privacidad",
+ "repair_defect_credit_label": "Solicitud de crédito ($)",
+ "repair_defect_credit_aria": "Solicitud de crédito para {label}",
+ "repair_defect_note_label": "Nota",
+ "repair_defect_note_placeholder": "Opcional — describa el motivo de la reparación o del crédito",
+ "repair_defect_location_prefix": "Ubicación:",
+ "repair_defect_estimate_hint": "Presupuesto del informe: ${low}–${high}",
+ "repair_defect_use_estimate": "Usar el presupuesto",
+ "repair_intro_heading": "Introducción personalizada",
+ "repair_intro_placeholder": "Agregue un mensaje personal para que aparezca al inicio de la solicitud de reparación compartida…",
+ "repair_share_heading": "Compartir",
+ "repair_share_view_pdf": "Ver como PDF",
+ "repair_share_email_heading": "Enviar por correo al contratista",
+ "repair_share_email_placeholder": "contratista@ejemplo.com",
+ "repair_share_email_msg_placeholder": "Mensaje opcional…",
+ "repair_share_email_submit": "Enviar el correo",
+ "repair_share_email_sent": "Correo enviado.",
+ "report_link_inactive_title": "Este enlace del informe no funciona",
+ "report_link_inactive_message": "Verifique la dirección web que abrió. Si es correcta, es posible que el enlace haya vencido o que se haya reemplazado por uno más nuevo — su inspector puede enviarle un enlace nuevo.",
+ "report_link_inactive_message_company": "Verifique la dirección web que abrió. Si es correcta, es posible que el enlace haya vencido o que se haya reemplazado por uno más nuevo — {company} puede enviarle un enlace nuevo."
}
From 0425efbf385fc3fb7d5b465bb6807a794ff65b84 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:36:59 +0800
Subject: [PATCH 032/111] i18n(es-419): translate pca-report.json (130 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Also records the client-portal and invoice vocabulary as glossary rows (21).
reports.json shares 58 English strings with checkout, inspections,
communication, booking, components, misc and public — more overlap than any
other module — so the waves that own those files inherit these decisions
instead of re-deciding them and red-gating the consistency check.
---
docs/developers/i18n-glossary.md | 33 ++++++++
messages/es-419/pca-report.json | 132 ++++++++++++++++++++++++++++++-
2 files changed, 164 insertions(+), 1 deletion(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index 72bf6b950..ad404e82f 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -321,6 +321,39 @@ character for character, so the consistency check binds them together.
| Immediate / Short-term / Long-term | Inmediato / Corto plazo / Largo plazo | — | The three cost buckets. Masculine singular — they label a bucket, like a status. |
| Conforms / Does not conform | Conforme / No conforme | — | The ASTM conformance statement. |
+## The client portal, invoices and verification
+
+Fixed while translating `reports.json` (258 keys) and `pca-report.json` (130).
+`reports.json` shares 58 English strings with `checkout`, `inspections`,
+`communication`, `booking`, `components`, `misc` and `public` — more overlap
+than any other module — so these are the rows the client-facing waves inherit.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Email / Email address | Correo electrónico | — | The full form both times. *Email* untranslated reads as a loanword the rest of the catalogue avoids, and *correo* alone is postal mail. |
+| Phone | Teléfono | — | |
+| Property | Propiedad | — | Also the noun in the Product nouns table; repeated here because it is a field label in six modules. |
+| Amount | Monto | — | Not *cantidad*, which is a count. "Amount due" → *Monto adeudado*; "Amount paid" → *Monto pagado*. |
+| Item | Elemento | — | A row in a list or a table. Same word as a template item — they never collide on one surface. |
+| Section | Sección | — | |
+| Document | Documento | — | |
+| Note | Nota | — | Singular of *notas*. |
+| Logo | Logotipo | — | *Logo* is understood but *logotipo* is the written form. |
+| Overview | Vista general | — | Deliberately different from Summary → *Resumen*: the client portal shows both, as a nav tab and as a report filter. |
+| Sign in (verb) / sign-in link | iniciar sesión / enlace de inicio de sesión | — | "Sign out" → *Cerrar sesión*. Distinct from *Firmar*, which signs a document — English uses "sign" for both and Spanish must not. |
+| Please try again. | Inténtelo de nuevo. | — | The recovery sentence on roughly twenty error strings. One wording, *usted* imperative. "Please try again in a moment." → *Inténtelo de nuevo en unos instantes.* |
+| Something went wrong | Algo salió mal | — | |
+| Copied! | ¡Copiado! | — | Opening exclamation mark is not optional in Spanish. |
+| Processing… | Procesando… | — | |
+| Secured by Stripe | Protegido por Stripe | — | Not *Asegurado*, which means insured. |
+| Privacy Policy | Política de privacidad | — | |
+| Repair Request Builder | Generador de solicitudes de reparación | — | The client-facing tool. The document it produces is the *solicitud de reparación*. |
+| Condition | Condición | — | The commercial systems-summary column. |
+| Compliance | Cumplimiento | — | |
+| Not applicable | No aplica | — | Spelled out where English spells it out; *N/A* stays *N/A*. |
+
## Register enforcement
Every entry here is wrong in `es-419` in every context, which is what makes it
diff --git a/messages/es-419/pca-report.json b/messages/es-419/pca-report.json
index 006f618aa..fd5650c6b 100644
--- a/messages/es-419/pca-report.json
+++ b/messages/es-419/pca-report.json
@@ -1,3 +1,133 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "pca_severity_good": "Bueno",
+ "pca_severity_minor": "No aplica",
+ "pca_severity_marginal": "Marginal",
+ "pca_severity_significant": "Significativo",
+ "pca_signed_date": "Firmado el {date}",
+ "pca_building_profile_title": "Perfil del edificio",
+ "pca_building_profile_group_identity": "Identidad",
+ "pca_building_profile_group_physical": "Físico",
+ "pca_building_profile_group_occupancy": "Ocupación",
+ "pca_building_profile_group_compliance": "Cumplimiento",
+ "pca_building_profile_group_utilities": "Servicios públicos",
+ "pca_building_profile_group_maintenance": "Mantenimiento",
+ "pca_conformance_verb_conforms": "es conforme",
+ "pca_conformance_verb_does_not_conform": "no es conforme",
+ "pca_conformance_statement": "Este informe {verb} con ASTM {standard}.",
+ "pca_cost_photo_ref": "Foto {n}",
+ "pca_cost_table1_title": "Opinión de Costo — Mantenimiento diferido",
+ "pca_cost_col_item": "Elemento",
+ "pca_cost_col_qty": "Cant.",
+ "pca_cost_col_unit": "Unidad",
+ "pca_cost_col_unit_cost": "Costo unitario",
+ "pca_cost_col_immediate": "Inmediato",
+ "pca_cost_col_short_term": "Corto plazo",
+ "pca_cost_col_comments": "Comentarios",
+ "pca_cost_totals": "Totales",
+ "pca_cost_reserve_title": "Programa de reservas para reemplazo de capital",
+ "pca_cost_col_eul": "EUL",
+ "pca_cost_col_eff_age": "Edad efect.",
+ "pca_cost_col_rul": "RUL",
+ "pca_cost_col_photo_no": "Foto n.º",
+ "pca_cost_col_total": "Total",
+ "pca_cost_total_uninflated": "Total sin inflación",
+ "pca_cost_cumulative_inflated": "Acumulado con inflación",
+ "pca_cost_per_sf_uninflated_all": "Por pie² (sin inflación, todos los años)",
+ "pca_cost_per_sf_inflated_all": "Por pie² (con inflación, todos los años)",
+ "pca_cost_per_sf_inflated_per_year": "Por pie² (con inflación, por año)",
+ "pca_docreview_col_document": "Documento",
+ "pca_docreview_col_status": "Estado",
+ "pca_docreview_col_notes": "Notas",
+ "pca_docreview_status_na": "No aplica",
+ "pca_docreview_status_received_reviewed": "Recibido y revisado",
+ "pca_docreview_status_received": "Recibido",
+ "pca_docreview_status_requested": "Solicitado",
+ "pca_docreview_status_not_requested": "No solicitado",
+ "pca_docreview_limitation_badge": "no proporcionado (limitación)",
+ "pca_skeleton_transmittal_letter": "Carta de remisión",
+ "pca_skeleton_summary": "1. Resumen",
+ "pca_skeleton_summary_general_description": "1.1 Descripción general",
+ "pca_skeleton_summary_physical_condition": "1.2 Condición física general",
+ "pca_skeleton_summary_opinion_of_cost": "1.3 Opinión de Costo",
+ "pca_skeleton_summary_deviations": "1.4 Desviaciones de la Guía",
+ "pca_skeleton_no_deviations": "Sin desviaciones de la Guía.",
+ "pca_skeleton_deviation_baseline_reason": "Referencia: {baseline} — Motivo: {reason}",
+ "pca_skeleton_summary_recommendations": "1.5 Recomendaciones",
+ "pca_skeleton_introduction": "2. Introducción",
+ "pca_skeleton_introduction_purpose": "2.1 Propósito",
+ "pca_skeleton_introduction_scope_of_work": "2.2 Alcance del trabajo",
+ "pca_skeleton_introduction_limitations": "2.3 Limitaciones y excepciones",
+ "pca_skeleton_introduction_reconnaissance": "2.4 Reconocimiento general de la propiedad",
+ "pca_skeleton_introduction_user_reliance": "2.5 Confianza del usuario",
+ "pca_skeleton_reliance_default": "La relación del consultor con el cliente se divulga de acuerdo con ASTM E2018 §7.3.",
+ "pca_skeleton_chapter_property_description": "Descripción general de la propiedad",
+ "pca_skeleton_document_review": "Revisión de documentos y entrevistas",
+ "pca_skeleton_chapter_site": "Sitio",
+ "pca_skeleton_chapter_structural_envelope": "Estructura y envolvente del edificio",
+ "pca_skeleton_chapter_mep": "Mecánica, eléctrica y plomería",
+ "pca_skeleton_chapter_interior": "Elementos interiores",
+ "pca_skeleton_chapter_life_safety": "Seguridad humana / protección contra incendios",
+ "pca_skeleton_additional_considerations": "Consideraciones adicionales",
+ "pca_photo_appendix_title": "Apéndice B — Fotografías",
+ "pca_photo_appendix_photo_no": "Foto {n}.",
+ "pca_psq_title": "Apéndice E — Cuestionario de idoneidad de la propiedad",
+ "pca_psq_declined": "PSQ rechazado — vea Desviaciones.",
+ "pca_psq_pending": "PSQ enviado — respuesta pendiente.",
+ "pca_defect_card_inspector_added": "agregado por el inspector",
+ "pca_media_watch_walkthrough": "▶ Ver el recorrido",
+ "pca_media_download_title": "Descargar {name}",
+ "pca_repair_panel_title": "Solicitud de reparación",
+ "pca_repair_panel_empty": "No hay elementos seleccionados. Marque \"Agregar a la solicitud de reparación\" en las tarjetas de defectos de arriba.",
+ "pca_repair_panel_item_count": "{count} elementos",
+ "pca_repair_panel_export_pdf": "Exportar a PDF",
+ "pca_repair_panel_send": "Enviar al inspector",
+ "pca_toc_aria": "Tabla de contenido",
+ "pca_toc_title": "Tabla de contenido",
+ "pca_signature_draft_badge": "BORRADOR",
+ "pca_signature_draft_note": "Este informe no está firmado ni se ha publicado.",
+ "pca_signature_signed_by": "Inspeccionado y firmado por",
+ "pca_signature_img_alt": "Firma del inspector",
+ "pca_signature_credential_alt": "Insignia de credencial del inspector",
+ "pca_signature_license": "Licencia n.º {license}",
+ "pca_signature_timezone_note": "Todas las horas del informe se muestran en {tz}.",
+ "pca_signature_electronically_signed": "Firmado electrónicamente por {name}",
+ "pca_signature_nudge_before": "Suba su firma en ",
+ "pca_signature_nudge_strong": "Configuración → Perfil",
+ "pca_signature_nudge_after": " para mostrarla en los informes impresos.",
+ "pca_verification_title": "Documento verificado",
+ "pca_verification_published": "Publicado y firmado — versión v{version}",
+ "pca_verification_verify_at": "Verifique en",
+ "pca_verification_integrity_hash": "Hash de integridad: {hash}…",
+ "pca_signoff_role_field_observer": "Observador de campo",
+ "pca_signoff_role_pcr_reviewer": "Revisor del PCR",
+ "pca_signoff_title": "Firmas",
+ "pca_signoff_license": "Licencia {license}",
+ "pca_signoff_dual_role": "Atestación de rol doble: esta persona actuó en ambas capacidades de firma en este informe, conforme a ASTM E2018 §11.4.3.",
+ "pca_systems_summary_title": "Resumen de sistemas",
+ "pca_systems_col_system": "Sistema",
+ "pca_systems_col_condition": "Condición",
+ "pca_systems_col_safety": "Seguridad",
+ "pca_systems_col_recommendation": "Recomendación",
+ "pca_systems_col_maintenance": "Mantenimiento",
+ "pca_unit_matrix_title": "Matriz de condición de las unidades",
+ "pca_unit_matrix_col_unit": "Unidad",
+ "pca_unit_matrix_col_defects": "Defectos",
+ "pca_unit_matrix_exception": "Excepción",
+ "pca_unit_matrix_no_findings": "Sin hallazgos",
+ "pca_unit_matrix_count_safety": "S:{n}",
+ "pca_unit_matrix_count_recommendation": "R:{n}",
+ "pca_unit_matrix_count_maintenance": "M:{n}",
+ "pca_unit_sections_title": "Unidades con excepción — Detalle",
+ "pca_unit_sections_count_safety": "Seguridad: {n}",
+ "pca_unit_sections_count_recommendation": "Recomendación: {n}",
+ "pca_unit_sections_count_maintenance": "Mantenimiento: {n}",
+ "pca_word_export_error_start": "No se pudo iniciar la exportación a Word. Inténtelo de nuevo.",
+ "pca_word_export_error_status": "No se pudo consultar el estado de la exportación.",
+ "pca_word_export_error_failed": "La exportación a Word falló. Reintente.",
+ "pca_word_export_unavailable_title": "La exportación a Word no está configurada en esta instalación.",
+ "pca_word_export_label": "Exportar a Word",
+ "pca_word_export_download": "Descargar Word",
+ "pca_word_export_retry": "Error al exportar — Reintentar",
+ "pca_word_export_preparing": "Preparando…"
}
From 41438aeec65e00037ce34a600eb177f20fb3051e Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:47:11 +0800
Subject: [PATCH 033/111] i18n(es-419): translate settings.json (244 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The settings hub, profile, account/security, company, data and team pages.
Glossary gains a settings section: the chrome words here (Configuración,
Facturación, Rol, Zona horaria, Sin definir, Guardado.) recur across
settings-components, settings-integrations, misc and nav, so they are decided
once rather than re-argued per module. Settings is Configuracion, singular, and
only the plural Ajustes is banned — the singular ajuste already means preset.
Coverage 1427 -> 1671 of 4323.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
docs/developers/i18n-glossary.md | 39 +++++
messages/es-419/settings.json | 244 +++++++++++++++++++++++++++++++
2 files changed, 283 insertions(+)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index ad404e82f..77f298872 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -354,6 +354,45 @@ than any other module — so these are the rows the client-facing waves inherit.
| Compliance | Cumplimiento | — | |
| Not applicable | No aplica | — | Spelled out where English spells it out; *N/A* stays *N/A*. |
+## Settings, account and team
+
+Fixed while translating `settings.json` (244 keys). These words are the chrome
+of every settings surface and most of them recur in `settings-components`,
+`settings-integrations`, `misc`, `nav` and `communication`, so they are decided
+once here.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Settings | Configuración | ajustes | Singular. It is the page name, the breadcrumb and the nav entry; *Ajustes* is the other common rendering and picking one is the point. Only the **plural** is banned: the singular *ajuste* is the ordinary word for an adjustment and is already load-bearing elsewhere (*preajuste de estilo* = style preset), so banning it would false-fire. |
+| Account | Cuenta | — | |
+| Profile | Perfil | — | |
+| Billing | Facturación | — | The area. The document is a *factura*. |
+| Integrations | Integraciones | — | |
+| Automations | Automatizaciones | — | |
+| Communication | Comunicación | — | The settings section covering email and SMS delivery. |
+| Advanced | Avanzado | — | Masculine singular: it names a settings section, like a status names a state. |
+| Usage | Uso | — | |
+| Data | Datos | — | |
+| Connected applications | Aplicaciones conectadas | — | The authorized-MCP-client list. |
+| Company name | Nombre de la empresa | — | With the article. *Nombre de empresa* reads as a form field on a government paper. |
+| Timezone / Your timezone | Zona horaria / Su zona horaria | — | One word in English, two in Spanish; both halves of the pair are fixed so the company and personal settings match. |
+| Locale | Configuración regional | — | The language-and-number-format setting. Not *localización*, which is a place. |
+| Currency | Moneda | — | |
+| Branding | Marca | — | |
+| Role / Roles | Rol / Roles | — | The account role. *Rol* is the region-neutral form; *papel* is a theatre part. |
+| Export (settings action) | Exportar | — | Infinitive, matching the sibling *Importar contactos*. Used for the heading and the button alike. |
+| Import | Importar | — | |
+| GDPR | RGPD | — | The regulation's own Spanish acronym. Used in the Data and Compliance sections. |
+| Not set | Sin definir | — | The empty-value rendering of any account field. Invariant for gender, which is why it is not *No definido*. |
+| Pending | Pendiente | — | Invite and member state. Invariant for gender, so the status-label rule needs no help here. |
+| Signature | Firma | — | The drawn/uploaded mark. The email footer is the *firma de correo electrónico*; both are *firma* and they never share a surface. |
+| Saved. | Guardado. | — | The flash message, masculine singular by the status-label rule. |
+| Couldn't save that. Please try again. | No se pudo guardar. Inténtelo de nuevo. | — | Built from the two fixed halves: the impersonal failure and the one recovery sentence. |
+| Unknown action | Acción desconocida | — | The form-action fallback error, shared by several settings routes. |
+| Free plan limit reached | Límite del plan gratuito alcanzado | — | |
+
## Register enforcement
Every entry here is wrong in `es-419` in every context, which is what makes it
diff --git a/messages/es-419/settings.json b/messages/es-419/settings.json
index 3014e97da..e0153c020 100644
--- a/messages/es-419/settings.json
+++ b/messages/es-419/settings.json
@@ -1,5 +1,249 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
+ "settings_crumb_settings": "Configuración",
+ "settings_hub_section_personal": "Personal",
+ "settings_hub_section_team_company": "Equipo y empresa",
+ "settings_hub_section_workflow_integrations": "Flujo de trabajo e integraciones",
+ "settings_hub_section_communication": "Comunicación",
+ "settings_hub_section_compliance": "Cumplimiento",
+ "settings_hub_profile_title": "Perfil",
+ "settings_hub_profile_desc": "Identidad del inspector. Se muestra en los informes.",
+ "settings_hub_account_title": "Cuenta",
+ "settings_hub_account_desc": "Contraseña, doble factor, seguridad.",
+ "settings_hub_connected_apps_title": "Aplicaciones conectadas",
+ "settings_hub_connected_apps_desc": "Clientes MCP (por ejemplo, Claude) que usted autorizó.",
+ "settings_hub_inspection_title": "Flujo de trabajo de inspección",
+ "settings_hub_inspection_desc": "Valores predeterminados de clonación, avance automático, etiquetas fijadas.",
+ "settings_hub_schedule_title": "Mi agenda",
+ "settings_hub_schedule_desc": "Horario semanal, ausencias, sincronización del calendario.",
+ "settings_hub_company_title": "Empresa",
+ "settings_hub_company_desc": "Nombre de la empresa, logotipo, color de marca, tema del informe.",
+ "settings_hub_booking_title": "Reservas en línea",
+ "settings_hub_booking_desc": "Políticas de reserva y widget insertable.",
+ "settings_hub_services_title": "Servicios y catálogo",
+ "settings_hub_services_desc": "Tipos de inspección, tarifas, complementos.",
+ "settings_hub_contractor_types_title": "Tipos de contratista",
+ "settings_hub_contractor_types_desc": "Categorías de contratista recomendado para los elementos de reparación.",
+ "settings_hub_event_types_title": "Tipos de evento",
+ "settings_hub_event_types_desc": "Categorías de eventos del calendario para la programación.",
+ "settings_hub_inspection_types_title": "Tipos de inspección",
+ "settings_hub_inspection_types_desc": "Categorías de inspección personalizadas para su empresa.",
+ "settings_hub_inspection_roles_title": "Roles de inspección",
+ "settings_hub_inspection_roles_desc": "Los roles que un contacto puede tener en una inspección.",
+ "settings_hub_billing_title": "Facturación",
+ "settings_hub_billing_desc": "Plan de suscripción, método de pago, facturas.",
+ "settings_hub_integrations_title": "Integraciones",
+ "settings_hub_integrations_desc": "QuickBooks, claves de Stripe, conexiones de calendario y de API.",
+ "settings_hub_advanced_title": "Avanzado",
+ "settings_hub_advanced_desc": "Pagos, IA, integraciones.",
+ "settings_hub_data_title": "Datos",
+ "settings_hub_data_desc": "Importación, exportación, RGPD.",
+ "settings_hub_communication_title": "Comunicación",
+ "settings_hub_communication_desc": "Envío de correo electrónico y SMS. Los inspectores conectan sus calendarios en Mi agenda.",
+ "settings_hub_automations_title": "Automatizaciones",
+ "settings_hub_automations_desc": "Disparadores y reglas de correo electrónico.",
+ "settings_hub_compliance_title": "Cumplimiento",
+ "settings_hub_compliance_desc": "Páginas de Privacidad y Términos, retención según el RGPD, registros de eliminación.",
+ "settings_hub_usage_title": "Uso",
+ "settings_hub_usage_desc": "SMS, correos electrónicos y almacenamiento que ha usado esta cuenta.",
+ "settings_error_save_failed": "No se pudo guardar",
+ "settings_profile_crumb": "Perfil",
+ "settings_profile_subtitle": "La identidad del inspector que aparece en cada informe que usted genera.",
+ "settings_profile_flash_saved": "Perfil guardado.",
+ "settings_profile_name_label": "Nombre completo",
+ "settings_profile_name_placeholder": "Juan Pérez",
+ "settings_profile_name_hint": "Se muestra en los informes de inspección.",
+ "settings_profile_phone_label": "Teléfono",
+ "settings_profile_phone_placeholder": "(555) 123-4567",
+ "settings_profile_timezone_label": "Su zona horaria",
+ "settings_profile_timezone_hint": "Cambia cómo se muestran las horas solo para usted. Los informes y los eventos del calendario siempre usan la zona horaria de la empresa.",
+ "settings_profile_timezone_inherit_option": "Usar la zona horaria de la empresa",
+ "settings_profile_timezone_company_named": "Usar la zona horaria de la empresa ({zone})",
+ "settings_timezone_browser_hint": "La zona horaria de su navegador es {zone}.",
+ "settings_timezone_browser_use": "Usar esta",
+ "settings_profile_locale_label": "Su idioma / configuración regional",
+ "settings_profile_locale_hint": "Cambia el formato de las fechas, las horas y los números solo para usted. Déjelo en el valor predeterminado del espacio de trabajo para heredar la configuración de la empresa.",
+ "settings_profile_locale_inherit_option": "Usar el valor predeterminado del espacio de trabajo",
+ "settings_profile_photo_heading": "Foto de perfil",
+ "settings_profile_photo_subtitle": "Su imagen en la página pública de reservas de la empresa.",
+ "settings_profile_photo_none": "Sin foto",
+ "settings_profile_photo_alt": "Perfil",
+ "settings_profile_photo_hint": "JPG, PNG o WebP. Máximo 2 MB. El recorte cuadrado se ve mejor.",
+ "settings_notifications_eyebrow": "Notificaciones",
+ "settings_notifications_heading": "Lo que le enviamos",
+ "settings_notifications_desc": "Estos son los mensajes dirigidos a usted personalmente. Las alertas que recibe toda su empresa se configuran en Automatizaciones.",
+ "settings_notifications_error": "No se pudo guardar. Inténtelo de nuevo.",
+ "settings_notifications_unavailable": "No se pudo cargar su configuración de notificaciones. Vuelva a cargar la página para intentarlo de nuevo.",
+ "settings_profile_credentials_heading": "Licencias y afiliaciones",
+ "settings_profile_credentials_subtitle": "Su licencia y las membresías de asociaciones que tenga. Aparecen en sus informes, en su firma de correo electrónico y en su página de reservas. Suba la imagen de una insignia o agregue una entrada solo de texto.",
+ "settings_profile_credentials_empty": "Todavía no hay nada aquí. Agregue su licencia o una membresía de asociación para mostrarla en sus informes.",
+ "settings_profile_credentials_details_summary": "Detalles (etiqueta · n.º de miembro)",
+ "settings_profile_credentials_label_placeholder": "por ejemplo, Inspector de viviendas con licencia, o InterNACHI CPI",
+ "settings_profile_credentials_member_placeholder": "N.º de licencia o de miembro (opcional)",
+ "settings_profile_credentials_remove": "Quitar",
+ "settings_profile_credentials_add": "+ Agregar licencia o afiliación",
+ "settings_profile_signature_heading": "Firma de correo electrónico",
+ "settings_profile_signature_subtitle": "El pie tipo tarjeta de presentación que se agrega a los correos que usted envía. Se arma con los campos de arriba: guarde su perfil para actualizar la vista previa.",
+ "settings_profile_signature_toggle": "Agregar a mis correos electrónicos",
+ "settings_profile_signature_preview_label": "Vista previa",
+ "settings_profile_signature_empty": "Agregue arriba su nombre (y su teléfono o licencia) para armar una firma.",
+ "settings_profile_save_button": "Guardar perfil",
+ "settings_profile_saved_signature_heading": "Firma",
+ "settings_profile_saved_signature_subtitle": "La marca que se aplica a los acuerdos que usted envía y a los informes que publica.",
+ "settings_profile_signature_saved_flash": "Firma guardada.",
+ "settings_profile_signature_pad_save": "Guardar firma",
+ "settings_profile_signature_update": "Actualizar firma",
+ "settings_profile_signature_add": "Agregar firma",
+ "settings_profile_error_no_signature": "No se proporcionaron datos de firma",
+ "settings_profile_error_no_photo": "No se proporcionó una foto válida",
+ "settings_profile_error_upload_failed": "No se pudo subir el archivo",
+ "settings_profile_error_reorder_failed": "No se pudo guardar el nuevo orden",
+ "settings_profile_credentials_move_up": "Subir",
+ "settings_profile_credentials_move_down": "Bajar",
+ "settings_profile_credentials_primary_badge": "Se muestra junto a su firma",
+ "settings_security_crumb": "Cuenta y seguridad",
+ "settings_security_subtitle": "Contraseña, autenticación de dos factores, datos de la cuenta y configuración de seguridad.",
+ "settings_security_flash_saved": "Guardado.",
+ "settings_security_account_details_heading": "Detalles de la cuenta",
+ "settings_security_email_label": "Correo electrónico",
+ "settings_security_name_label": "Nombre",
+ "settings_security_not_set": "Sin definir",
+ "settings_security_sessions_heading": "Sesiones activas",
+ "settings_security_sessions_current": "Sesión actual",
+ "settings_security_sessions_active_now": "Activo ahora",
+ "settings_security_sessions_coming_soon": "La gestión completa de sesiones estará disponible pronto.",
+ "settings_security_danger_heading": "Zona de peligro",
+ "settings_security_delete_title": "Eliminar cuenta",
+ "settings_security_delete_description": "Elimine de forma permanente su cuenta y todos los datos asociados, incluidas las inspecciones, los informes, las plantillas y los registros de clientes. Esta acción no se puede deshacer.",
+ "settings_security_delete_button": "Eliminar mi cuenta",
+ "settings_security_delete_confirm_label": "Vuelva a escribir su correo electrónico para confirmar",
+ "settings_security_delete_confirm_placeholder": "correo@ejemplo.com",
+ "settings_security_delete_confirm_button": "Eliminar de forma permanente",
+ "settings_security_error_password_change_failed": "No se pudo cambiar la contraseña",
+ "settings_security_error_turnstile_save_failed": "No se pudo guardar la clave de Turnstile.",
+ "settings_security_error_export_failed": "No se pudieron exportar los datos. Inténtelo de nuevo.",
+ "settings_security_export_success_message": "Exportación de datos completada. Los datos de su cuenta están disponibles abajo.",
+ "settings_security_error_delete_failed": "No se pudo eliminar la cuenta.",
+ "settings_security_delete_success_message": "Cuenta eliminada.",
+ "settings_security_error_unknown_action": "Acción desconocida",
+ "settings_workspace_crumb": "Empresa",
+ "settings_workspace_subtitle": "Marca, tema del informe y fuentes de referencia.",
+ "settings_workspace_flash_saved": "Configuración de la empresa guardada.",
+ "settings_workspace_branding_heading": "Marca",
+ "settings_workspace_company_name_label": "Nombre de la empresa",
+ "settings_workspace_primary_color_label": "Color principal",
+ "settings_workspace_logo_label": "Logotipo de la empresa",
+ "settings_workspace_timezone_heading": "Zona horaria",
+ "settings_workspace_timezone_subtitle": "Los informes, los recordatorios y los eventos del calendario usan esta zona horaria. Cada usuario puede cambiar en su perfil la forma en que se le muestran las horas.",
+ "settings_workspace_timezone_select_label": "Zona horaria de la empresa",
+ "settings_workspace_timezone_detected": "Detectada desde su navegador. Guarde para confirmar o elija otra.",
+ "settings_workspace_locale_currency_heading": "Configuración regional y moneda",
+ "settings_workspace_locale_currency_subtitle": "Controla el IDIOMA en que se escriben las fechas, las horas y los números, y la moneda en que se cobra. La forma de una fecha —si el día va antes del mes y si 14:30 se escribe 2:30 PM— es la configuración separada de abajo. Cada usuario puede cambiar el idioma en su perfil; la moneda es para toda la empresa.",
+ "settings_workspace_locale_select_label": "Configuración regional de la empresa",
+ "settings_workspace_currency_select_label": "Moneda",
+ "settings_workspace_report_style_heading": "Estilo del informe",
+ "settings_workspace_report_style_subtitle": "Elija una apariencia para los informes que ven los clientes. Su color de marca y su logotipo se aplican encima.",
+ "settings_workspace_referral_heading": "Fuentes de referencia",
+ "settings_workspace_referral_builtin_label": "Fuentes integradas",
+ "settings_workspace_referral_custom_label": "Etiquetas personalizadas",
+ "settings_workspace_referral_custom_placeholder": "Anuncio en revista\nFeria comercial\nSocio de referencia",
+ "settings_workspace_referral_custom_hint": "Una etiqueta por línea. Máximo 32 entradas; los duplicados se ignoran.",
+ "settings_workspace_report_features_heading": "Funciones del informe",
+ "settings_workspace_repair_list_title": "Mostrar la pestaña de lista de reparaciones",
+ "settings_workspace_repair_list_desc": "Muestra una pestaña de Lista de reparaciones resumida en el informe publicado del cliente.",
+ "settings_workspace_repair_export_title": "Permitir que los clientes generen solicitudes de reparación",
+ "settings_workspace_repair_export_desc": "Permite que los clientes, los agentes y los inspectores generen, a partir de un informe publicado, un anexo de solicitud de reparación que se puede compartir.",
+ "settings_workspace_report_pdf_heading": "PDF del informe",
+ "settings_workspace_report_pdf_subtitle": "Opciones de diseño de impresión para los PDF de informes descargables.",
+ "settings_workspace_company_address_label": "Dirección de la empresa",
+ "settings_workspace_company_address_placeholder": "123 Main St, Springfield, IL 62704",
+ "settings_workspace_company_address_hint": "Se muestra en el bloque del pie de página del PDF del informe.",
+ "settings_workspace_pdf_footer_title": "Mostrar el pie de página",
+ "settings_workspace_pdf_footer_desc": "Muestra el bloque de pie de página de la empresa al final de cada página del PDF del informe.",
+ "settings_workspace_pdf_page_numbers_title": "Mostrar los números de página",
+ "settings_workspace_pdf_page_numbers_desc": "Agrega números de página al PDF del informe.",
+ "settings_workspace_pdf_license_title": "Mostrar la licencia del inspector",
+ "settings_workspace_pdf_license_desc": "Incluye el número de licencia del inspector en el PDF del informe.",
+ "settings_workspace_save_button": "Guardar empresa",
+ "settings_workspace_error_no_logo": "No se proporcionó un logotipo válido",
+ "settings_data_meta_title": "Datos - Configuración - OpenInspection",
+ "settings_data_crumb": "Importación / exportación de datos",
+ "settings_data_subtitle": "Descargue sus datos o importe contactos desde otras plataformas.",
+ "settings_data_export_heading": "Exportar",
+ "settings_data_export_subtitle": "Descargue sus datos en formato CSV o JSON. Se incluyen todos los registros históricos.",
+ "settings_data_export_inspections_csv": "CSV de inspecciones",
+ "settings_data_export_contacts_csv": "CSV de contactos",
+ "settings_data_export_full_json": "JSON completo",
+ "settings_data_import_heading": "Importar contactos",
+ "settings_data_import_subtitle": "Admite los formatos de exportación de Spectora e Inspector Toolbelt. Los duplicados (mismo correo electrónico) se omiten.",
+ "settings_data_import_choose_file": "Elegir archivo CSV",
+ "settings_data_import_file_hint": "Máximo 5 MB, codificación UTF-8",
+ "settings_data_cleanup_heading": "Limpieza de datos",
+ "settings_data_cleanup_subtitle": "Quite los datos de prueba o solicite una exportación completa de datos según el RGPD.",
+ "settings_data_cleanup_delete_test": "Eliminar datos de prueba",
+ "settings_data_cleanup_gdpr_export": "Solicitar exportación RGPD",
+ "settings_usage_meta_title": "Uso - Configuración - OpenInspection",
+ "settings_usage_crumb": "Uso",
+ "settings_usage_subtitle_free": "Lo que esta cuenta ha usado en el plan gratuito. El almacenamiento se mide una vez al día.",
+ "settings_usage_subtitle_saas": "Lo que esta cuenta ha consumido. Las inspecciones, los SMS y los correos electrónicos son totales acumulados; el almacenamiento se mide una vez al día.",
+ "settings_usage_subtitle_standalone": "Lo que esta cuenta ha consumido. Los SMS y los correos electrónicos son totales acumulados; el almacenamiento se mide una vez al día.",
+ "settings_usage_metric_inspections": "Inspecciones",
+ "settings_usage_metric_inspections_sub": "Inspecciones creadas — acumulado",
+ "settings_usage_metric_sms": "SMS enviados",
+ "settings_usage_metric_sms_sub": "Mensajes de texto enviados — acumulado",
+ "settings_usage_metric_email": "Correos electrónicos enviados",
+ "settings_usage_metric_email_sub": "Correos electrónicos entregados — acumulado",
+ "settings_usage_metric_storage": "Almacenamiento usado",
+ "settings_usage_metric_storage_sub": "Fotos y documentos — medido a diario",
+ "settings_usage_back_to_billing": "← Volver a facturación y plan",
+ "settings_usage_cap_reached": "Límite del plan gratuito alcanzado",
+ "settings_usage_cap_remaining": "Quedan {count} en el plan gratuito",
+ "settings_usage_byo": "a través de su propia cuenta: {count}",
+ "settings_team_meta_title": "Equipo - OpenInspection",
+ "settings_team_crumb": "Equipo",
+ "settings_team_heading": "Equipo del espacio de trabajo",
+ "settings_team_member_singular": "miembro",
+ "settings_team_member_plural": "miembros",
+ "settings_team_invite_button": "Invitar miembro",
+ "settings_team_tab_active": "Activo",
+ "settings_team_tab_pending": "Invitaciones pendientes",
+ "settings_team_empty_pending_title": "No hay invitaciones pendientes",
+ "settings_team_empty_active_title": "No se encontraron miembros",
+ "settings_team_empty_desc": "Invite arriba a miembros del equipo para comenzar.",
+ "settings_team_col_name": "Nombre",
+ "settings_team_member_unnamed": "Sin nombre",
+ "settings_team_col_role": "Rol",
+ "settings_team_col_status": "Estado",
+ "settings_team_status_active": "Activo",
+ "settings_team_status_pending": "Pendiente",
+ "settings_team_col_last_active": "Última actividad",
+ "settings_team_roles_heading": "Roles",
+ "settings_team_role_owner_name": "Titular",
+ "settings_team_role_owner_desc": "Titular de la cuenta. Acceso total, incluida la facturación.",
+ "settings_team_role_manager_name": "Gerente",
+ "settings_team_role_manager_desc": "Administración interna: equipo, configuración, programación y todas las inspecciones.",
+ "settings_team_role_inspector_name": "Inspector",
+ "settings_team_role_inspector_desc": "Realiza inspecciones; edita y publica informes.",
+ "settings_team_role_agent_name": "Agente",
+ "settings_team_role_agent_desc": "No es un miembro del equipo ni ocupa una licencia. A los agentes se les da acceso a inspecciones individuales desde la sección Personas de esa inspección, y leen el informe mediante un enlace que no requiere cuenta.",
+ "settings_team_cancel_invite": "Cancelar invitación",
+ "settings_team_resend_invite": "Reenviar",
+ "settings_team_invite_expires_in": "Vence en {days} d",
+ "settings_team_invite_expired": "Venció hace {days} d",
+ "settings_team_cancel_invite_title": "¿Cancelar esta invitación?",
+ "settings_team_cancel_invite_confirm": "Se revocará la invitación enviada a {email}. Esa persona ya no podrá unirse con el enlace que recibió por correo electrónico.",
+ "settings_team_load_failed": "No se pudo cargar la lista del equipo, así que puede estar incompleta. Vuelva a cargar la página antes de suponer que alguien fue quitado.",
+ "settings_apps_load_failed": "No se pudieron cargar las aplicaciones conectadas. Esto no confirma que nada tenga acceso: vuelva a cargar la página antes de actuar.",
+ "settings_profile_saved_signature_alt": "Su firma guardada",
+ "settings_profile_signature_draw": "Dibujar firma",
+ "settings_profile_signature_upload": "Subir imagen",
+ "settings_profile_signature_empty_hint": "Todavía no hay ninguna firma. Dibuje una o suba una imagen de su firma.",
+ "settings_profile_signature_upload_too_big": "Esa imagen supera los 2 MB. Pruebe con una más pequeña.",
+ "settings_profile_signature_upload_bad_type": "Use una imagen PNG, JPEG, WebP o SVG.",
+ "settings_profile_signature_upload_unreadable": "No se pudo leer ese archivo como imagen.",
+ "settings_profile_signature_crop_aria": "Recortar firma",
+ "settings_profile_signature_crop_save": "Guardar firma",
+ "settings_profile_photo_choose": "Elegir foto",
"settings_workspace_datetime_format_heading": "Formato de fecha y hora",
"settings_workspace_datetime_format_subtitle": "La forma en que se escribe cada fecha y hora en toda su empresa, incluidos los informes y las páginas que leen sus clientes y agentes. El idioma es la configuración separada de arriba: decide cómo se llama \"septiembre\"; esta decide si el día va antes.",
"settings_workspace_dateformat_select_label": "Formato de fecha",
From f8f5f3087c5f9e9f477978ad8ff98d165f6c408e Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 12:51:27 +0800
Subject: [PATCH 034/111] i18n(es-419): translate settings-catalog.json (123
keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Services, inspection workflow, inspection/event/contractor types, My Schedule
and the report-link expiry controls. Glossary gains the catalogue subsection:
Slug is Identificador (the radon_pickup placeholder beside it stays English —
it is a format sample), and the report-link bulk actions are Aplicar/Quitar
vencimiento because Spanish vencer is intransitive and 'Expire N links' has no
verb-for-verb rendering.
Coverage 1671 -> 1794 of 4323.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
docs/developers/i18n-glossary.md | 19 ++++
messages/es-419/settings-catalog.json | 125 +++++++++++++++++++++++++-
2 files changed, 143 insertions(+), 1 deletion(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index 77f298872..e5ef429f7 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -393,6 +393,25 @@ once here.
| Unknown action | Acción desconocida | — | The form-action fallback error, shared by several settings routes. |
| Free plan limit reached | Límite del plan gratuito alcanzado | — | |
+### The services / event-types catalogue
+
+Fixed while translating `settings-catalog.json` (123 keys). These recur in
+`settings-components`, `contacts` and `public`.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Slug | Identificador | — | The URL-safe key of an event type or a booking page. *Slug* is untranslatable jargon in Spanish and the column shows values like `radon_pickup`, which explain themselves. The placeholder that shows that value stays English — it is a format sample. |
+| Duration | Duración | — | |
+| Platform | Plataforma | — | The vendor-shipped tier of inspection types, against *Su organización*. |
+| Enabled / Disabled | Habilitado / Deshabilitado | — | Paired with the verbs *Habilitar* / *Deshabilitar* so the chip and the button that flips it share a root. |
+| Inactive | Inactivo | — | Masculine singular by the status-label rule. |
+| My Schedule | Mi agenda | — | Uses the Schedule (noun) row: *agenda*, not *horario*, which is the weekly-hours grid inside it. |
+| No access / View only / View and edit | Sin acceso / Solo lectura / Ver y editar | — | The three capability levels, shared by the workflow settings and the contact-role modal. *Solo lectura* is the recognised access level; the third stays a verb pair because English does. |
+| Sort order | Orden de clasificación | — | |
+| Report link expiry | Vencimiento del enlace del informe | — | *Vencimiento*, matching Expired → *Vencido*. The bulk actions are phrased as *Aplicar vencimiento a…* / *Quitar vencimiento de…*: Spanish *vencer* is intransitive, so "Expire N links" has no verb-for-verb rendering. |
+
## Register enforcement
Every entry here is wrong in `es-419` in every context, which is what makes it
diff --git a/messages/es-419/settings-catalog.json b/messages/es-419/settings-catalog.json
index 006f618aa..cc29b02a9 100644
--- a/messages/es-419/settings-catalog.json
+++ b/messages/es-419/settings-catalog.json
@@ -1,3 +1,126 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "settings_common_saving": "Guardando...",
+ "settings_services_meta_title": "Servicios y catálogo - Configuración - OpenInspection",
+ "settings_services_crumb": "Servicios y catálogo",
+ "settings_services_error_create_failed": "No se pudo crear el servicio.",
+ "settings_services_error_invalid_user_ids": "Formato de identificadores de usuario no válido.",
+ "settings_services_error_save_restrictions_failed": "No se pudieron guardar las restricciones.",
+ "settings_services_intro": "Defina los servicios que ofrece y sus precios, además de los códigos de descuento.",
+ "settings_services_add_button": "+ Agregar servicio",
+ "settings_services_name_label": "Nombre",
+ "settings_services_name_placeholder": "por ejemplo, Inspección estándar",
+ "settings_services_description_label": "Descripción",
+ "settings_services_description_placeholder": "Detalles opcionales",
+ "settings_services_price_label": "Precio",
+ "settings_inspection_meta_title": "Configuración del flujo de trabajo de inspección - OpenInspection",
+ "settings_inspection_loading": "Cargando...",
+ "settings_inspection_crumb": "Flujo de trabajo de inspección",
+ "settings_inspection_intro": "Valores predeterminados que se aplican a todos los inspectores de este espacio de trabajo.",
+ "settings_inspection_clone_heading": "Valor predeterminado de Clonar último (tecla R)",
+ "settings_inspection_clone_rating": "Solo calificación",
+ "settings_inspection_clone_rating_notes": "Calificación + notas",
+ "settings_inspection_clone_all": "Todo (calificación + notas + fotos + etiquetas)",
+ "settings_inspection_autoadvance_heading": "Avance automático después de calificar",
+ "settings_inspection_autoadvance_keyboard": "Solo al calificar con el teclado (1-5 para recorrer rápido; los clics permanecen en el elemento)",
+ "settings_inspection_autoadvance_always": "Siempre (los clics y el teclado avanzan)",
+ "settings_inspection_autoadvance_off": "Nunca (permanecer siempre en el elemento)",
+ "settings_inspection_autoadvance_note": "Las calificaciones de tipo Defecto o Vigilar siempre permanecen en el elemento y activan Notas para que usted pueda describir el hallazgo.",
+ "settings_inspection_autoadvance_delay_value": "{ms} ms",
+ "settings_inspection_autoadvance_delay_help": "Espera antes de que el editor avance al siguiente elemento.",
+ "settings_inspection_required_heading": "Campos obligatorios del defecto al publicar",
+ "settings_inspection_required_help": "Campos que todo defecto debe tener antes de que se pueda publicar un informe. Cada inspección puede cambiar esto para su propio trabajo en la configuración de la inspección.",
+ "settings_inspection_required_none": "Ninguno — los campos faltantes advierten, nunca bloquean",
+ "settings_inspection_required_location": "Ubicación obligatoria",
+ "settings_inspection_required_trade": "Oficio recomendado obligatorio",
+ "settings_inspection_required_both": "Ubicación + oficio obligatorios",
+ "settings_inspection_agent_repair_heading": "Acceso del agente a las solicitudes de reparación",
+ "settings_inspection_agent_repair_hint": "Los agentes siempre pueden ver el informe. Esto controla la lista de solicitudes de reparación.",
+ "settings_inspection_agent_repair_off": "Sin acceso",
+ "settings_inspection_agent_repair_read": "Solo lectura",
+ "settings_inspection_agent_repair_readwrite": "Ver y editar",
+ "settings_inspection_pinned_heading": "Etiquetas fijadas ({count}/5)",
+ "settings_inspection_pinned_help": "Hasta 5 etiquetas que se muestran como fichas de un clic debajo del campo Notas.",
+ "settings_inspection_manage_tags": "Administrar la biblioteca de etiquetas",
+ "settings_inspection_types_crumb": "Tipos de inspección",
+ "settings_inspection_roles_heading": "Roles de inspección",
+ "settings_inspection_roles_meta_title": "Roles de inspección - OpenInspection",
+ "settings_inspection_types_platform_eyebrow": "Plataforma",
+ "settings_inspection_types_platform_desc": "Tipos estándar que vienen con la plataforma.",
+ "settings_inspection_types_counts": "{templates} plantillas · {inspections} inspecciones",
+ "settings_inspection_types_status_enabled": "Habilitado",
+ "settings_inspection_types_status_disabled": "Deshabilitado",
+ "settings_inspection_types_org_eyebrow": "Su organización",
+ "settings_inspection_types_org_desc": "Tipos personalizados basados en los tipos de la plataforma.",
+ "settings_inspection_types_add_button": "+ Agregar subtipo personalizado",
+ "settings_inspection_types_empty": "Todavía no hay subtipos personalizados.",
+ "settings_inspection_types_based_on": "Basado en {name}",
+ "settings_inspection_types_action_disable": "Deshabilitar",
+ "settings_inspection_types_action_enable": "Habilitar",
+ "settings_inspection_types_modal_edit_title": "Editar subtipo personalizado",
+ "settings_inspection_types_modal_add_title": "Agregar subtipo personalizado",
+ "settings_inspection_types_name_label": "Nombre",
+ "settings_inspection_types_name_placeholder": "por ejemplo, Consultorio médico",
+ "settings_inspection_types_based_on_label": "Basado en",
+ "settings_inspection_types_based_on_placeholder": "Seleccione un tipo de la plataforma...",
+ "settings_inspection_types_description_label": "Descripción",
+ "settings_inspection_types_description_placeholder": "Detalles opcionales...",
+ "settings_event_types_meta_title": "Tipos de evento - OpenInspection",
+ "settings_event_types_crumb": "Tipos de evento",
+ "settings_event_types_intro": "Defina los eventos complementarios de inspección que se pueden adjuntar a una inspección.",
+ "settings_event_types_add_button": "+ Agregar tipo",
+ "settings_event_types_empty_title": "Todavía no hay tipos de evento.",
+ "settings_event_types_empty_desc": "Haga clic en “+ Agregar tipo” para definir su primer tipo de evento.",
+ "settings_event_types_col_name": "Nombre",
+ "settings_event_types_col_slug": "Identificador",
+ "settings_event_types_col_duration": "Duración",
+ "settings_event_types_col_price": "Precio",
+ "settings_event_types_col_color": "Color",
+ "settings_event_types_col_actions": "Acciones",
+ "settings_event_types_inactive": "Inactivo",
+ "settings_event_types_duration_value": "{min} min",
+ "settings_event_types_modal_edit_title": "Editar tipo de evento",
+ "settings_event_types_modal_new_title": "Nuevo tipo de evento",
+ "settings_event_types_name_label": "Nombre",
+ "settings_event_types_name_placeholder": "por ejemplo, Prueba de radón - Retiro",
+ "settings_event_types_slug_label": "Identificador",
+ "settings_event_types_slug_placeholder": "radon_pickup",
+ "settings_event_types_duration_label": "Duración (min)",
+ "settings_event_types_price_label": "Precio",
+ "settings_event_types_color_label": "Color",
+ "settings_event_types_sort_label": "Orden de clasificación",
+ "settings_contractor_types_meta_title": "Tipos de contratista - OpenInspection",
+ "settings_contractor_types_crumb": "Tipos de contratista",
+ "settings_contractor_types_intro": "Categorías de contratista recomendado que se muestran en los elementos de reparación y en los informes.",
+ "settings_contractor_types_move_up_aria": "Subir {name}",
+ "settings_contractor_types_move_down_aria": "Bajar {name}",
+ "settings_contractor_types_rename": "Cambiar nombre",
+ "settings_contractor_types_delete_aria": "Eliminar {name}",
+ "settings_contractor_types_name_placeholder": "por ejemplo, Electricista con licencia",
+ "settings_contractor_types_empty": "Todavía no hay tipos de contratista.",
+ "settings_contractor_types_delete_title": "Eliminar tipo de contratista",
+ "settings_contractor_types_delete_confirm": "¿Eliminar \"{name}\"? Esta acción no se puede deshacer.",
+ "settings_schedule_meta_title": "Mi agenda - Configuración - OpenInspection",
+ "settings_schedule_error_sync_failed": "Falló la sincronización del calendario.",
+ "settings_schedule_error_disconnect_failed": "No se pudo desconectar Google Calendar.",
+ "settings_schedule_crumb": "Mi agenda",
+ "settings_schedule_intro": "Horario semanal, ausencias y su enlace personal de reservas.",
+ "settings_schedule_managing_for": "Administrando la agenda de",
+ "settings_schedule_myself": "Yo",
+ "settings_booking_meta_title": "Reservas en línea - Configuración - OpenInspection",
+ "settings_booking_crumb": "Reservas en línea",
+ "settings_booking_intro": "Políticas de reserva de la empresa y el widget insertable.",
+ "settings_inspection_report_link_heading": "Vencimiento del enlace del informe",
+ "settings_inspection_report_link_help": "Durante cuánto tiempo un cliente puede abrir el enlace del informe que usted le envía.",
+ "settings_inspection_report_link_future_only": "Cambiar esto solo afecta a los enlaces enviados de ahora en adelante. Para cambiar los enlaces ya enviados de una inspección, use el mismo control en su tarjeta Personas.",
+ "settings_inspection_report_link_bulk_help": "Cambiar la política de arriba solo afecta a los enlaces emitidos de ahora en adelante. Para aplicarla a los enlaces que ya están en circulación, use la acción de abajo: indica cuántos va a cambiar.",
+ "settings_inspection_report_link_bulk_expire": "Aplicar vencimiento a {count} enlaces",
+ "settings_inspection_report_link_bulk_expire_one": "Aplicar vencimiento a 1 enlace",
+ "settings_inspection_report_link_bulk_lift": "Quitar vencimiento de {count} enlaces",
+ "settings_inspection_report_link_bulk_lift_one": "Quitar vencimiento de 1 enlace",
+ "settings_inspection_report_link_bulk_expire_confirm": "Esto cambia la fecha de {count} enlaces de informe que hoy funcionan. Quien tenga uno conserva el acceso solo hasta el nuevo vencimiento. Los enlaces que ya vencieron o fueron revocados siguen cerrados.",
+ "settings_inspection_report_link_bulk_lift_confirm": "Esto quita el vencimiento de {count} enlaces de informe que hoy funcionan, por lo que quedan abiertos indefinidamente. Los enlaces que ya vencieron o fueron revocados siguen cerrados.",
+ "settings_inspection_report_link_bulk_done": "Se actualizaron {count} enlaces.",
+ "settings_inspection_archive_revokes_label": "Archivar un contacto también revoca sus enlaces de informe",
+ "settings_inspection_archive_revokes_help": "Desactivado de forma predeterminada. Un enlace de informe funciona sin cuenta, así que archivar un contacto normalmente deja legibles los informes que se le dieron. Actívelo si archivar es la forma en que usted da de baja a alguien."
}
From 1855b6952fdbe0ef46660b65cda0b78a0fa6578d Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:02:03 +0800
Subject: [PATCH 035/111] i18n(es-419): translate settings-components.json (491
keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The largest module in the catalogue: holidays, weekly schedule, Google Calendar,
booking policies, the report gate, Stripe, video, 2FA, four email providers and
managed SMS.
First declared divergence. English 'Resend' is two unrelated things: the verb on
the team page (Reenviar) and the email vendor Resend in the integrations
catalogue and provider select, where rule 3 forbids translating a product name.
Both readings are registered under gate:divergence — and every key sharing that
English has to sit on the bullet's first line or the parser drops it, which is
now written down beside the marker.
Glossary also fixes how third-party text is handled: menu paths into Stripe /
Twilio / Cloudflare, credential field names, key prefixes, format samples and
the SMS keywords STOP / START / HELP all stay English. Translating a STOP
keyword is a compliance failure, not a typo.
Coverage 1794 -> 2285 of 4323.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
docs/developers/i18n-glossary.md | 49 ++-
messages/es-419/settings-components.json | 493 ++++++++++++++++++++++-
2 files changed, 540 insertions(+), 2 deletions(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index e5ef429f7..da79e2d87 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -412,6 +412,41 @@ Fixed while translating `settings-catalog.json` (123 keys). These recur in
| Sort order | Orden de clasificación | — | |
| Report link expiry | Vencimiento del enlace del informe | — | *Vencimiento*, matching Expired → *Vencido*. The bulk actions are phrased as *Aplicar vencimiento a…* / *Quitar vencimiento de…*: Spanish *vencer* is intransitive, so "Expire N links" has no verb-for-verb rendering. |
+### Scheduling, providers and integration panels
+
+Fixed while translating `settings-components.json` (491 keys), the largest
+module in the catalogue. It is where the product talks to Google, Stripe,
+Twilio, Telnyx and four email vendors, so it also fixes how third-party text is
+handled.
+
+**Rule 7 applies heavily here, and extends to third-party navigation.** A menu
+path into someone else's product (`Stripe → Developers → Webhooks`,
+`Twilio Console → Account Info`, `Account → Customer subdomain`), the label of a
+button the user must click *in that product* (`Send test event`), a credential
+field name (`Twilio Account SID`, `Auth Token`), a key prefix (`pk_test_`,
+`whsec_`, `SG.`), a format sample (`+15551234567`, `acct_1AbCdEfGhIjKlMnO`,
+`customer.cloudflarestream.com`) and the SMS keywords carriers match on
+(**STOP / START / HELP**) all stay English. Translate the prose around them. The
+STOP/HELP case is not cosmetic: those are the words a consumer texts back, and a
+translated one would be a compliance failure, not a typo.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Holiday | feriado | festivo | *Festivo* is Castilian; `es-419` says *feriado*. "Company holidays" → *Feriados de la empresa*. |
+| Time off | Ausencias | — | Plural, because the panel lists blocks. *Tiempo libre* is leisure, not a scheduled absence. |
+| Tenant (the SaaS account) | cuenta | — | Deliberately **not** *inquilino*. In a product whose subject is somebody's house, *inquilino* is the person renting it — the same collision the Owner row avoids. Not machine-banned: a future English string about an actual occupant would need the word. |
+| Failed (a test / a delivery) | Fallido | — | Masculine singular by the status-label rule; the verb *Falló* is used in sentences (*Falló la sincronización del calendario.*). |
+| Not connected / Not configured | Sin conectar / Sin configurar | — | The *Sin …* shape, matching *Sin definir*. Reserve *No …* for sentences. |
+| Qualified (inspectors) | autorizados | — | Not *calificados*: this catalogue already spends *calificación* on Rating, and "inspectores calificados" reads as *rated* inspectors to anyone who has seen the editor. *Autorizados* also states what the checkbox does — the help text below it says "allow all staff". |
+| Light / Dark (theme) | Claro / Oscuro | — | |
+| Live / Test (Stripe key mode) | Producción / Prueba | — | |
+| Carrier | operador | — | The mobile carrier. |
+| Concierge review | revisión previa | conserjería | *Conserjería* is a doorman's desk. The English is internal jargon for "the office approves it first", which is what the Spanish says. |
+| Slot | horario | — | A bookable start time. "Slot rules" → *Reglas de horarios*, "Slot interval" → *Intervalo entre horarios*. |
+| Weekday names | Domingo … Sábado | — | Capitalised, because each is a standalone row label and buttons/labels take sentence case. Spanish lowercases weekdays mid-sentence; nothing in this catalogue puts one mid-sentence. |
+
## Register enforcement
Every entry here is wrong in `es-419` in every context, which is what makes it
@@ -442,9 +477,21 @@ Where the same English genuinely needs two Spanish renderings — usually gender
agreement, or a word that is a noun in one place and a verb in another — list
the keys here with the reason, and the gate will allow it.
+**Put every key sharing that English on the bullet's first line.** The parser
+reads keys only from lines that begin with `-`, so keys wrapped onto a
+continuation line are dropped, and a divergence missing one of its keys does not
+apply at all.
+
-*(No declared divergences yet. Add them as `- \`key_one\`, \`key_two\` — reason.)*
+- `settings_team_resend_invite`, `settings_integrations_resend_name`, `settings_email_provider_resend` — English "Resend" is two unrelated things.
+ On the team page it is the verb (send the invite again) and must be
+ *Reenviar*. In the integrations catalogue and the email-provider select it is
+ **Resend the company**, the transactional email vendor, and rule 3 forbids
+ translating a product name — *Reenviar* there would name a provider that does
+ not exist. The two readings never share a surface.
+
+*(Add further divergences as `- \`key_one\`, \`key_two\` — reason.)*
## Working through a module
diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json
index 006f618aa..ebd6e9b8e 100644
--- a/messages/es-419/settings-components.json
+++ b/messages/es-419/settings-components.json
@@ -1,3 +1,494 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "settings_testconn_idle": "Probar conexión",
+ "settings_testconn_busy": "Probando…",
+ "settings_conn_time_just_now": "hace un momento",
+ "settings_conn_time_minutes": "hace {min} min",
+ "settings_conn_time_hours": "hace {hr} h",
+ "settings_conn_time_days": "hace {day} d",
+ "settings_conn_status_not_tested": "Todavía sin probar",
+ "settings_conn_status_connected": "Conectado",
+ "settings_conn_status_failed": "Fallido",
+ "settings_conn_last_tested": "· Última prueba",
+ "settings_conn_recent_tests": "Pruebas recientes ({count})",
+ "settings_webhook_processed": "✓ Procesado",
+ "settings_webhook_received": "✓ Recibido (sin acción)",
+ "settings_webhook_signature_failed": "✗ Falló la firma — revise su secreto de firma",
+ "settings_webhook_tenant_mismatch": "✗ La cuenta no coincide",
+ "settings_closed_policy_blocked": "Bloqueado",
+ "settings_closed_policy_advisory": "Permitido con aviso",
+ "settings_closed_policy_allowed": "Permitido",
+ "settings_closed_heading": "Días de cierre de la empresa",
+ "settings_closed_public_booking": "Reservas públicas:",
+ "settings_closed_none_upcoming": "No hay días de cierre próximos en el próximo año.",
+ "settings_closed_owner_note": "Los feriados de la empresa los define un titular en Reservas en línea. Usted no puede editarlos aquí.",
+ "settings_holiday_catalog_label": "Calendario de feriados",
+ "settings_holiday_catalog_off": "Desactivado — no se registran feriados",
+ "settings_holiday_catalog_off_help": "No se registran feriados. Todos los días quedan disponibles para reservar.",
+ "settings_holiday_policy_heading": "En los feriados",
+ "settings_holiday_policy_aria": "Qué ocurre en un feriado",
+ "settings_holiday_policy_closed_title": "Cerrado en los feriados",
+ "settings_holiday_policy_closed_detail": "Los clientes no pueden reservar. Su equipo sí puede programar, con una advertencia.",
+ "settings_holiday_policy_request_title": "Abierto a solicitud",
+ "settings_holiday_policy_request_detail": "Los clientes pueden solicitar un horario; usted confirma cada uno.",
+ "settings_holiday_policy_open_notice": "Por ahora las reservas están abiertas en los feriados. Cámbielo en Avanzado.",
+ "settings_holiday_region_federal_only": "Solo federales (EE. UU.)",
+ "settings_holiday_region_federal_plus": "Federales + {name} ({code})",
+ "settings_holiday_state_tx": "Texas",
+ "settings_holiday_state_ca": "California",
+ "settings_holiday_state_ny": "Nueva York",
+ "settings_holiday_state_fl": "Florida",
+ "settings_holiday_state_il": "Illinois",
+ "settings_holiday_public_block": "Bloquear las reservas",
+ "settings_holiday_public_advisory": "Permitir con aviso",
+ "settings_holiday_public_open": "Permitir las reservas",
+ "settings_holiday_internal_warn": "Solo advertir",
+ "settings_holiday_internal_block": "Bloquear",
+ "settings_holiday_advanced_summary": "Avanzado",
+ "settings_holiday_region_label": "Región de feriados",
+ "settings_holiday_region_off": "Desactivado (sin calendario de feriados)",
+ "settings_holiday_public_heading": "Reservas públicas",
+ "settings_holiday_public_aria": "Política de feriados para las reservas públicas",
+ "settings_holiday_open_warning": "Los clientes aún pueden reservar en los feriados de la lista (por ejemplo, Acción de Gracias). Use Bloquear o Permitir con aviso si esa no es la intención.",
+ "settings_holiday_internal_heading": "Programación interna",
+ "settings_holiday_internal_aria": "Política de feriados para la programación interna",
+ "settings_holiday_custom_heading": "Días de cierre personalizados",
+ "settings_holiday_custom_none": "Todavía no hay días personalizados.",
+ "settings_holiday_custom_date": "Fecha",
+ "settings_holiday_custom_name": "Nombre",
+ "settings_holiday_custom_name_placeholder": "Picnic de la empresa",
+ "settings_holiday_save_pending": "Guardando...",
+ "settings_holiday_save": "Guardar la configuración de feriados",
+ "settings_holiday_saved": "Guardado.",
+ "settings_holiday_save_failed": "No se pudo guardar. Inténtelo de nuevo.",
+ "settings_holiday_panel_heading": "Feriados de la empresa",
+ "settings_holiday_panel_desc": "Aplique los feriados federales y estatales a las reservas públicas y a la programación interna.",
+ "settings_holiday_concierge_label": "Exigir la confirmación de la oficina",
+ "settings_holiday_concierge_desc": "Las reservas en fechas de feriado quedan pendientes hasta que alguien de su equipo las confirme.",
+ "settings_holiday_concierge_warn": "Las reservas pueden confirmarse sin revisión en las fechas de feriado.",
+ "settings_holiday_confirm_title": "¿Permitir reservas en los feriados?",
+ "settings_holiday_confirm_message": "Los clientes aún pueden reservar en los feriados de la lista. Confirme solo si es intencional.",
+ "settings_holiday_coverage_warn": "Las fechas de feriados integradas llegan hasta {year}. Las reservas posteriores no se marcarán como feriados automáticamente: agréguelas abajo como fechas personalizadas o actualice la aplicación para años más recientes.",
+ "settings_teamsched_heading": "Agendas del equipo",
+ "settings_teamsched_desc": "El horario semanal y las ausencias están en Mi agenda",
+ "settings_teamsched_members_count": " ({count} miembros programables)",
+ "settings_teamsched_manage": "Administrar las agendas del equipo →",
+ "settings_timeoff_all_day": "Todo el día",
+ "settings_timeoff_heading": "Ausencias",
+ "settings_timeoff_desc": "Las ausencias personales están en el calendario. Agregue o edite bloques allí: esta lista es una vista rápida de lo que usted ya programó.",
+ "settings_timeoff_open_calendar": "Abrir el calendario",
+ "settings_timeoff_none": "No hay ausencias programadas.",
+ "settings_timeoff_block_time": "Bloquear tiempo en el calendario",
+ "settings_dateoverrides_heading": "Sincronizados y heredados",
+ "settings_dateoverrides_use_prefix": "Use",
+ "settings_dateoverrides_link": "Calendario → Bloquear tiempo",
+ "settings_dateoverrides_use_suffix": "para nuevas ausencias. Esta lista muestra los días ocupados sincronizados de Google y los bloques de fecha antiguos.",
+ "settings_dateoverrides_extra_hours": "Horas adicionales",
+ "settings_day_sunday": "Domingo",
+ "settings_day_monday": "Lunes",
+ "settings_day_tuesday": "Martes",
+ "settings_day_wednesday": "Miércoles",
+ "settings_day_thursday": "Jueves",
+ "settings_day_friday": "Viernes",
+ "settings_day_saturday": "Sábado",
+ "settings_weekly_heading": "Horario semanal",
+ "settings_weekly_to": "a",
+ "settings_weekly_unavailable": "No disponible",
+ "settings_weekly_save": "Guardar el horario",
+ "settings_common_copied": "¡Copiado!",
+ "settings_schedlinks_heading": "Sus enlaces",
+ "settings_schedlinks_personal_label": "Enlace personal de reservas",
+ "settings_schedlinks_deeplink_hint": "Este enlace lo preselecciona a usted en la página de reservas de la empresa.",
+ "settings_schedlinks_not_ready": "Su enlace personal de reservas aparece cuando estén configurados los identificadores de su empresa y de su usuario.",
+ "settings_schedlinks_ics_label": "Suscripciones de calendario ICS",
+ "settings_schedlinks_ics_desc": "La suscripción ICS actual usa un token de la empresa y muestra las inspecciones programadas. No es un feed de ocupación por inspector, no importa tiempo ocupado y no actualiza Google Calendar; use la conexión de arriba para sincronizar la disponibilidad.",
+ "settings_schedlinks_ics_learn": "Aprenda cómo suscribirse",
+ "settings_companylink_heading": "Enlace de la empresa",
+ "settings_companylink_booking_page": "Página de reservas",
+ "settings_companylink_share_hint": "Comparta el enlace de la empresa: a los clientes se les asigna el primer inspector disponible.",
+ "settings_calconnect_cap_availability": "Solo leer la disponibilidad",
+ "settings_calconnect_cap_full": "Sincronización completa (leer y escribir eventos)",
+ "settings_calconnect_connected_toast": "Google Calendar conectado.",
+ "settings_calconnect_heading": "Google Calendar",
+ "settings_calconnect_desc": "Mantenga el tiempo ocupado externo fuera de sus horas disponibles para reservas.",
+ "settings_calconnect_personal_note": "Las conexiones de calendario son personales. Seleccione Yo arriba para administrar su Google Calendar.",
+ "settings_calconnect_syncing": "Sincronizando…",
+ "settings_calconnect_sync_now": "Sincronizar ahora",
+ "settings_calconnect_disconnecting": "Desconectando…",
+ "settings_calconnect_disconnect": "Desconectar",
+ "settings_calconnect_sync_complete": "Sincronización completa. Se revisaron {count} eventos del calendario.",
+ "settings_calconnect_sync_failed": "Falló la sincronización del calendario.",
+ "settings_calconnect_choose_access": "Elija a qué puede acceder Google:",
+ "settings_calconnect_connecting": "Conectando…",
+ "settings_calconnect_continue_google": "Continuar con Google",
+ "settings_calconnect_oauth_not_configured": "Google OAuth no está configurado. Pida a un administrador de la empresa que lo configure en Comunicación.",
+ "settings_calpicker_heading": "Calendarios que se revisan",
+ "settings_calpicker_desc": "Elija qué calendarios se revisan en busca de conflictos. Las reservas se escriben en uno solo.",
+ "settings_calpicker_read_label": "Revisar conflictos",
+ "settings_calpicker_write_label": "Escribir las reservas en",
+ "settings_calpicker_primary_locked": "Principal — siempre se revisa",
+ "settings_calpicker_write_only_editable": "Solo los calendarios que usted puede editar pueden recibir reservas.",
+ "settings_calpicker_save": "Guardar los calendarios",
+ "settings_calpicker_saving": "Guardando…",
+ "settings_calpicker_saved": "Calendarios guardados.",
+ "settings_calpicker_save_failed": "No se pudieron guardar los calendarios.",
+ "settings_calpicker_none": "No se encontraron otros calendarios.",
+ "settings_gcal_heading": "Aplicación OAuth de Google Calendar",
+ "settings_gcal_inspectors_connect_prefix": "Los inspectores conectan sus calendarios en",
+ "settings_gcal_my_schedule": "Mi agenda",
+ "settings_gcal_mode_platform": "OAuth de Google de la plataforma",
+ "settings_gcal_mode_own": "Mi propia aplicación OAuth",
+ "settings_gcal_mode_platform_desc": "Use la aplicación OAuth alojada de la plataforma para las conexiones de los inspectores.",
+ "settings_gcal_mode_own_desc": "Use el cliente OAuth de Google Cloud de su empresa.",
+ "settings_gcal_save_oauth_mode": "Guardar el modo de OAuth",
+ "settings_gcal_selfhost_note": "Las instalaciones autoalojadas usan su propia aplicación OAuth de Google Cloud.",
+ "settings_gcal_create_prefix": "Cree las credenciales de OAuth en",
+ "settings_gcal_cloud_console": "Google Cloud Console",
+ "settings_gcal_redirect_uri": ". URI de redirección:",
+ "settings_gcal_client_id_label": "ID de cliente de Google",
+ "settings_gcal_client_id_hint": "ID de cliente de OAuth 2.0 de Google Cloud Console",
+ "settings_gcal_client_secret_label": "Secreto de cliente de Google",
+ "settings_gcal_client_secret_hint": "Se combina con el ID de cliente de arriba",
+ "settings_gcal_save_credentials": "Guardar las credenciales",
+ "settings_policies_heading": "Políticas de reserva",
+ "settings_policies_concierge_label": "Exigir revisión previa",
+ "settings_policies_concierge_desc": "Las reservas enviadas por un agente deben ser aprobadas por usted antes de que el cliente reciba un enlace de confirmación.",
+ "settings_policies_signed_label": "Exigir un acuerdo firmado",
+ "settings_policies_signed_desc": "Los clientes deben firmar el acuerdo de inspección antes de que se confirme la reserva.",
+ "hub_gate_unlock_action": "Desbloquear los informes de esta inspección",
+ "hub_gate_unlock_title": "Desbloquear los informes",
+ "hub_gate_unlock_body": "Esto entrega al cliente todos los informes de esta inspección, aunque el acuerdo o el pago que se esperaba no haya llegado. Úselo cuando un informe terminado está retenido por el papeleo de otra cosa del mismo trabajo.",
+ "hub_gate_unlock_reason_label": "Motivo",
+ "hub_gate_unlock_reason_placeholder": "El cliente está en el cierre; el anexo de radón sigue pendiente de firma.",
+ "hub_gate_unlock_confirm": "Desbloquear los informes",
+ "hub_gate_unlocked_heading": "Informes desbloqueados",
+ "hub_gate_unlocked_by": "Liberado por {name} el {date}.",
+ "hub_gate_unlocked_unknown_person": "un compañero de equipo",
+ "hub_gate_relock_action": "Volver a poner el bloqueo",
+ "hub_gate_unlock_reason_required": "Indique por qué lo desbloquea: queda registrado.",
+ "hub_gate_unlock_failed": "No se pudo desbloquear. El bloqueo sigue activo.",
+ "hub_gate_relock_failed": "No se pudo restablecer el bloqueo. Sigue desbloqueado.",
+ "settings_policies_signed_scope": "Un acuerdo sin firmar también retiene todos los informes de esa inspección, no solo el informe del servicio que cubre el acuerdo. Cuando un informe terminado está esperando el papeleo de otra cosa del mismo trabajo, un titular o un gerente puede desbloquear esa inspección.",
+ "settings_policies_choice_label": "Permitir que los clientes elijan su inspector",
+ "settings_policies_choice_desc": "Cuando está desactivado, las reservas se asignan automáticamente al primer inspector disponible.",
+ "settings_policies_save": "Guardar las políticas",
+ "settings_slotrules_mode_fixed": "Horarios fijos",
+ "settings_slotrules_mode_open": "Agenda abierta",
+ "settings_slotrules_interval_15": "15 minutos",
+ "settings_slotrules_interval_30": "30 minutos",
+ "settings_slotrules_interval_60": "60 minutos",
+ "settings_slotrules_heading": "Reglas de horarios",
+ "settings_slotrules_desc": "Elija cómo se generan las horas de inicio reservables a partir de las ventanas de disponibilidad del inspector. Los valores predeterminados son Horarios fijos / 30 minutos.",
+ "settings_slotrules_mode_label": "Modo de agenda",
+ "settings_slotrules_mode_aria": "Modo de horarios de reserva",
+ "settings_slotrules_fixed_desc": "Los inicios se alinean con la hora de inicio de cada ventana de disponibilidad y luego avanzan según el intervalo.",
+ "settings_slotrules_open_desc": "Los inicios se ajustan al reloj (por ejemplo, :00 / :30) en cada intervalo dentro de cada ventana.",
+ "settings_slotrules_interval_label": "Intervalo entre horarios",
+ "settings_slotrules_save": "Guardar las reglas de horarios",
+ "settings_embed_style_light": "Claro",
+ "settings_embed_style_dark": "Oscuro",
+ "settings_embed_style_branded": "Con marca",
+ "settings_embed_heading": "Widget insertable",
+ "settings_embed_no_company": "No hay ninguna empresa configurada — el widget insertable no está disponible.",
+ "settings_embed_code_label": "Código para insertar",
+ "settings_embed_copy_snippet": "Copiar el fragmento",
+ "settings_embed_live_preview": "Vista previa en vivo",
+ "settings_embed_preview_title": "Vista previa del widget",
+ "settings_integrations_qbo_name": "QuickBooks Online",
+ "settings_integrations_qbo_desc": "Sincronice facturas, contactos y el estado de pago en tiempo real.",
+ "settings_integrations_gcal_name": "Google Calendar",
+ "settings_integrations_gcal_desc": "Sincronización bidireccional para la programación de inspecciones y la disponibilidad.",
+ "settings_integrations_places_name": "Google Places",
+ "settings_integrations_places_desc": "Autocompletado de direcciones y enriquecimiento de datos de la propiedad.",
+ "settings_integrations_resend_name": "Resend",
+ "settings_integrations_resend_desc": "Entrega de correo electrónico transaccional para informes y notificaciones.",
+ "settings_integrations_zapier_name": "Zapier",
+ "settings_integrations_zapier_desc": "Conéctese a más de 5000 aplicaciones con flujos de trabajo sin código.",
+ "settings_integrations_gemini_name": "Gemini AI",
+ "settings_integrations_gemini_desc": "Asistencia de inspección y detección de defectos con IA.",
+ "settings_integrations_status_available": "Disponible",
+ "settings_integrations_configure": "Configurar",
+ "settings_integrations_connect": "Conectar",
+ "settings_stripe_heading": "Pagos con Stripe",
+ "settings_stripe_subtitle": "Conecte su propia cuenta de Stripe para aceptar pagos con tarjeta en las facturas.",
+ "settings_stripe_test_mode_label": "Comience en modo de prueba.",
+ "settings_stripe_use_your": "Use sus",
+ "settings_stripe_keys_from": "claves de",
+ "settings_stripe_pay_with_card": "y pague con la tarjeta",
+ "settings_stripe_verify_flow": "(cualquier fecha futura / CVC) para verificar el flujo antes de pasar a producción.",
+ "settings_stripe_publishable_label": "Clave publicable",
+ "settings_stripe_publishable_hint": "Se envía al navegador para mostrar el campo de la tarjeta. Comienza con pk_test_ (prueba) o pk_live_ (producción).",
+ "settings_stripe_secret_label": "Clave secreta",
+ "settings_stripe_secret_hint": "Clave del servidor que crea el cargo. Comienza con sk_test_ o sk_live_. Nunca se comparte con el navegador.",
+ "settings_stripe_webhook_secret_label": "Secreto de firma del webhook",
+ "settings_stripe_webhook_secret_hint": "Verifica las notificaciones de pago. Se obtiene después de agregar el endpoint del webhook de abajo (comienza con whsec_).",
+ "settings_stripe_save": "Guardar las claves de Stripe",
+ "settings_stripe_test_connected_prefix": "Conectado:",
+ "settings_stripe_livemode_live": "Producción",
+ "settings_stripe_livemode_test": "Prueba",
+ "settings_stripe_webhook_endpoint": "Endpoint del webhook",
+ "settings_stripe_webhook_desc_prefix": "En Stripe → Developers → Webhooks, agregue un endpoint para el evento",
+ "settings_stripe_webhook_desc_suffix": "que apunte a esta URL y luego pegue arriba su secreto de firma. Si ya registró un endpoint antes, vuelva a apuntarlo a esta URL: el secreto de firma no cambia.",
+ "settings_stripe_recent_deliveries": "Entregas recientes del webhook",
+ "settings_stripe_refreshing": "Actualizando…",
+ "settings_stripe_refresh": "Actualizar",
+ "settings_stripe_no_deliveries_prefix": "Todavía no hay entregas. En Stripe → Developers → Webhooks → su endpoint, haga clic en",
+ "settings_stripe_send_test_event": "Send test event",
+ "settings_stripe_no_deliveries_suffix": ", y luego presione Actualizar.",
+ "settings_video_heading": "Video",
+ "settings_video_subtitle": "Elija el backend de almacenamiento de video para esta instancia.",
+ "settings_video_default_label": "Predeterminado: R2 (gratis).",
+ "settings_video_r2_desc": "Los videos se almacenan en su bucket de Cloudflare R2 — sin costo adicional más allá de las tarifas de almacenamiento de R2.",
+ "settings_video_stream_label": "Cloudflare Stream",
+ "settings_video_stream_desc": "habilita la reproducción con tasa de bits adaptable y requiere una suscripción de pago a Stream más el enlace",
+ "settings_video_stream_binding": "en el archivo de configuración",
+ "settings_video_config_word": "de su proyecto.",
+ "settings_video_toggle_label": "Usar Cloudflare Stream para el video",
+ "settings_video_paid": "(de pago)",
+ "settings_video_subdomain_label": "Subdominio de cliente de Stream",
+ "settings_video_subdomain_placeholder": "customer.cloudflarestream.com",
+ "settings_video_subdomain_hint_prefix": "Se encuentra en su panel de Cloudflare Stream, en",
+ "settings_video_subdomain_hint_path": "Account → Customer subdomain",
+ "settings_video_save": "Guardar la configuración de video",
+ "settings_pw_heading": "Cambiar la contraseña",
+ "settings_pw_current_label": "Contraseña actual",
+ "settings_pw_new_label": "Nueva contraseña",
+ "settings_pw_confirm_label": "Confirmar la nueva contraseña",
+ "settings_pw_show": "Mostrar las contraseñas",
+ "settings_pw_submit": "Cambiar contraseña",
+ "settings_dataexport_heading": "Exportación de datos",
+ "settings_dataexport_desc": "Descargue una copia de todos sus datos, incluidas las inspecciones, los informes, las plantillas y la información de los clientes.",
+ "settings_dataexport_button": "Descargar mis datos",
+ "settings_turnstile_heading": "Protección contra bots",
+ "settings_turnstile_desc": "La protección contra bots impide los envíos automatizados de formularios en las páginas públicas. Obtenga las claves en",
+ "settings_turnstile_dashboard_link": "panel de Cloudflare",
+ "settings_turnstile_secret_label": "Clave secreta de Turnstile",
+ "settings_turnstile_secret_hint": "Protección contra bots en los formularios de reserva y de registro. Créela en dash.cloudflare.com → Turnstile. Use la clave de prueba 1x0000000000000000000000000000000AA para desarrollo",
+ "settings_2fa_heading": "Autenticación de dos factores",
+ "settings_2fa_enabled": "Habilitada. Se exige en cada inicio de sesión.",
+ "settings_2fa_not_enabled": "No habilitada.",
+ "settings_2fa_recovery_remaining": "Quedan {count} códigos de recuperación",
+ "settings_2fa_enable": "Habilitar 2FA",
+ "settings_2fa_regenerate": "Regenerar los códigos",
+ "settings_2fa_disable": "Deshabilitar 2FA",
+ "settings_intkeys_heading": "Claves de API de las integraciones",
+ "settings_intkeys_desc": "Estas integraciones mejoran el flujo de trabajo de inspección. Todas son opcionales: las funciones se degradan de forma controlada cuando no están configuradas.",
+ "settings_intkeys_places_label": "Clave de API de Google Places",
+ "settings_intkeys_places_hint": "Autocompletado de direcciones en los formularios de reserva y de nueva inspección. Créela en console.cloud.google.com → Places API",
+ "settings_intkeys_estated_label": "Clave de API de Estated",
+ "settings_intkeys_estated_hint": "Autocompleta los Datos de la propiedad (año de construcción, pies cuadrados, dormitorios). Obténgala en estated.com → API",
+ "settings_intkeys_baseurl_label": "URL base de la aplicación",
+ "settings_intkeys_baseurl_hint": "URL pública de su instalación (por ejemplo, https://app.yourdomain.com). Se usa en los enlaces de los correos electrónicos",
+ "settings_stripeconnect_heading": "Pagos (Stripe Connect)",
+ "settings_stripeconnect_not_connected": "Sin conectar",
+ "settings_stripeconnect_desc": "Acepte pagos con tarjeta en las facturas mediante su cuenta de Stripe Express. Cree su cuenta en",
+ "settings_stripeconnect_desc_suffix": ", y luego pegue abajo el ID de la cuenta.",
+ "settings_stripeconnect_connected_account": "Cuenta conectada:",
+ "settings_stripeconnect_account_id_label": "ID de cuenta de Stripe",
+ "settings_stripeconnect_account_placeholder": "acct_1AbCdEfGhIjKlMnO",
+ "settings_stripeconnect_connect_account": "Conectar la cuenta",
+ "settings_ai_heading": "Funciones de IA",
+ "settings_ai_configured": "Configurado",
+ "settings_ai_not_configured": "Sin configurar",
+ "settings_ai_desc": "Google Gemini impulsa la asistencia de comentarios y los resúmenes de inspección. Obtenga una clave en",
+ "settings_ai_key_label": "Clave de API de Gemini",
+ "settings_ai_key_hint": "Impulsa las sugerencias de comentarios con IA y el autocompletado inteligente de campos. Obténgala en aistudio.google.com/apikey",
+ "settings_ai_key_valid": "Conectado — la clave es válida",
+ "settings_email_provider_resend": "Resend",
+ "settings_email_provider_sendgrid": "SendGrid",
+ "settings_email_provider_postmark": "Postmark",
+ "settings_email_provider_mailgun": "Mailgun",
+ "settings_emaildelivery_heading": "Envío de correo electrónico",
+ "settings_emaildelivery_own_missing": "Se eligió un proveedor propio, pero falta la dirección del remitente o las credenciales de {provider}: los correos electrónicos no se podrán enviar.",
+ "settings_emaildelivery_platform_missing": "No hay correo electrónico de la plataforma configurado (SENDER_EMAIL / Resend): no se pueden enviar correos electrónicos.",
+ "settings_emaildelivery_inspector_note": "Los correos electrónicos usan el nombre y el correo de cada inspector; los inspectores sin nombre usan los de la empresa.",
+ "settings_emaildelivery_mode_platform": "Correo electrónico de la plataforma",
+ "settings_emaildelivery_mode_own": "Mi propio proveedor",
+ "settings_emaildelivery_mode_platform_desc": "Envíe desde el buzón de la plataforma. Usted puede definir el nombre visible y la dirección de respuesta; la dirección de envío es fija.",
+ "settings_emaildelivery_mode_own_desc": "Envíe desde su propio proveedor de correo electrónico. Elija el proveedor y agregue sus credenciales en Claves de API de correo electrónico, abajo, más una dirección de remitente verificada.",
+ "settings_emaildelivery_selfhost_note": "Las instalaciones autoalojadas envían desde su propio proveedor de correo electrónico. Elija un proveedor (Resend, SendGrid, Postmark o Mailgun) y agregue sus credenciales más una dirección de remitente verificada en Claves de API de correo electrónico, abajo, para habilitar el correo electrónico.",
+ "settings_emaildelivery_sender_email_label": "Correo del remitente",
+ "settings_emaildelivery_sender_email_placeholder": "reports@yourdomain.com",
+ "settings_emaildelivery_sender_verified": "Debe ser un dominio verificado en su cuenta de {provider}.",
+ "settings_emaildelivery_from_name_label": "Nombre del remitente",
+ "settings_emaildelivery_set_company_link": "Defina el nombre de su empresa en Configuración › Espacio de trabajo",
+ "settings_emaildelivery_from_workspace": "(de la configuración del espacio de trabajo)",
+ "settings_emaildelivery_override_name": "Usar un nombre distinto en el campo De del correo electrónico",
+ "settings_emaildelivery_display_name_placeholder": "Acme Inspections",
+ "settings_emaildelivery_replyto_label": "Responder a",
+ "settings_emaildelivery_replyto_required": "* obligatorio cuando el punto de contacto es la empresa",
+ "settings_emaildelivery_same_as_sender": "Igual que el correo del remitente",
+ "settings_emaildelivery_replyto_placeholder": "hello@yourdomain.com",
+ "settings_emaildelivery_replies_note": "Las respuestas llegan a esta dirección.",
+ "settings_emaildelivery_poc_label": "Punto de contacto",
+ "settings_emaildelivery_poc_company": "Empresa (se exige una dirección de respuesta)",
+ "settings_emaildelivery_poc_inspector": "Inspector que envía (las respuestas llegan a ese inspector)",
+ "settings_emaildelivery_send_as": "Los correos electrónicos se envían como:",
+ "settings_emaildelivery_your_company": "su empresa",
+ "settings_emaildelivery_the_inspector": "el inspector que envía",
+ "settings_emaildelivery_replies_to_inspector": ", las respuestas llegan a ese inspector",
+ "settings_emaildelivery_replies_to_address": ", las respuestas llegan a {replyTo}",
+ "settings_emaildelivery_provider_credentials": "Credenciales de {provider}",
+ "settings_emaildelivery_label_configured": "{label} configurado",
+ "settings_emaildelivery_label_not_set": "{label} sin definir",
+ "settings_emailsecrets_webhook_resend_label": "Secreto de firma del webhook de Resend",
+ "settings_emailsecrets_webhook_resend_hint": "Resend → Webhooks → Signing Secret (comienza con whsec_). Apunte el webhook a la URL de abajo.",
+ "settings_emailsecrets_webhook_sendgrid_label": "Clave de verificación del webhook de eventos de SendGrid",
+ "settings_emailsecrets_webhook_sendgrid_hint": "SendGrid → Settings → Mail Settings → Event Webhook → Signed Event Webhook (clave pública en base64). Apunte el webhook a la URL de abajo.",
+ "settings_emailsecrets_webhook_postmark_label": "Token del webhook de Postmark",
+ "settings_emailsecrets_webhook_postmark_hint": "Un token compartido que usted elige. Agréguelo como ?token=… en la URL del webhook de abajo (Postmark → Servers → Webhooks).",
+ "settings_emailsecrets_webhook_mailgun_label": "Clave de firma del webhook de Mailgun",
+ "settings_emailsecrets_webhook_mailgun_hint": "Mailgun → Settings → Webhooks → HTTP webhook signing key. Apunte el webhook a la URL de abajo.",
+ "settings_emailsecrets_heading": "Claves de API de correo electrónico",
+ "settings_emailsecrets_desc": "Sin el correo electrónico configurado, no se enviarán los restablecimientos de contraseña ni las confirmaciones de reserva. Elija su proveedor de correo electrónico e ingrese sus credenciales de API.",
+ "settings_emailsecrets_provider_label": "Proveedor de correo electrónico",
+ "settings_emailsecrets_get_key_at": "Obtenga una clave en",
+ "settings_emailsecrets_resend_key_label": "Clave de API de Resend",
+ "settings_emailsecrets_resend_key_hint": "Envío de correo electrónico para informes, confirmaciones y restablecimientos de contraseña. Obtenga su clave en resend.com → API Keys",
+ "settings_emailsecrets_sendgrid_key_label": "Clave de API de SendGrid",
+ "settings_emailsecrets_sendgrid_key_hint": "Comienza con SG. — SendGrid → Settings → API Keys",
+ "settings_emailsecrets_postmark_token_label": "Token de servidor de Postmark",
+ "settings_emailsecrets_postmark_token_hint": "Postmark → Servers → API Tokens",
+ "settings_emailsecrets_mailgun_key_label": "Clave de API de Mailgun",
+ "settings_emailsecrets_mailgun_key_hint": "Mailgun → Settings → API Keys",
+ "settings_emailsecrets_mailgun_domain_label": "Dominio de envío de Mailgun",
+ "settings_emailsecrets_mailgun_domain_hint": "Su dominio de envío, por ejemplo, mg.yourdomain.com",
+ "settings_emailsecrets_deliverability_label": "Webhook de capacidad de entrega",
+ "settings_emailsecrets_deliverability_desc": "Configure {provider} para que envíe aquí los eventos de rebote y de queja por spam, de modo que nunca se vuelva a escribir a las direcciones suprimidas.",
+ "settings_emailsecrets_webhook_url_label": "URL del webhook",
+ "settings_emailsecrets_save": "Guardar la(s) clave(s) de {provider}",
+ "settings_emailsecrets_resend_connected": "Conectado — {count} dominio(s) verificado(s)",
+ "settings_emailsecrets_validate_idle": "Validar las credenciales",
+ "settings_emailsecrets_validate_busy": "Validando…",
+ "settings_emailsecrets_creds_verified": "Credenciales verificadas.",
+ "settings_sms_provider_twilio": "Twilio",
+ "settings_sms_provider_telnyx": "Telnyx",
+ "settings_smssecrets_provider_label": "Proveedor de SMS",
+ "settings_smssecrets_a2p_link": "A2P 10DLC",
+ "settings_smssecrets_telnyx_portal_link": "Telnyx Mission Control Portal",
+ "settings_smssecrets_twilio_intro": "Agregue su Twilio Account SID, su Auth Token y un número de envío. Los números nuevos deben registrarse en",
+ "settings_smssecrets_twilio_intro_suffix": "antes de poder enviar mensajes a números de EE. UU.",
+ "settings_smssecrets_twilio_sid_label": "Twilio Account SID",
+ "settings_smssecrets_twilio_sid_hint": "Comienza con AC, 34 caracteres. Twilio Console → Account Info",
+ "settings_smssecrets_twilio_token_label": "Twilio Auth Token",
+ "settings_smssecrets_twilio_token_hint": "Se combina con el Account SID. También verifica los webhooks entrantes de STOP/START.",
+ "settings_smssecrets_twilio_from_label": "Número de envío de Twilio",
+ "settings_smssecrets_twilio_from_hint": "Su número de envío en formato E.164, por ejemplo, +15551234567",
+ "settings_smssecrets_telnyx_intro": "Agregue su clave de API de Telnyx, su número de envío y su clave pública. Configure su número en el",
+ "settings_smssecrets_telnyx_intro_suffix": ". Los SMS salientes y la paridad de STOP/HELP entrantes funcionan por completo una vez definida la clave pública.",
+ "settings_smssecrets_telnyx_key_label": "Clave de API de Telnyx",
+ "settings_smssecrets_telnyx_key_hint": "Telnyx Mission Control → API Keys. Manténgala en secreto.",
+ "settings_smssecrets_telnyx_from_label": "Número de envío de Telnyx",
+ "settings_smssecrets_telnyx_from_hint": "Su número de envío de Telnyx en formato E.164, por ejemplo, +15551234567",
+ "settings_smssecrets_telnyx_pubkey_label": "Clave pública de Telnyx",
+ "settings_smssecrets_telnyx_pubkey_hint": "Telnyx Mission Control → su Messaging Profile → Public Key. Se usa para verificar los webhooks entrantes de STOP/HELP.",
+ "settings_smssecrets_save": "Guardar las credenciales de {provider}",
+ "settings_smssecrets_inbound_label": "URL del webhook entrante",
+ "settings_smssecrets_inbound_placeholder": "Guarde primero su empresa para ver esta URL",
+ "settings_smssecrets_inbound_note_telnyx": "Pegue esto en el webhook entrante de su número de Telnyx para que se sincronicen las respuestas STOP/HELP. Defina arriba su clave pública para que podamos verificar estos webhooks.",
+ "settings_smssecrets_inbound_note_twilio": "Pegue esto en el webhook de mensajería de su número de Twilio para que se sincronicen las respuestas STOP/START.",
+ "settings_smssecrets_test_label": "Enviar un SMS de prueba",
+ "settings_smssecrets_sending": "Enviando…",
+ "settings_smssecrets_send_test": "Enviar prueba",
+ "settings_smssecrets_test_sent": "Mensaje de prueba enviado.",
+ "settings_smsdelivery_compliance_approved": "Aprobado",
+ "settings_smsdelivery_compliance_rejected": "Rechazado",
+ "settings_smsdelivery_compliance_pending": "Pendiente",
+ "settings_smsdelivery_compliance_not_started": "Sin iniciar",
+ "settings_smsdelivery_heading": "Envío de SMS",
+ "settings_smsdelivery_desc": "Envíe mensajes de texto de citas e informes por SMS. A los clientes solo se les escribe después de un consentimiento registrado; los agentes y otros contactos comerciales de un trabajo pueden recibir mensajes transaccionales sin ese paso del cliente. Las respuestas STOP se respetan para todos. Con su propio proveedor, usted paga directamente las tarifas por mensaje del operador.",
+ "settings_smsdelivery_byo_label": "Mi propio Twilio / Telnyx (BYO)",
+ "settings_smsdelivery_byo_desc": "Traiga su propia cuenta. Usted paga las tarifas del proveedor directamente y controla sus números.",
+ "settings_smsdelivery_shared_label": "Gestionado — número compartido",
+ "settings_smsdelivery_included": "(incluido)",
+ "settings_smsdelivery_shared_desc": "Envíe desde un número compartido gestionado por la plataforma. No requiere configuración.",
+ "settings_smsdelivery_dedicated_label": "Gestionado — número local dedicado",
+ "settings_smsdelivery_dedicated_desc": "Su propio número local, gestionado por la plataforma. Complete abajo el registro de TCR/TFV.",
+ "settings_smsdelivery_selfhost_note": "Las instalaciones autoalojadas envían mensajes desde su propio proveedor de SMS (Twilio o Telnyx). Elija un proveedor y agregue abajo sus credenciales para habilitar los SMS.",
+ "settings_smsdelivery_using_your": "Usando su {provider}",
+ "settings_smsdelivery_using_platform": "Usando los SMS de la plataforma",
+ "settings_smsdelivery_not_configured": "SMS sin configurar — defina abajo las credenciales de su proveedor",
+ "settings_smsdelivery_tfv_label": "Verificación de número gratuito:",
+ "settings_smsdelivery_company_phone_label": "Teléfono de la empresa",
+ "settings_smsdelivery_phone_note_prefix": "Se muestra en sus mensajes como el número de devolución de llamada (",
+ "settings_smsdelivery_phone_note_suffix": ").",
+ "settings_smsdelivery_save": "Guardar la configuración de SMS",
+ "settings_discount_heading": "Códigos de descuento",
+ "settings_discount_desc": "Códigos promocionales que los clientes pueden aplicar al reservar.",
+ "settings_discount_none": "Todavía no hay códigos de descuento.",
+ "settings_discount_percent_off": "{value}% de descuento",
+ "settings_discount_fixed_off": "${amount} de descuento",
+ "settings_discount_active": "Activo",
+ "settings_discount_disabled": "Deshabilitado",
+ "settings_qual_all_inspectors": "Todos los inspectores",
+ "settings_qual_inspectors_one": "{count} inspector",
+ "settings_qual_inspectors_many": "{count} inspectores",
+ "settings_qual_qualified_label": "Autorizados:",
+ "settings_qual_heading": "Inspectores autorizados",
+ "settings_qual_leave_unchecked": "Deje todo sin marcar para permitir a todo el personal.",
+ "settings_services_empty": "Todavía no hay servicios. Use \"+ Agregar servicio\" arriba para crear el primero.",
+ "settings_services_col_name": "Nombre",
+ "settings_services_col_duration": "Duración",
+ "settings_services_col_price": "Precio",
+ "settings_services_col_status": "Estado",
+ "settings_services_col_actions": "Acciones",
+ "settings_services_inactive": "Inactivo",
+ "settings_services_deactivate": "Desactivar",
+ "settings_services_activate": "Activar",
+ "settings_services_duration_label": "Duración (minutos)",
+ "settings_services_duration_placeholder": "90",
+ "settings_services_duration_hm": "{hours} h {minutes} min",
+ "settings_services_duration_h": "{hours} h",
+ "settings_services_duration_m": "{minutes} min",
+ "settings_services_duration_unset": "Sin definir",
+ "settings_services_template_label": "Plantilla del informe",
+ "settings_services_template_none": "Sin plantilla",
+ "settings_services_template_prefix": "Plantilla:",
+ "settings_services_no_template_warning": "Sin plantilla — las reservas en línea fallan cuando un cliente elige este servicio",
+ "settings_mcw_provisioning_status": "Estado del aprovisionamiento",
+ "settings_mcw_step_business_profile": "Perfil comercial (TCR)",
+ "settings_mcw_step_brand": "Registro de marca (10DLC)",
+ "settings_mcw_step_campaign": "Registro de campaña (10DLC)",
+ "settings_mcw_step_tfv": "Verificación de número gratuito (TFV)",
+ "settings_mcw_step_number_active": "Número activo",
+ "settings_mcw_waiting": "En espera",
+ "settings_mcw_provisioned_number": "Número aprovisionado:",
+ "settings_mcw_registration_rejected": "Registro rechazado",
+ "settings_mcw_rejection_note": "Corrija el problema de arriba, actualice abajo la información de su empresa y haga clic en “Corregir y reenviar”.",
+ "settings_mcw_active_banner": "Los SMS gestionados están activos y listos para enviar.",
+ "settings_mcw_in_progress": "El registro está en curso. Twilio suele completarlo en un plazo de 1 a 5 días hábiles.",
+ "settings_mcw_resubmitting": "Reenviando…",
+ "settings_mcw_fix_resubmit": "Corregir y reenviar",
+ "settings_mcw_managed_carrier": "Operador gestionado",
+ "settings_mcw_carrier_note": "Qué operador aprovisiona y gestiona el número dedicado de esta cuenta.",
+ "settings_mcw_save_carrier": "Guardar el operador",
+ "settings_mcw_update_business_info": "Actualizar la información de la empresa",
+ "settings_mcw_business_info": "Información de la empresa",
+ "settings_mcw_business_info_desc": "Se exige para el registro de marca o campaña 10DLC o para la verificación de número gratuito. Se envía directamente a The Campaign Registry (TCR) o a Twilio. En las respuestas sobre el caso de uso y el consentimiento, describa el consentimiento por capas con honestidad: consumidores = consentimiento registrado; partes comerciales del trabajo = relación comercial establecida; STOP para todos. Consulte docs/sms-compliance.md para ver un texto de ejemplo.",
+ "settings_mcw_registration_channel": "Canal de registro",
+ "settings_mcw_channel_10dlc_label": "Número local 10DLC",
+ "settings_mcw_channel_10dlc_desc": "Número local dedicado mediante marca + campaña (TCR). Ideal para A2P de alto volumen.",
+ "settings_mcw_channel_tollfree_label": "Número gratuito (TFV)",
+ "settings_mcw_channel_tollfree_desc": "Número de la serie 800 con verificación de número gratuito. Aprobación más simple, menor rendimiento.",
+ "settings_mcw_legal_name_label": "Razón social *",
+ "settings_mcw_legal_name_placeholder": "Acme Inspection LLC",
+ "settings_mcw_rep_name_label": "Representante autorizado *",
+ "settings_mcw_rep_name_placeholder": "Ana Pérez",
+ "settings_mcw_address_label": "Dirección comercial (calle, ciudad, estado, código postal) *",
+ "settings_mcw_address_placeholder": "123 Main St, Springfield, IL 62701",
+ "settings_mcw_email_label": "Correo electrónico de contacto",
+ "settings_mcw_email_placeholder": "compliance@acme.com",
+ "settings_mcw_areacode_label": "Código de área preferido (opcional)",
+ "settings_mcw_areacode_hint": "Código de área de 3 dígitos para la búsqueda de número. Déjelo en blanco para cualquiera.",
+ "settings_mcw_submitting": "Enviando…",
+ "settings_mcw_start_provisioning": "Iniciar el aprovisionamiento",
+ "settings_services_template_consequence": "Sin una plantilla, un cliente que elija este servicio no puede reservar en línea.",
+ "settings_services_template_shared_one": "{name} también usa esta plantilla — una inspección con ambos genera dos informes idénticos.",
+ "settings_services_template_shared_many": "{name} y {count} más también usan esta plantilla — una inspección con varios genera informes duplicados.",
+ "settings_services_template_none_available": "Todavía no hay plantillas.",
+ "settings_services_template_create_link": "Crear una plantilla",
+ "settings_services_edit_heading": "Editar {name}",
+ "settings_services_edit": "Editar",
+ "settings_services_error_update_failed": "No se pudo guardar este servicio. Inténtelo de nuevo.",
+ "settings_qual_change_link": "Cambiar los inspectores"
}
From 405679b39232de4d5d7439533aa8e57c3a986aea Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:08:42 +0800
Subject: [PATCH 036/111] i18n(es-419): translate settings-integrations.json
(294 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Communication, message templates, QuickBooks, connected MCP apps, billing,
GDPR compliance and automations.
Second declared divergence: English 'Subject' is a homograph. On the template
editor it is the email subject line (Asunto); in the erasure log it is the GDPR
data subject, a person (Interesado). No Spanish word covers both.
Three more instances of the {plural} call-site defect the plan already tracks —
settings_msgtpl_delete_conflict, settings_msgtpl_segments_count and
settings_billing_seat_line. All three now use number-invariant phrasing that
attaches {plural} to a noun taking a bare -s (flujo/flujos, segmento/segmentos,
puesto/puestos), because automatizacion+s is not a Spanish word.
settings_msgtpl_delete_confirm_* is a fourth shape of the same problem: the
sentence is split around the name, the prefix is pinned to 'Eliminar' by the
consistency rule, and Spanish cannot then open with the mandatory inverted
question mark. Rendered as a statement instead.
Coverage 2285 -> 2579 of 4323.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
docs/developers/i18n-glossary.md | 23 ++
messages/es-419/settings-integrations.json | 296 ++++++++++++++++++++-
2 files changed, 318 insertions(+), 1 deletion(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index da79e2d87..3ea0d1694 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -447,6 +447,27 @@ translated one would be a compliance failure, not a typo.
| Slot | horario | — | A bookable start time. "Slot rules" → *Reglas de horarios*, "Slot interval" → *Intervalo entre horarios*. |
| Weekday names | Domingo … Sábado | — | Capitalised, because each is a standalone row label and buttons/labels take sentence case. Spanish lowercases weekdays mid-sentence; nothing in this catalogue puts one mid-sentence. |
+### Communication, billing and compliance settings
+
+Fixed while translating `settings-integrations.json` (294 keys).
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Subject (email) | Asunto | — | The subject line. Distinct from the GDPR data subject — see the declared divergence. |
+| Data subject | Interesado | — | The GDPR term for the person a record is about. The regulation's own Spanish text uses *interesado*. |
+| Seat | puesto | — | A licensed team member. Never *asiento*: that is a chair, an *asiento contable* is a ledger entry, and in this product's own domain *asiento* is foundation settlement. Deliberately not machine-banned for exactly that last reason. |
+| Managed (SMS, provider, number) | gestionado | — | Run by the platform on the tenant's behalf. |
+| Self-hosted | autoalojado | — | "Self-host docs" → *Documentación de autoalojamiento*. |
+| Standalone mode | modo autónomo | — | The single-tenant deployment. |
+| Deployment | instalación | despliegue | *Despliegue* is a military deployment; an operator reads *instalación*. |
+| Opt-in / opt-out | consentimiento / baja | — | SMS compliance. The keywords themselves (STOP / START / HELP) stay English. |
+| Erasure request | solicitud de eliminación | — | GDPR Art. 17. Uses *eliminar*, the Delete verb. |
+| Anonymized / Retained | Anonimizado / Conservado | — | Erasure-log columns, masculine singular alongside *Eliminado*. |
+| Built-in | Integrado | — | A platform-supplied template or referral source, against the tenant's own. |
+| Estimated monthly cost | Costo mensual estimado | — | The one place *estimado* is the right word — see the Estimate row, which bans the noun and not this adjective. The figure itself is *un cálculo aproximado*, never *un estimado*. |
+
## Register enforcement
Every entry here is wrong in `es-419` in every context, which is what makes it
@@ -491,6 +512,8 @@ apply at all.
translating a product name — *Reenviar* there would name a provider that does
not exist. The two readings never share a surface.
+- `settings_comms_template_subject_label`, `settings_compliance_col_subject` — English "Subject" is a homograph, not a shared concept. On the email-template editor it is the subject line (*Asunto*); in the erasure log it is the GDPR **data subject**, a person (*Interesado*). No Spanish word covers both, and picking either would make one of the two screens nonsense.
+
*(Add further divergences as `- \`key_one\`, \`key_two\` — reason.)*
## Working through a module
diff --git a/messages/es-419/settings-integrations.json b/messages/es-419/settings-integrations.json
index 006f618aa..7d6b91415 100644
--- a/messages/es-419/settings-integrations.json
+++ b/messages/es-419/settings-integrations.json
@@ -1,3 +1,297 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "settings_crumb_root": "Configuración",
+ "settings_comms_crumb": "Comunicación",
+ "settings_integrations_crumb": "Integraciones",
+ "settings_send": "Enviar",
+ "settings_sending": "Enviando…",
+ "settings_flash_saved": "Configuración guardada.",
+ "settings_flash_saved_short": "Guardado.",
+ "settings_channel_email": "Correo electrónico",
+ "settings_channel_sms": "SMS",
+ "settings_channel_in_app": "En la aplicación",
+ "settings_connection_test_failed": "Falló la prueba de conexión.",
+ "settings_unknown_action": "Acción desconocida",
+ "settings_error_generic": "Ocurrió un error.",
+ "settings_comms_meta_title": "Comunicación - Configuración - OpenInspection",
+ "settings_comms_intro": "Configure el envío de correo electrónico, las plantillas y la sincronización del calendario.",
+ "settings_comms_nav_managed": "SMS gestionados",
+ "settings_comms_nav_calendar": "Google Calendar",
+ "settings_comms_email_save_error": "No se pudo guardar la configuración de correo electrónico.",
+ "settings_comms_provider_save_error": "No se pudo guardar la selección de proveedor.",
+ "settings_comms_email_secrets_save_error": "No se pudieron guardar los secretos de correo electrónico.",
+ "settings_comms_unknown_provider": "Proveedor desconocido.",
+ "settings_comms_credential_validation_failed": "Falló la validación de las credenciales.",
+ "settings_comms_calendar_secrets_save_error": "No se pudieron guardar los secretos del calendario.",
+ "settings_comms_google_oauth_mode_save_error": "No se pudo guardar el modo de OAuth de Google.",
+ "settings_comms_sms_settings_save_error": "No se pudo guardar la configuración de SMS.",
+ "settings_comms_sms_credentials_save_error": "No se pudieron guardar las credenciales de SMS.",
+ "settings_comms_test_sms_failed": "Falló el SMS de prueba.",
+ "settings_comms_managed_saas_only": "Los SMS gestionados solo están disponibles en la plataforma SaaS.",
+ "settings_comms_legal_name_required": "La razón social es obligatoria.",
+ "settings_comms_business_address_required": "La dirección comercial es obligatoria.",
+ "settings_comms_rep_name_required": "El nombre del representante es obligatorio.",
+ "settings_comms_email_invalid": "Ingrese una dirección de correo electrónico válida.",
+ "settings_comms_managed_provision_unavailable": "El aprovisionamiento de SMS gestionados no está disponible en el modo autónomo.",
+ "settings_comms_managed_not_configured": "Las credenciales gestionadas de Twilio no están configuradas en esta instalación.",
+ "settings_comms_provision_start_failed": "No se pudo iniciar el aprovisionamiento.",
+ "settings_comms_resubmit_failed": "No se pudo volver a enviar.",
+ "settings_comms_managed_provider_save_error": "No se pudo guardar el proveedor gestionado.",
+ "settings_comms_template_update_error": "No se pudo actualizar la plantilla.",
+ "settings_comms_dedicated_heading": "Configuración del número dedicado",
+ "settings_comms_dedicated_desc": "Aprovisione su propio número local o gratuito dedicado, gestionado por la plataforma. Envíe abajo la información de su empresa para iniciar el registro de TCR / TFV.",
+ "settings_comms_shared_pool_heading": "Estado del grupo compartido",
+ "settings_comms_shared_pool_desc": "Sus mensajes se envían desde un número compartido gestionado por la plataforma. No se requiere configuración adicional.",
+ "settings_comms_shared_pool_active": "Grupo de la plataforma: Activo",
+ "settings_comms_email_templates_heading": "Plantillas de correo electrónico",
+ "settings_comms_manage_templates_link": "Administrar las plantillas",
+ "settings_comms_templates_count": "{count} plantillas · haga clic para personalizar",
+ "settings_comms_no_templates": "No hay plantillas de correo electrónico disponibles.",
+ "settings_comms_template_meta_title": "{name} - Comunicación - OpenInspection",
+ "settings_comms_template_meta_fallback": "Plantilla",
+ "settings_comms_template_reset_error": "No se pudo restablecer.",
+ "settings_comms_template_save_error": "No se pudo guardar.",
+ "settings_comms_template_reset_confirm": "¿Restablecer al valor predeterminado?",
+ "settings_comms_template_reset_button": "Restablecer",
+ "settings_comms_template_reset_default": "Restablecer al valor predeterminado",
+ "settings_comms_template_send_toggle": "Enviar este correo electrónico",
+ "settings_comms_template_subject_label": "Asunto",
+ "settings_comms_template_variables_hint": "Variables — haga clic para insertar",
+ "settings_comms_template_signature_desc": "Su firma de correo electrónico — defínala en Configuración → Perfil",
+ "settings_comms_template_save_changes": "Guardar cambios",
+ "settings_msgtpl_meta_title": "Plantillas de mensajes - Configuración - OpenInspection",
+ "settings_msgtpl_crumb": "Plantillas",
+ "settings_msgtpl_create_error": "No se pudo crear la plantilla.",
+ "settings_msgtpl_update_error": "No se pudo actualizar la plantilla.",
+ "settings_msgtpl_duplicate_error": "No se pudo duplicar la plantilla.",
+ "settings_msgtpl_in_use": "La plantilla está en uso.",
+ "settings_msgtpl_delete_error": "No se pudo eliminar la plantilla.",
+ "settings_msgtpl_preview_error": "Falló la vista previa.",
+ "settings_msgtpl_test_send_error": "Falló el envío de prueba.",
+ "settings_msgtpl_intro": "Plantillas de mensajes reutilizables para las automatizaciones.",
+ "settings_msgtpl_new_button": "+ Nueva plantilla",
+ "settings_msgtpl_empty_title": "Todavía no hay plantillas",
+ "settings_msgtpl_empty_desc": "Cree una plantilla para reutilizarla en sus automatizaciones.",
+ "settings_msgtpl_builtin_pill": "Integrada",
+ "settings_msgtpl_subject_prefix": "Asunto: {subject}",
+ "settings_msgtpl_variables_prefix": "Variables: {vars}",
+ "settings_msgtpl_duplicate": "Duplicar",
+ "settings_msgtpl_delete_title": "Eliminar plantilla",
+ "settings_msgtpl_delete_conflict": "Esta plantilla se usa en {count} flujo{plural} de automatización y no se puede eliminar:",
+ "settings_msgtpl_delete_conflict_hint": "Primero quite la plantilla de esas automatizaciones y vuelva a intentarlo.",
+ "settings_msgtpl_delete_confirm_prefix": "Eliminar",
+ "settings_msgtpl_delete_confirm_suffix": ". Esta acción no se puede deshacer.",
+ "settings_msgtpl_edit_title": "Editar plantilla",
+ "settings_msgtpl_new_channel_title": "Nueva plantilla de {channel}",
+ "settings_msgtpl_create": "Crear",
+ "settings_msgtpl_name_label": "Nombre de la plantilla",
+ "settings_msgtpl_name_placeholder": "por ejemplo, Informe listo — correo electrónico",
+ "settings_msgtpl_subject_line_label": "Línea de asunto",
+ "settings_msgtpl_subject_placeholder": "Su informe de inspección está listo",
+ "settings_msgtpl_email_body_label": "Cuerpo del correo electrónico",
+ "settings_msgtpl_sms_body_label": "Cuerpo del SMS",
+ "settings_msgtpl_insert_label": "Insertar:",
+ "settings_msgtpl_segments_zero": "0 caracteres · 0 segmentos",
+ "settings_msgtpl_segments_count": "{chars} caracteres · {segments} segmento{plural}",
+ "settings_msgtpl_preview_label": "Vista previa",
+ "settings_msgtpl_refresh_preview": "Actualizar la vista previa",
+ "settings_msgtpl_preview_subject_label": "Asunto:",
+ "settings_msgtpl_test_send_email_heading": "Enviar un correo electrónico de prueba",
+ "settings_msgtpl_test_send_sms_heading": "Enviar un SMS de prueba",
+ "settings_msgtpl_to_email_label": "Al correo electrónico",
+ "settings_msgtpl_to_phone_label": "Al teléfono (+1 555 000 0000)",
+ "settings_msgtpl_to_email_placeholder": "nombre@ejemplo.com",
+ "settings_msgtpl_to_phone_placeholder": "+15550001234",
+ "settings_msgtpl_test_sent": "Prueba enviada.",
+ "settings_msgtpl_compliance_heading": "SMS de cumplimiento",
+ "settings_msgtpl_optin_heading": "Aviso de consentimiento",
+ "settings_msgtpl_optin_desc_before": "El texto de su aviso de consentimiento se configura en",
+ "settings_msgtpl_optin_link": "Configuración de comunicación",
+ "settings_msgtpl_optin_desc_after": "en Envío de SMS. Las cuentas que usan Twilio propio (BYO) administran allí el texto del aviso.",
+ "settings_msgtpl_stopstart_heading": "STOP / START / HELP",
+ "settings_msgtpl_stopstart_desc": "Las palabras clave entrantes de baja (STOP), alta (START) y ayuda (HELP) las gestiona automáticamente su proveedor de SMS. Cuando un destinatario envía STOP, los mensajes futuros se suprimen en el proveedor. Usted no necesita gestionar estas respuestas manualmente: quedan registradas en el registro de cumplimiento, en Configuración de comunicación.",
+ "settings_integrations_meta_title": "Integraciones - Configuración - OpenInspection",
+ "settings_integrations_intro": "Conecte OpenInspection con sus otras herramientas de negocio.",
+ "settings_integrations_stripe_save_error": "No se pudieron guardar las claves de Stripe.",
+ "settings_integrations_video_plan_managed": "En el modo alojado, el backend de video lo gestiona el plan.",
+ "settings_integrations_video_invalid": "Configuración de video no válida.",
+ "settings_integrations_video_read_error": "No se pudo leer la configuración actual. No se guardó ningún cambio.",
+ "settings_integrations_video_mode_save_error": "No se pudo guardar el modo de video.",
+ "settings_integrations_video_config_save_error": "No se pudo guardar la configuración de la integración.",
+ "settings_integrations_video_saved": "Configuración de video guardada.",
+ "settings_qbo_meta_title": "Integración con QuickBooks - OpenInspection",
+ "settings_qbo_crumb": "QuickBooks Online",
+ "settings_qbo_save_error": "No se pudieron guardar las claves de QBO.",
+ "settings_qbo_action_failed": "Falló: {intent}",
+ "settings_qbo_time_never": "Nunca",
+ "settings_qbo_time_just_now": "Hace un momento",
+ "settings_qbo_time_minutes_ago": "hace {minutes} minutos",
+ "settings_qbo_time_hours_ago": "hace {hours} horas",
+ "settings_qbo_flash_saved": "Credenciales de QBO guardadas.",
+ "settings_qbo_api_credentials_heading": "Credenciales de API",
+ "settings_qbo_credentials_desc_before": "Credenciales de OAuth de su aplicación de QuickBooks Developer. Se exigen antes de conectar. Obténgalas en",
+ "settings_qbo_credentials_link": "developer.intuit.com",
+ "settings_qbo_client_id_label": "ID de cliente de QBO",
+ "settings_qbo_client_id_hint": "Integración con QuickBooks Online para la sincronización de facturas. Créela en developer.intuit.com → My Apps",
+ "settings_qbo_client_secret_label": "Secreto de cliente de QBO",
+ "settings_qbo_client_secret_hint": "Se combina con el ID de cliente. Se encuentra en la misma configuración de la aplicación de Intuit",
+ "settings_qbo_webhook_label": "Token verificador del webhook de QBO",
+ "settings_qbo_webhook_hint": "Verifica las notificaciones de cambio de datos de QuickBooks. Se encuentra en developer.intuit.com → Webhooks",
+ "settings_qbo_save_credentials": "Guardar las credenciales",
+ "settings_qbo_expiry_warning": "Su conexión con QuickBooks vence pronto.",
+ "settings_qbo_reconnect_link": "Vuelva a conectar para evitar interrupciones.",
+ "settings_qbo_connect_heading": "Conectar QuickBooks Online",
+ "settings_qbo_feature_sync": "Sincronización de facturas en tiempo real",
+ "settings_qbo_feature_payments": "Actualizaciones automáticas del estado de pago",
+ "settings_qbo_feature_dedup": "Detección de clientes duplicados",
+ "settings_qbo_feature_void": "Sincronización de anulaciones y reembolsos de facturas",
+ "settings_qbo_connect_button": "Conectar QuickBooks",
+ "settings_qbo_connected_fallback": "Conectado",
+ "settings_qbo_last_synced": "Última sincronización: {time}",
+ "settings_qbo_status_active": "Activo",
+ "settings_qbo_status_paused": "Pausado",
+ "settings_qbo_syncing": "Sincronizando...",
+ "settings_qbo_sync_now": "Sincronizar ahora",
+ "settings_qbo_pause_sync": "Pausar la sincronización",
+ "settings_qbo_resume_sync": "Reanudar la sincronización",
+ "settings_qbo_disconnect": "Desconectar",
+ "settings_qbo_sync_errors": "Errores de sincronización ({count})",
+ "settings_qbo_sync_errors_desc": "Consulte el registro de errores de sincronización para ver los detalles. Los errores se reintentan automáticamente en la próxima sincronización.",
+ "settings_apps_meta_title": "Aplicaciones conectadas - Configuración - OpenInspection",
+ "settings_apps_crumb": "Aplicaciones conectadas",
+ "settings_apps_all_modules": "Todos los módulos",
+ "settings_apps_no_modules": "Sin módulos",
+ "settings_apps_unknown_user": "Usuario desconocido",
+ "settings_apps_created": "Creada el {date}",
+ "settings_apps_expires": "· Vence el {date}",
+ "settings_apps_no_expiry": "· Sin vencimiento",
+ "settings_apps_revoke": "Revocar",
+ "settings_apps_mcp_disabled_title": "MCP no está habilitado en esta instalación.",
+ "settings_apps_mcp_disabled_desc": "Comuníquese con su administrador para habilitar el acceso remoto por MCP.",
+ "settings_apps_intro": "Clientes MCP (por ejemplo, Claude) que usted autorizó a acceder a sus datos. Revoque el acceso en cualquier momento.",
+ "settings_apps_your_heading": "Sus aplicaciones",
+ "settings_apps_none_title": "Todavía no hay aplicaciones autorizadas.",
+ "settings_apps_none_desc": "Cuando usted autorice un cliente MCP (por ejemplo, Claude), aparecerá aquí.",
+ "settings_apps_tenant_heading": "Aplicaciones autorizadas en toda la cuenta",
+ "settings_apps_tenant_desc": "Todas las autorizaciones de clientes MCP de su equipo. Usted puede revocar el acceso de cualquier miembro.",
+ "settings_apps_tenant_none": "Ningún miembro del equipo ha autorizado aplicaciones.",
+ "settings_apps_revoke_title": "Revocar el acceso",
+ "settings_apps_revoke_confirm": "¿Revocar \"{name}\"? La aplicación perderá el acceso de inmediato.",
+ "settings_billing_crumb": "Facturación",
+ "settings_billing_intro_hosted": "Administre su suscripción, sus puestos y sus facturas.",
+ "settings_billing_intro_selfhost": "Instalación autoalojada — no se requiere suscripción.",
+ "settings_billing_selfhost_heading": "Autoalojado · sin suscripción",
+ "settings_billing_selfhost_desc": "Esta instalación funciona en modo autónomo. Sin cargo por puesto, sin Stripe. Agregue tantos inspectores como necesite.",
+ "settings_billing_github_link": "OpenInspection en GitHub",
+ "settings_billing_current_plan": "Plan actual",
+ "settings_billing_open_portal": "Abrir el portal de Stripe",
+ "settings_billing_seats_used": "Puestos usados",
+ "settings_billing_active_members": "Miembros activos",
+ "settings_billing_permanent": "Permanente",
+ "settings_billing_view_usage": "Ver el uso de SMS, correo electrónico y almacenamiento",
+ "settings_billing_capacity_heading": "Capacidad del espacio de trabajo",
+ "settings_billing_capacity_desc": "No hay cuotas en el modo autónomo — estos datos son informativos.",
+ "settings_billing_cost_heading": "Costo mensual estimado",
+ "settings_billing_cost_desc": "Stripe emite la factura oficial — estas cifras son un cálculo aproximado basado en la tarifa por puesto.",
+ "settings_billing_seat_line": "{count} puesto{plural} de inspector permanente · $29.99 cada uno",
+ "settings_billing_seat_charges": "Cargos aproximados por puesto de este mes",
+ "settings_billing_invoices_heading": "Facturas y método de pago",
+ "settings_billing_invoices_desc": "El historial de facturas, las actualizaciones de la tarjeta guardada y {kind} ocurren en el portal de facturación alojado por Stripe, para que el cumplimiento de PCI quede fuera de OpenInspection.",
+ "settings_billing_seat_cycle": "los cambios de ciclo de puestos",
+ "settings_billing_plan_tier": "los cambios de nivel de plan",
+ "settings_billing_manage_portal": "Administrar en el portal de Stripe",
+ "settings_billing_no_portal": "El portal de facturación no está configurado en esta instalación.",
+ "settings_billing_need_hosted": "¿Prefiere la versión alojada?",
+ "settings_billing_hosted_pitch": "InspectorHub.io ofrece el mismo código de OpenInspection como servicio gestionado — sin cuenta de Cloudflare, sin preocuparse por las cuotas de D1.",
+ "settings_billing_try_hosted": "Pruebe la versión alojada",
+ "settings_billing_want_selfhost": "¿Quiere alojarlo usted mismo?",
+ "settings_billing_selfhost_pitch": "Todas las funciones de colaboración son gratuitas en la versión de código abierto. La suscripción por puesto solo existe en el plan compartido alojado.",
+ "settings_billing_selfhost_docs": "Documentación de autoalojamiento",
+ "settings_billing_add_seat_heading": "Agregar un puesto",
+ "settings_billing_add_seat_before": "Agregue un inspector en",
+ "settings_billing_team_link": "Configuración del equipo",
+ "settings_compliance_meta_title": "Cumplimiento - Configuración - OpenInspection",
+ "settings_compliance_crumb": "Cumplimiento",
+ "settings_compliance_retention_range_error": "Ingrese un número entero entre {min} y {max}.",
+ "settings_compliance_intro": "Política de retención del RGPD y el registro de las solicitudes de eliminación que usted ha atendido.",
+ "settings_compliance_retention_heading": "Período de retención de los acuerdos",
+ "settings_compliance_retention_desc": "Cuánto tiempo se conservan los acuerdos firmados y las firmas antes de destruirse de forma permanente.",
+ "settings_compliance_years_label": "Años",
+ "settings_compliance_saving": "Guardando...",
+ "settings_compliance_save_failed": "No se pudo guardar. Inténtelo de nuevo.",
+ "settings_compliance_retention_note": "Se conserva como obligación legal según el art. 17(3)(e) del RGPD (defensa de reclamaciones legales). El valor predeterminado de 6 años coincide con el plazo de prescripción de los contratos simples del Reino Unido. Nota: las filas eliminadas siguen siendo restaurables desde las copias de seguridad de D1 Time-Travel hasta por 30 días.",
+ "settings_compliance_erasure_heading": "Solicitudes de eliminación recientes",
+ "settings_compliance_erasure_desc": "El registro de rendición de cuentas de las solicitudes de eliminación de los interesados que usted ha atendido. Solo lectura.",
+ "settings_compliance_erasure_empty": "Todavía no se ha registrado ninguna solicitud de eliminación.",
+ "settings_compliance_col_subject": "Interesado",
+ "settings_compliance_col_date": "Fecha",
+ "settings_compliance_col_status": "Estado",
+ "settings_compliance_col_deleted": "Eliminado",
+ "settings_compliance_col_anonymized": "Anonimizado",
+ "settings_compliance_col_retained": "Conservado",
+ "settings_compliance_status_completed": "Completado",
+ "settings_compliance_status_partial": "Parcial",
+ "settings_compliance_status_refused": "Denegado",
+ "settings_compliance_legal_heading": "Privacidad y Términos",
+ "settings_compliance_legal_desc": "Páginas públicas para los clientes y para el registro ante los operadores (número gratuito / 10DLC). Se muestran en los pies de página de las reservas, el portal y los informes.",
+ "settings_compliance_legal_mode_hosted": "Páginas de OpenInspection",
+ "settings_compliance_legal_mode_custom": "Mi propio sitio web",
+ "settings_compliance_legal_hosted_note": "Publicamos Privacidad y Términos con el nombre de su empresa. Copie estas URL en la verificación de Twilio. Deje los cuadros de texto vacíos para usar la plantilla integrada.",
+ "settings_compliance_legal_custom_note": "Las páginas deben ser públicas, nombrar su empresa e incluir el texto sobre SMS / STOP. Las URL rotas o protegidas por inicio de sesión no pasan la revisión del operador.",
+ "settings_compliance_legal_privacy_url": "URL de la política de privacidad",
+ "settings_compliance_legal_terms_url": "URL de los términos del servicio",
+ "settings_compliance_legal_privacy_body": "Texto de la página de privacidad (opcional)",
+ "settings_compliance_legal_terms_body": "Texto de la página de términos (opcional)",
+ "settings_compliance_legal_body_placeholder": "Déjelo en blanco para usar la plantilla integrada…",
+ "settings_compliance_legal_copy": "Copiar",
+ "settings_compliance_legal_open": "Abrir la página",
+ "settings_compliance_legal_unsaved": "Cambios sin guardar",
+ "settings_compliance_legal_custom_required": "Ingrese las URL de Privacidad y de Términos cuando use su propio sitio web.",
+ "settings_automations_meta_title": "Automatizaciones - Configuración - OpenInspection",
+ "settings_automations_crumb": "Automatizaciones",
+ "settings_automations_intro": "Correos electrónicos que se envían automáticamente cuando ocurren eventos de la inspección.",
+ "settings_automations_new_button": "+ Nueva automatización",
+ "settings_automations_review_label": "Enlace de reseñas",
+ "settings_automations_review_hint": "Pegue su enlace de reseñas de Google o Yelp. La automatización “Solicitud de reseña” permanece desactivada hasta que lo defina.",
+ "settings_automations_review_placeholder": "https://g.page/r/...",
+ "settings_automations_empty": "Todavía no hay automatizaciones.",
+ "settings_automations_default_badge": "Predeterminado",
+ "settings_automations_disable_aria": "Deshabilitar la automatización",
+ "settings_automations_enable_aria": "Habilitar la automatización",
+ "settings_automations_recent_heading": "Actividad reciente",
+ "settings_automations_recent_empty": "Todavía no hay actividad de automatizaciones.",
+ "settings_automations_edit_title": "Editar la automatización",
+ "settings_automations_new_title": "Nueva automatización",
+ "settings_automations_confirm_delete": "¿Confirmar la eliminación?",
+ "settings_automations_pick_channel_title": "Elija al menos un canal de envío",
+ "settings_automations_name_placeholder": "Nombre de la automatización",
+ "settings_automations_when_legend": "Cuándo",
+ "settings_automations_onlyif_legend": "Solo si",
+ "settings_automations_require_paid": "El cliente ha pagado",
+ "settings_automations_require_signed": "Acuerdo firmado",
+ "settings_automations_limit_services": "Limitar a servicios (ninguno = cualquiera):",
+ "settings_automations_dothis_legend": "Haga esto",
+ "settings_automations_delay_title": "Retraso en minutos (para los recordatorios: minutos ANTES de la inspección)",
+ "settings_automations_pick_channel": "Elija al menos un canal de envío.",
+ "settings_automations_all_recipients_warning": "\"Todos los de la inspección\" envía a todas las personas agregadas a cada inspección que reciben el informe, incluido cualquier agente del vendedor. Use un rol específico si el informe del comprador no debe llegar al lado del vendedor.",
+ "settings_automations_template_label": "Plantilla",
+ "settings_automations_select_template": "— Seleccione una plantilla —",
+ "settings_automations_edit_new_template": "Editar / Nueva plantilla",
+ "settings_automations_save_failed": "No se pudo guardar — inténtelo de nuevo.",
+ "settings_automations_request_failed": "Falló la solicitud",
+ "settings_automations_recipient_kind_role": "Un rol",
+ "settings_automations_recipient_kind_inspector": "Inspector",
+ "settings_automations_recipient_kind_all": "Todos los del informe",
+ "settings_automations_select_role": "— Seleccione un rol —",
+ "settings_advanced_crumb": "Avanzado",
+ "settings_advanced_intro": "Pagos con Stripe, funciones de IA e integraciones.",
+ "settings_advanced_stripe_connect_error": "No se pudo conectar la cuenta de Stripe.",
+ "settings_advanced_stripe_disconnect_error": "No se pudo desconectar la cuenta de Stripe.",
+ "settings_advanced_api_key_required": "La clave de API es obligatoria.",
+ "settings_advanced_ai_save_error": "No se pudo guardar la configuración de IA.",
+ "settings_advanced_integration_keys_save_error": "No se pudieron guardar las claves de las integraciones.",
+ "settings_advanced_data_heading": "Gestión de datos",
+ "settings_advanced_data_desc": "Importe datos desde otra plataforma de inspección o exporte sus datos como copia de seguridad.",
+ "settings_advanced_import_export": "Importar / Exportar datos",
+ "settings_automations_recipient_kind_staff": "Personal de oficina (titulares y gerentes)"
}
From b6f5f875303c4b0ee18fd0d7fe388b549825f877 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:13:50 +0800
Subject: [PATCH 037/111] i18n(es-419): translate auth.json (126 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Finishes the module the Phase C login pilot started: password rules, forgot /
reset, team invite accept, first-run setup, and the whole agent invite / signup /
sign-in track.
auth is almost entirely imperatives, which is exactly where the usted decision
is lost silently — the register gate reads pronouns, so it would have caught
'tus' and would not have caught 'Ingresa'. Every imperative here is the usted
form: Ingrese, Revise, Defina, Solicite, Cree, Configure, Regístrese.
English uses both 'log in' and 'sign in' for one act; Spanish has one, so both
land on Iniciar sesion and both 'Back to ...' links become Volver al inicio de
sesion — the destination is the page. Still distinct from Firmar.
'You have been invited' is rendered as 'Usted recibio una invitacion' rather
than 'Ha sido invitado': the catalogue cannot know the reader's gender, and a
participle agreeing with the reader is wrong half the time.
Coverage 2579 -> 2705 of 4323.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
docs/developers/i18n-glossary.md | 19 +++++
messages/es-419/auth.json | 128 ++++++++++++++++++++++++++++++-
2 files changed, 146 insertions(+), 1 deletion(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index 3ea0d1694..e1e3ed28f 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -468,6 +468,25 @@ Fixed while translating `settings-integrations.json` (294 keys).
| Built-in | Integrado | — | A platform-supplied template or referral source, against the tenant's own. |
| Estimated monthly cost | Costo mensual estimado | — | The one place *estimado* is the right word — see the Estimate row, which bans the noun and not this adjective. The figure itself is *un cálculo aproximado*, never *un estimado*. |
+### Authentication
+
+Fixed while finishing `auth.json` (126 keys beyond the 15-key login pilot). This
+module is almost entirely imperatives, which is where the register decision is
+either kept or quietly lost — the gate catches *tus*, it does not catch
+*Ingresa*.
+
+
+
+| English | es-419 | Never | Why |
+|---|---|---|---|
+| Log in / Sign in | Iniciar sesión | loguearse, iniciar la sesión | English uses both verbs for one act; Spanish has one. "Log In" and "Sign in" therefore land on the same string, and "Back to log in" / "Back to sign in" both become *Volver al inicio de sesión* — the destination is the page, not the act. Still distinct from *Firmar*, which signs a document. |
+| Reset (a password) | Restablecer | resetear | *Resetear* is an anglicism. "Reset link" → *enlace de restablecimiento*. |
+| Setup (first-run) | Configuración inicial | — | Distinguished from Settings → *Configuración*, which is the ongoing page. The `SETUP_CODE` secret name and the Cloudflare dashboard path beside it stay English. |
+| Sign up | Registrarse | — | Creating an account. Distinct from *Iniciar sesión*. |
+| Partner agent | agente asociado | — | The agent-portal relationship. |
+| Referral (an agent's) | referencia | — | Also *Fuentes de referencia* in company settings; one root for both. |
+| You have been invited | Usted recibió una invitación | — | The passive shape avoids *Ha sido invitado/invitada*: the catalogue cannot know the reader's gender, and every second-person sentence in `auth` has to survive that. Prefer a construction with no participle agreeing with the reader. |
+
## Register enforcement
Every entry here is wrong in `es-419` in every context, which is what makes it
diff --git a/messages/es-419/auth.json b/messages/es-419/auth.json
index f5ef4a1d2..2146e2ba5 100644
--- a/messages/es-419/auth.json
+++ b/messages/es-419/auth.json
@@ -14,5 +14,131 @@
"auth_login_error_network": "Error de red: ¿el servidor de la API está en ejecución?",
"auth_validation_email_required": "El correo electrónico es obligatorio",
"auth_validation_email_invalid": "Correo electrónico no válido",
- "auth_validation_password_required": "La contraseña es obligatoria"
+ "auth_validation_password_required": "La contraseña es obligatoria",
+ "auth_validation_password_min8": "La contraseña debe tener al menos 8 caracteres",
+ "auth_validation_password_uppercase": "Debe contener al menos una letra mayúscula",
+ "auth_validation_password_number": "Debe contener al menos un número",
+ "auth_validation_password_special": "Debe contener al menos un carácter especial",
+ "auth_validation_workspace_name_required": "El nombre del espacio de trabajo es obligatorio",
+ "auth_validation_your_name_required": "Su nombre es obligatorio",
+ "auth_validation_name_too_long": "El nombre es demasiado largo",
+ "auth_validation_setup_code_min": "El código de configuración debe tener al menos 6 caracteres",
+ "auth_validation_name_required": "El nombre es obligatorio",
+ "auth_validation_full_name_required": "Ingrese su nombre completo",
+ "auth_validation_password_min12": "La contraseña debe tener al menos 12 caracteres",
+ "auth_validation_password_too_long": "La contraseña es demasiado larga",
+ "auth_password_hint": "Al menos 8 caracteres, con una letra mayúscula, un número y un carácter especial.",
+ "auth_forgot_meta_title": "Restablezca su contraseña - OpenInspection",
+ "auth_forgot_sent_heading": "Revise su bandeja de entrada",
+ "auth_forgot_sent_subtitle_prefix": "Si existe una cuenta para",
+ "auth_forgot_sent_subtitle_suffix": ", le enviamos un enlace para restablecer la contraseña.",
+ "auth_forgot_sent_expiry_note": "El enlace vence en 1 hora. Revise su carpeta de spam si no llega.",
+ "auth_forgot_sent_different_email": "Usar otro correo electrónico",
+ "auth_forgot_heading": "Restablezca su contraseña",
+ "auth_forgot_subtitle": "Ingrese su correo electrónico y le enviaremos un enlace para restablecerla.",
+ "auth_forgot_back_to_login": "Volver al inicio de sesión",
+ "auth_forgot_submit_pending": "Enviando…",
+ "auth_forgot_submit": "Enviar el enlace de restablecimiento",
+ "auth_reset_meta_title": "Defina una nueva contraseña - OpenInspection",
+ "auth_reset_invalid_heading": "Enlace de restablecimiento no válido",
+ "auth_reset_error_invalid_link": "Este enlace de restablecimiento no es válido o ya venció. Solicite uno nuevo.",
+ "auth_reset_request_new_link": "Solicitar un enlace nuevo",
+ "auth_reset_done_heading": "Contraseña actualizada",
+ "auth_reset_done_subtitle": "Ya puede iniciar sesión con su nueva contraseña.",
+ "auth_reset_go_to_login": "Ir al inicio de sesión",
+ "auth_reset_heading": "Defina una nueva contraseña",
+ "auth_reset_password_label": "Nueva contraseña",
+ "auth_reset_submit_pending": "Actualizando…",
+ "auth_reset_submit": "Actualizar la contraseña",
+ "auth_join_meta_title": "Aceptar la invitación - OpenInspection",
+ "auth_join_error_missing_token": "Falta el token de la invitación",
+ "auth_join_error_invalid": "Enlace de invitación no válido o vencido",
+ "auth_join_error_unavailable": "Servicio no disponible",
+ "auth_join_error_accept_failed": "No se pudo aceptar la invitación. Es posible que el enlace haya vencido.",
+ "auth_join_invalid_heading": "Invitación no válida",
+ "auth_join_heading": "Únase a {name}",
+ "auth_join_heading_fallback_name": "el equipo",
+ "auth_join_subtitle_invited_as": "Usted recibió una invitación como {email}. Defina su nombre y su contraseña para comenzar.",
+ "auth_join_subtitle_invited": "Usted recibió una invitación. Defina su nombre y su contraseña para comenzar.",
+ "auth_join_name_label": "Nombre completo",
+ "auth_join_submit_pending": "Aceptando…",
+ "auth_join_submit": "Aceptar la invitación",
+ "auth_setup_meta_title": "Configuración inicial - OpenInspection",
+ "auth_setup_error_failed": "Falló la configuración inicial. Revise los datos que ingresó.",
+ "auth_setup_error_no_session": "La configuración inicial se completó, pero no se creó ninguna sesión",
+ "auth_setup_heading": "Configure su cuenta",
+ "auth_setup_subtitle": "Cree la primera cuenta de administrador y configure su empresa de inspecciones.",
+ "auth_setup_company_label": "Nombre de la empresa",
+ "auth_setup_company_placeholder": "Acme Home Inspections",
+ "auth_setup_name_label": "Su nombre",
+ "auth_setup_name_placeholder": "Carlos Rivera",
+ "auth_setup_name_help": "Se muestra en su enlace público de reservas, en los acuerdos firmados y en las facturas.",
+ "auth_setup_email_label": "Correo electrónico del administrador",
+ "auth_setup_code_label": "Código de configuración",
+ "auth_setup_code_placeholder": "Código de verificación de la configuración",
+ "auth_setup_code_help_required": "Obligatorio (al menos 6 caracteres).",
+ "auth_setup_code_help_enter_prefix": "Ingrese el valor del secreto",
+ "auth_setup_code_help_middle": "en este Worker. ¿Todavía no tiene uno? Agréguelo en el panel de Cloudflare, en",
+ "auth_setup_code_help_settings_path": "Settings → Variables and Secrets",
+ "auth_setup_code_help_suffix": "(tipo Secret), y luego actualice esta página.",
+ "auth_setup_code_help_link": "Cómo agregar un secreto →",
+ "auth_setup_submit_pending": "Creando su cuenta…",
+ "auth_setup_submit": "Crear cuenta",
+ "auth_agent_invite_meta_title": "Tiene una invitación - OpenInspection",
+ "auth_agent_invite_error_failed": "No se pudo aceptar la invitación",
+ "auth_agent_invite_unavailable_heading": "Invitación no disponible",
+ "auth_agent_invite_unavailable_body": "Este enlace de invitación venció, ya se usó o no es válido.",
+ "auth_agent_invite_signup_link": "Regístrese directamente",
+ "auth_agent_invite_heading": "Tiene una invitación",
+ "auth_agent_invite_body_at": "de",
+ "auth_agent_invite_body_rest": "le envió una invitación para ser agente asociado. Vea todas las inspecciones que sus inspectores completan para los clientes que usted refiere.",
+ "auth_agent_invite_prop1_title": "Referencias en tiempo real",
+ "auth_agent_invite_prop1_sub": "Vea los informes en cuanto estén listos",
+ "auth_agent_invite_prop2_title": "Vista entre empresas",
+ "auth_agent_invite_prop2_sub": "Todos sus inspectores, un solo panel",
+ "auth_agent_invite_prop3_title": "Gratis",
+ "auth_agent_invite_prop3_sub": "Sin comisiones, sin tarjeta guardada",
+ "auth_agent_invite_email_label": "Correo electrónico",
+ "auth_agent_invite_name_label": "Su nombre completo",
+ "auth_agent_name_placeholder": "Ana Pérez",
+ "auth_agent_invite_password_label": "Cree una contraseña",
+ "auth_agent_password_placeholder": "Al menos 12 caracteres",
+ "auth_agent_invite_submit_pending": "Configurando su cuenta...",
+ "auth_agent_invite_submit": "Aceptar la invitación",
+ "auth_agent_invite_footer_note": "Al aceptar, usted acepta recibir notificaciones cuando se inspeccionen sus referencias. Puede darse de baja en cualquier momento.",
+ "auth_agent_signup_meta_title": "Conviértase en agente asociado - OpenInspection",
+ "auth_agent_signup_error_conflict": "Ese correo electrónico ya está registrado. Inicie sesión.",
+ "auth_agent_signup_error_failed": "No se pudo crear la cuenta",
+ "auth_agent_signup_prop1_bold": "Vea todas las inspecciones que usted refirió.",
+ "auth_agent_signup_prop1_text": "Un solo panel, todos los inspectores con los que trabaja.",
+ "auth_agent_signup_prop2_bold": "Suscríbase a la disponibilidad.",
+ "auth_agent_signup_prop2_text": "Los feeds de calendario mantienen en su propia aplicación de calendario las fechas en que sus inspectores están disponibles.",
+ "auth_agent_signup_prop3_bold": "Gratis para siempre.",
+ "auth_agent_signup_prop3_text": "Sin comisiones, sin tarjeta guardada. Sus inspectores pagan la plataforma.",
+ "auth_agent_signup_heading": "Conviértase en agente asociado",
+ "auth_agent_signup_panel_text": "La forma gratuita en que los agentes inmobiliarios pueden seguir todas las inspecciones que sus inspectores completaron para los clientes que refirieron.",
+ "auth_agent_signup_form_heading": "Cree su cuenta gratuita",
+ "auth_agent_signup_form_subtitle": "Toma alrededor de un minuto. ¿Ya recibió una invitación? Use el enlace de su correo electrónico: completa automáticamente la cuenta correcta.",
+ "auth_agent_signup_email_label": "Correo electrónico del trabajo",
+ "auth_agent_signup_email_placeholder": "ana@inmobiliaria.com",
+ "auth_agent_signup_submit_pending": "Creando la cuenta...",
+ "auth_agent_signup_submit": "Crear cuenta",
+ "auth_agent_signup_have_account": "¿Ya tiene una cuenta?",
+ "auth_agent_signup_login_link": "Iniciar sesión",
+ "auth_agent_login_meta_title": "Inicio de sesión de agentes - OpenInspection",
+ "auth_agent_login_heading": "Inicio de sesión de agentes",
+ "auth_agent_login_subtitle": "Inicie sesión para ver las referencias, los informes y la disponibilidad de los inspectores.",
+ "auth_agent_login_submit": "Iniciar sesión",
+ "auth_agent_login_submit_pending": "Iniciando sesión…",
+ "auth_agent_login_error_invalid": "Correo electrónico o contraseña no válidos",
+ "auth_agent_login_error_no_session": "La autenticación se realizó correctamente, pero no se creó ninguna sesión",
+ "auth_agent_login_or_divider": "o",
+ "auth_agent_login_link_cta": "Envíenme por correo electrónico un enlace de inicio de sesión",
+ "auth_agent_login_link_submit_pending": "Enviando…",
+ "auth_agent_login_link_sent_heading": "Revise su bandeja de entrada",
+ "auth_agent_login_link_sent_subtitle": "Si existe una cuenta de agente para ese correo electrónico, le enviamos un enlace de inicio de sesión.",
+ "auth_agent_login_link_sent_note": "El enlace vence en 15 minutos y solo se puede usar una vez.",
+ "auth_agent_login_back_link": "Volver al inicio de sesión",
+ "auth_agent_login_no_account": "¿Es agente nuevo?",
+ "auth_agent_login_signup_link": "Crear una cuenta"
}
From 7b353045aaebc6fcd3254e51e816085a870ac46b Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:14:33 +0800
Subject: [PATCH 038/111] i18n(es-419): disambiguate one settings-catalog
string from a tu imperative
'Espera antes de que el editor avance...' was meant as the noun (the delay) but
is a complete, correct tu imperative read the other way ('Wait before...'), and
nothing in the sentence forces the noun reading. Retraso is the noun only, and
matches settings_automations_delay_title.
Found by sweeping the five modules for tu-imperative verb forms, which is the
one register error no gate can see: check-i18n-glossary matches pronouns and
clitics, not verb conjugations. Eleven other hits in that sweep were genuine
third-person indicatives or nouns.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
messages/es-419/settings-catalog.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/messages/es-419/settings-catalog.json b/messages/es-419/settings-catalog.json
index cc29b02a9..3defcd2a1 100644
--- a/messages/es-419/settings-catalog.json
+++ b/messages/es-419/settings-catalog.json
@@ -27,7 +27,7 @@
"settings_inspection_autoadvance_off": "Nunca (permanecer siempre en el elemento)",
"settings_inspection_autoadvance_note": "Las calificaciones de tipo Defecto o Vigilar siempre permanecen en el elemento y activan Notas para que usted pueda describir el hallazgo.",
"settings_inspection_autoadvance_delay_value": "{ms} ms",
- "settings_inspection_autoadvance_delay_help": "Espera antes de que el editor avance al siguiente elemento.",
+ "settings_inspection_autoadvance_delay_help": "Retraso antes de que el editor avance al siguiente elemento.",
"settings_inspection_required_heading": "Campos obligatorios del defecto al publicar",
"settings_inspection_required_help": "Campos que todo defecto debe tener antes de que se pueda publicar un informe. Cada inspección puede cambiar esto para su propio trabajo en la configuración de la inspección.",
"settings_inspection_required_none": "Ninguno — los campos faltantes advierten, nunca bloquean",
From 3ef6964860e8565e14b5125fb785de830e165b75 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:25:20 +0800
Subject: [PATCH 039/111] i18n(es-419): translate inspections.json (251 keys)
---
messages/es-419/inspections.json | 253 ++++++++++++++++++++++++++++++-
1 file changed, 252 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/inspections.json b/messages/es-419/inspections.json
index 006f618aa..082b4a29c 100644
--- a/messages/es-419/inspections.json
+++ b/messages/es-419/inspections.json
@@ -1,3 +1,254 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "inspections_new_meta_title": "Nueva inspección - OpenInspection",
+ "inspections_list_meta_title": "Inspecciones - OpenInspection",
+ "inspections_list_greeting_morning": "Buenos días",
+ "inspections_list_greeting_afternoon": "Buenas tardes",
+ "inspections_list_greeting_evening": "Buenas noches",
+ "inspections_list_meta_upcoming_one": "{count} inspección próxima",
+ "inspections_list_meta_upcoming_other": "{count} inspecciones próximas",
+ "inspections_list_meta_needs_one": "{count} informe requiere atención",
+ "inspections_list_meta_needs_other": "{count} informes requieren atención",
+ "inspections_list_meta_pending_one": "{count} reserva pendiente",
+ "inspections_list_meta_pending_other": "{count} reservas pendientes",
+ "inspections_list_action_export": "Exportar",
+ "inspections_list_action_new": "Nueva inspección",
+ "inspections_list_stat_upcoming": "Próximas",
+ "inspections_list_stat_in_progress": "En curso",
+ "inspections_list_stat_needs_attention": "Requiere atención",
+ "inspections_list_stat_recent_reports": "Informes recientes",
+ "inspections_list_filter_all_tags": "Todas las etiquetas",
+ "inspections_list_batch_selected": "{count} seleccionados",
+ "inspections_list_batch_select_all": "Seleccionar todo",
+ "inspections_list_empty_title": "Aún no hay inspecciones",
+ "inspections_list_empty_desc": "Cree una para comenzar.",
+ "inspections_list_nomatch_title": "Ninguna inspección coincide con estos filtros",
+ "inspections_list_nomatch_desc_one": "Un filtro está limitando la lista.",
+ "inspections_list_nomatch_desc_many": "{count} filtros están limitando la lista.",
+ "inspections_list_nomatch_clear": "Borrar filtros",
+ "inspections_list_searching": "Buscando…",
+ "inspections_list_results_one": "{count} resultado",
+ "inspections_list_results_other": "{count} resultados",
+ "inspections_list_load_more": "Cargar más",
+ "inspections_hub_meta_title": "Inspección - OpenInspection",
+ "inspections_hub_error_send_agreement": "No se pudo enviar el acuerdo. Inténtelo de nuevo.",
+ "inspections_hub_error_inspector_sign": "No se pudo guardar su firma. Inténtelo de nuevo.",
+ "inspections_hub_error_request_payment": "No se pudo solicitar el pago. Inténtelo de nuevo.",
+ "inspections_hub_error_attest_sms": "No se pudo registrar el consentimiento. Inténtelo de nuevo.",
+ "inspections_hub_error_publish": "No se pudo publicar el informe. Inténtelo de nuevo.",
+ "inspections_hub_error_submit": "No se pudo enviar el informe. Inténtelo de nuevo.",
+ "inspections_hub_error_return": "No se pudo devolver el informe. Inténtelo de nuevo.",
+ "inspections_hub_error_unpublish": "No se pudo anular la publicación del informe. Inténtelo de nuevo.",
+ "inspections_hub_error_reinspect_no_items": "Seleccione al menos un elemento para trasladar.",
+ "inspections_hub_error_reinspect": "No se pudo crear la reinspección. Inténtelo de nuevo.",
+ "inspections_hub_error_unknown_action": "Acción desconocida.",
+ "inspections_hub_breadcrumb_inspections": "Inspecciones",
+ "inspections_hub_untitled": "Inspección sin título",
+ "inspections_hub_action_open_editor": "Abrir editor",
+ "inspections_hub_action_view_report": "Ver informe",
+ "inspections_hub_block_people": "Personas",
+ "inspections_hub_block_schedule": "Agenda",
+ "inspections_hub_block_services": "Servicios",
+ "inspections_hub_block_agreement": "Acuerdo",
+ "inspections_hub_block_invoice": "Factura",
+ "inspections_hub_block_report": "Informe",
+ "inspections_hub_people_client": "Cliente",
+ "inspections_hub_people_agents": "Agentes",
+ "inspections_hub_people_other": "Otro",
+ "inspections_hub_people_add": "Agregar persona",
+ "inspections_hub_people_submit": "Agregar",
+ "inspections_hub_people_adding": "Agregando…",
+ "inspections_hub_people_primary": "Principal",
+ "inspections_hub_people_remove": "Quitar de la inspección",
+ "inspections_hub_people_remove_title": "¿Quitar de la inspección?",
+ "inspections_hub_people_remove_confirm": "¿Quitar a {name} de esta inspección? Su enlace del informe deja de funcionar de inmediato.",
+ "inspections_hub_people_remove_cta": "Quitar",
+ "inspections_hub_people_empty_title": "Aún no hay personas",
+ "inspections_hub_people_empty_desc": "Agregue un cliente, un agente u otro contacto a esta inspección.",
+ "inspections_hub_people_modal_title": "Agregar una persona",
+ "inspections_hub_people_contact_label": "Contacto",
+ "inspections_hub_people_search_ph": "Buscar contactos…",
+ "inspections_hub_people_searching": "Buscando…",
+ "inspections_hub_people_no_contacts": "No se encontraron contactos",
+ "inspections_hub_people_create_new": "Crear un nuevo contacto",
+ "inspections_hub_people_new_contact_title": "Nuevo contacto",
+ "inspections_hub_people_name_label": "Nombre",
+ "inspections_hub_people_name_ph": "Nombre completo",
+ "inspections_hub_people_email_label": "Correo electrónico",
+ "inspections_hub_people_email_ph": "nombre@ejemplo.com",
+ "inspections_hub_people_phone_label": "Teléfono",
+ "inspections_hub_people_phone_ph": "(555) 123-4567",
+ "inspections_hub_people_agency_label": "Agencia",
+ "inspections_hub_people_agency_ph": "Empresa o agencia",
+ "inspections_hub_people_role_label": "Rol",
+ "inspections_hub_people_role_placeholder": "Seleccione un rol…",
+ "inspections_hub_people_access_notice": "Agregar a alguien con este rol le permite abrir esta inspección desde un enlace, sin registrarse y antes de que se publique el informe.",
+ "inspections_hub_people_access_revoke_hint": "Puede revocarlo en cualquier momento desde su ficha de contacto. Para devolver un enlace revocado, use “Restablecer el enlace de acceso” en su fila y envíe el informe de nuevo; volver a agregar a la persona no lo emite otra vez.",
+ "inspections_hub_people_no_roles_admin": "No hay perfiles de rol configurados: agregue uno en Contactos → Roles.",
+ "inspections_hub_people_no_roles_non_admin": "Aún no hay perfiles de rol configurados. Pida a un administrador que configure uno.",
+ "inspections_hub_people_clear_contact_aria": "Borrar el contacto seleccionado",
+ "inspections_hub_error_person_add": "No se pudo agregar a esta persona. Inténtelo de nuevo.",
+ "inspections_hub_error_person_add_role_required": "Elija un rol para esta persona.",
+ "inspections_hub_error_person_add_name_required": "Ingrese un nombre para el nuevo contacto.",
+ "inspections_hub_error_person_remove": "No se pudo quitar a esta persona. Inténtelo de nuevo.",
+ "inspections_hub_schedule_edit": "Editar agenda",
+ "inspections_hub_schedule_unscheduled": "Sin programar",
+ "inspections_hub_schedule_field_datetime": "Fecha y hora",
+ "inspections_hub_schedule_field_inspector": "Inspector",
+ "inspections_hub_schedule_inspector_unassigned": "Sin asignar",
+ "inspections_hub_schedule_inspector_none": "Sin inspector asignado",
+ "inspections_hub_schedule_inspector_named": "Inspector · {name}",
+ "inspections_hub_services_empty_title": "Sin servicios",
+ "inspections_hub_services_empty_desc": "No se han agregado servicios a esta inspección.",
+ "inspections_hub_services_total": "Total",
+ "inspections_hub_services_add": "Agregar servicio",
+ "inspections_hub_services_add_title": "Agregar un servicio",
+ "inspections_hub_services_field_service": "Servicio",
+ "inspections_hub_services_select": "Seleccione un servicio",
+ "inspections_hub_services_field_price": "Precio para esta inspección",
+ "inspections_hub_services_price_hint": "Deje el precio de catálogo para facturar el monto habitual.",
+ "inspections_hub_services_all_added": "Todos los servicios de su catálogo ya están en esta inspección.",
+ "inspections_hub_services_catalog_empty": "Su catálogo de servicios está vacío: agregue servicios en Configuración antes de incluirlos en una inspección.",
+ "inspections_hub_services_price_title": "Cambiar el precio de la línea",
+ "inspections_hub_services_price_catalog": "Precio de catálogo {price}",
+ "inspections_hub_services_price_revert": "Usar el precio de catálogo",
+ "inspections_hub_services_edit_price": "Editar precio",
+ "inspections_hub_services_remove": "Quitar",
+ "inspections_hub_services_remove_title": "¿Quitar este servicio?",
+ "inspections_hub_services_remove_body": "{name} ya no se facturará en esta inspección. El servicio permanece en su catálogo.",
+ "inspections_hub_block_reports": "Informes",
+ "inspections_hub_reports_empty": "Aún no hay informes en esta orden. Se generan a partir de los servicios vendidos, cuando está programado el inicio del trabajo.",
+ "inspections_hub_reports_primary": "Principal",
+ "inspections_hub_reports_status_in_progress": "En curso",
+ "inspections_hub_reports_status_published": "Publicado",
+ "inspections_hub_reports_published_on": "Publicado el {date}",
+ "inspections_hub_reports_versions_one": "1 versión firmada",
+ "inspections_hub_reports_versions_other": "{count} versiones firmadas",
+ "inspections_hub_reports_delete": "Eliminar",
+ "inspections_hub_reports_delete_title": "¿Eliminar este informe?",
+ "inspections_hub_reports_delete_filled": "{name} tiene información cargada. Al eliminar el informe se destruye ese contenido —sus hallazgos, notas y fotos— junto con su historial de edición. La línea de servicio permanece en la orden, así que lo que se le factura al cliente no cambia. Esta acción no se puede deshacer.",
+ "inspections_hub_reports_delete_empty": "{name} todavía no tiene nada cargado. Al eliminarlo se quitan el informe y su documento de esta orden. La línea de servicio permanece, así que lo que se le factura al cliente no cambia. Esta acción no se puede deshacer.",
+ "inspections_hub_reports_blocked_primary": "El informe principal no se puede eliminar: cada orden conserva uno y, sin él, la orden no se puede editar.",
+ "inspections_hub_reports_blocked_published": "Un informe publicado no se puede eliminar: ya fue entregado, y sus versiones firmadas son lo que le permite al cliente verificar el documento que tiene en su poder.",
+ "inspections_hub_error_report_delete": "No se pudo eliminar el informe.",
+ "inspections_hub_block_details": "Detalles de la orden",
+ "inspections_hub_details_edit": "Editar detalles",
+ "inspections_hub_details_reference": "Número de referencia",
+ "inspections_hub_details_referral": "Fuente de referencia",
+ "inspections_hub_details_closing": "Fecha de cierre",
+ "inspections_hub_details_unset": "Sin definir",
+ "inspections_hub_details_referral_select": "Seleccione una fuente",
+ "inspections_hub_invoice_set_amount": "Establecer el monto",
+ "inspections_hub_invoice_amount_title": "Establecer el precio de la inspección",
+ "inspections_hub_invoice_amount_hint": "Este es el precio base, que se factura cuando no hay servicios reservados. Agregue servicios para facturar línea por línea.",
+ "inspections_hub_invoice_from_services": "Se factura a partir de las líneas de servicio de arriba.",
+ "inspections_hub_gate_agreement": "Exigir un acuerdo firmado antes de que el cliente pueda abrir el informe",
+ "inspections_hub_gate_payment": "Exigir el pago antes de que el cliente pueda abrir el informe",
+ "inspections_hub_error_save_order": "No se pudo guardar. Inténtelo de nuevo.",
+ "inspections_hub_error_service_add": "No se pudo agregar el servicio. Inténtelo de nuevo.",
+ "inspections_hub_error_service_price": "No se pudo cambiar el precio. Inténtelo de nuevo.",
+ "inspections_hub_error_service_remove": "No se pudo quitar el servicio. Inténtelo de nuevo.",
+ "inspections_hub_agreement_empty": "Aún no hay solicitudes de acuerdo.",
+ "inspections_hub_agreement_send": "Enviar acuerdo",
+ "inspections_hub_invoice_paid": "Pago recibido.",
+ "inspections_hub_invoice_hidden": "Monto oculto",
+ "inspections_hub_invoice_resend": "Reenviar la solicitud",
+ "inspections_hub_invoice_request": "Solicitar el pago",
+ "inspections_hub_report_create_reinspection": "Crear una reinspección",
+ "inspections_hub_report_published": "Informe publicado.",
+ "inspections_hub_report_published_on": "Informe publicado el {date}.",
+ "inspections_hub_publish_ok_both": "Informe publicado. Se envió un correo electrónico al cliente y al agente.",
+ "inspections_hub_publish_ok_client": "Informe publicado. Se envió un correo electrónico al cliente.",
+ "inspections_hub_publish_ok_agent": "Informe publicado. Se envió un correo electrónico al agente.",
+ "inspections_hub_publish_ok_none": "Informe publicado. Todavía no se envió ningún correo electrónico: use Enviar informe.",
+ "inspections_hub_report_unpublishing": "Anulando la publicación…",
+ "inspections_hub_report_unpublish": "Anular la publicación",
+ "inspections_hub_report_submitted": "Informe enviado para revisión.",
+ "inspections_hub_report_ready": "Todos los campos obligatorios están completos.",
+ "inspections_hub_report_blockers": "{count} bloqueo(s) por resolver antes de publicar.",
+ "inspections_hub_report_publish": "Publicar el informe",
+ "inspections_hub_report_submitting": "Enviando…",
+ "inspections_hub_report_submit": "Enviar para revisión",
+ "inspections_hub_report_returning": "Devolviendo…",
+ "inspections_hub_report_return": "Devolver al inspector",
+ "inspections_hub_report_resolve": "Resolver en el editor",
+ "inspections_hub_versions_title": "Versiones del informe",
+ "inspections_hub_versions_version": "Versión {n}",
+ "inspections_hub_versions_amendment": "Modificación",
+ "inspections_hub_versions_view_changes": "Ver los cambios",
+ "inspections_hub_publish_summary_label": "¿Qué cambió en esta modificación?",
+ "inspections_hub_publish_summary_ph": "p. ej., Se corrigió la calificación del techo después de la reinspección.",
+ "inspections_hub_lifecycle_title": "Estado de la inspección",
+ "inspections_hub_lifecycle_mark_complete": "Marcar el trabajo de campo como terminado",
+ "inspections_hub_lifecycle_marking": "Marcando…",
+ "inspections_hub_lifecycle_hint": "Marca el trabajo en el sitio como terminado. Publicar un informe no lo requiere.",
+ "inspections_hub_lifecycle_error": "No se pudo marcar la inspección como completada.",
+ "inspections_hub_lifecycle_done": "El trabajo en el sitio está terminado. La publicación del informe se registra por separado, abajo.",
+ "inspections_hub_lifecycle_cancelled": "Esta inspección fue cancelada. No hay nada más programado para ella.",
+ "inspections_hub_doc_upload_failed": "Error al subir. Inténtelo de nuevo.",
+ "inspections_hub_doc_delete_failed": "No se pudo eliminar el documento. Inténtelo de nuevo.",
+ "inspections_hub_sms_granted": "otorgado",
+ "inspections_hub_sms_revoked": "revocado",
+ "inspections_hub_sms_not_recorded": "sin registrar",
+ "inspections_hub_sms_heading": "Mensajes de texto",
+ "inspections_hub_sms_explainer": "Los clientes necesitan un consentimiento registrado antes de recibir mensajes de texto. Confírmelo aquí si lo dieron por teléfono o en persona. Los agentes y otros contactos comerciales de este trabajo no usan este paso de consentimiento del cliente.",
+ "inspections_hub_sms_recording": "Registrando…",
+ "inspections_hub_sms_confirm": "El cliente aceptó recibir mensajes de texto: lo confirmo",
+ "inspections_hub_copied": "¡Copiado!",
+ "inspections_hub_copy_link": "Copiar enlace",
+ "inspections_hub_eb_not_found": "No se pudo encontrar esta inspección. Es posible que se haya eliminado.",
+ "inspections_hub_eb_forbidden": "No tiene permiso para ver esta inspección.",
+ "inspections_hub_eb_generic": "Algo salió mal al abrir la inspección.",
+ "inspections_hub_eb_back": "Volver al panel",
+ "inspections_hub_error_send_report": "No se pudo enviar el informe. Inténtelo de nuevo.",
+ "inspections_hub_error_send_sms": "No se pudo enviar el mensaje de texto. Inténtelo de nuevo.",
+ "inspections_hub_report_send": "Enviar informe",
+ "inspections_hub_report_send_sms": "Enviar mensaje de texto",
+ "inspections_hub_send_report_title": "Enviar informe",
+ "inspections_hub_send_sms_title": "Enviar mensaje de texto",
+ "inspections_hub_send_sms_hint": "Solo se puede enviar mensajes de texto a las personas de esta inspección que tengan un teléfono registrado. Los clientes necesitan un consentimiento de SMS registrado; los agentes y otros contactos comerciales pueden recibir mensajes de texto transaccionales del trabajo mientras estén asignados a él. STOP se respeta para todos.",
+ "inspections_hub_send_sms_no_phone_hint": "Sin teléfono registrado",
+ "inspections_hub_send_sms_submit": "Enviar mensaje de texto",
+ "inspections_hub_send_sms_sending": "Enviando…",
+ "inspections_hub_send_report_people_label": "Personas en esta inspección",
+ "inspections_hub_send_report_no_email_hint": "Sin correo electrónico registrado",
+ "inspections_hub_send_report_oneoff_title": "Destinatario puntual",
+ "inspections_hub_send_report_oneoff_email_label": "Correo electrónico",
+ "inspections_hub_send_report_oneoff_email_ph": "nombre@ejemplo.com",
+ "inspections_hub_send_report_oneoff_role_label": "Rol",
+ "inspections_hub_send_report_oneoff_role_placeholder": "Seleccione un rol…",
+ "inspections_hub_send_report_channel_label": "Canal",
+ "inspections_hub_send_report_channel_email": "Correo electrónico",
+ "inspections_hub_send_report_submit": "Enviar",
+ "inspections_hub_send_report_sending": "Enviando…",
+ "inspections_hub_people_mailto_hint": "Abre su propia aplicación de correo electrónico; los mensajes enviados así no se registran aquí",
+ "inspections_hub_people_make_primary": "Hacer principal",
+ "inspections_hub_people_remove_sole_reason": "Primero agregue otro cliente o haga principal a otra persona",
+ "inspections_hub_people_reset": "Restablecer el enlace de acceso",
+ "inspections_hub_people_reset_title": "¿Restablecer el enlace de acceso?",
+ "inspections_hub_people_reset_confirm": "El enlace del informe que tiene {name} deja de funcionar de inmediato. Envíe el informe de nuevo para entregarle el nuevo.",
+ "inspections_hub_people_reset_cta": "Restablecer enlace",
+ "inspections_hub_people_access_not_sent": "Aún no se ha enviado ningún enlace del informe",
+ "inspections_hub_people_access_active": "Enlace enviado el {date}",
+ "inspections_hub_people_access_active_until": "Enlace enviado el {date} · vence el {expiry}",
+ "inspections_hub_people_access_expired": "Enlace enviado el {date} · vencido",
+ "inspections_hub_people_access_revoked": "Acceso revocado",
+ "inspections_hub_people_link_expiry_heading": "Vencimiento del enlace del informe",
+ "inspections_hub_people_link_expiry_help": "Se aplica a los enlaces del informe ya enviados para esta inspección.",
+ "inspections_hub_people_link_expiry_apply": "Aplicar vencimiento a {count} enlaces enviados",
+ "inspections_hub_people_link_expiry_lift": "Quitar el vencimiento de {count} enlaces enviados",
+ "inspections_hub_error_person_reset": "No se pudo restablecer ese enlace de acceso.",
+ "inspections_hub_error_person_make_primary": "No se pudo cambiar el cliente principal.",
+ "inspections_hub_error_link_expiry": "No se pudo actualizar el vencimiento del enlace del informe.",
+ "inspections_hub_people_link_expiry_apply_one": "Aplicar vencimiento al único enlace ya enviado",
+ "inspections_hub_people_link_expiry_lift_one": "Quitar el vencimiento del único enlace enviado",
+ "inspections_hub_people_link_expiry_noop": "Todos los enlaces ya coinciden con esto: no hay nada que cambiar.",
+ "inspections_hub_people_copy_email": "Copiar",
+ "inspections_hub_people_copy_email_aria": "Copiar la dirección de correo electrónico {email}",
+ "inspections_hub_people_already_present": "{name} ya tiene este rol en esta inspección, así que no cambió nada. Para restaurar un enlace del informe revocado, cierre esto y use “Restablecer el enlace de acceso” en su fila.",
+ "inspections_hub_people_this_contact": "Este contacto",
+ "inspections_hub_people_reset_confirm_restore": "{name} no tiene ningún enlace activo en este momento. Esto emite uno nuevo; envíe el informe de nuevo para entregarlo.",
+ "inspections_hub_details_referred_by": "Referido por",
+ "inspections_hub_details_referred_by_placeholder": "Buscar cualquier contacto…",
+ "inspections_hub_details_referred_by_clear": "Borrar"
}
From 1b6b7a5f2dfddcd45603ddab52f0fbd7f3dd7098 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:28:23 +0800
Subject: [PATCH 040/111] i18n(es-419): translate contacts.json (133 keys)
---
messages/es-419/contacts.json | 135 +++++++++++++++++++++++++++++++++-
1 file changed, 134 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/contacts.json b/messages/es-419/contacts.json
index 006f618aa..30a45c497 100644
--- a/messages/es-419/contacts.json
+++ b/messages/es-419/contacts.json
@@ -1,3 +1,136 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "contacts_meta_title": "Contactos - OpenInspection",
+ "contacts_detail_meta_title": "Contacto - OpenInspection",
+ "contacts_label_contacts": "Contactos",
+ "contacts_label_agents": "Agentes",
+ "contacts_label_clients": "Clientes",
+ "contacts_label_other": "Otro",
+ "contacts_list_count_one": "Contacto",
+ "contacts_list_meta_count": "{count} contactos",
+ "contacts_list_meta_showing": "Mostrando {count}",
+ "contacts_filter_type_aria": "Filtrar por tipo de contacto",
+ "contacts_filter_all_types": "Todos los tipos",
+ "contacts_action_import_csv": "Importar CSV",
+ "contacts_action_add": "Agregar contacto",
+ "contacts_type_client": "Cliente",
+ "contacts_type_agent": "Agente",
+ "contacts_modal_edit_title": "Editar contacto",
+ "contacts_modal_type_label": "Tipo",
+ "contacts_modal_name_label": "Nombre completo *",
+ "contacts_modal_name_placeholder": "Ana Pérez",
+ "contacts_modal_email_placeholder": "ana@inmobiliaria.com",
+ "contacts_modal_phone_placeholder": "(555) 123-4567",
+ "contacts_modal_agency_placeholder": "Inmobiliaria Amanecer",
+ "contacts_modal_language_unset_option": "Sin definir",
+ "contacts_field_email": "Correo electrónico",
+ "contacts_field_language": "Idioma preferido",
+ "contacts_field_phone": "Teléfono",
+ "contacts_field_agency": "Agencia",
+ "contacts_field_notes": "Notas",
+ "contacts_field_inspections": "Inspecciones",
+ "contacts_table_empty_title": "Aún no hay contactos",
+ "contacts_table_empty_desc": "Agregue uno arriba para comenzar.",
+ "contacts_table_col_name": "Nombre",
+ "contacts_table_col_actions": "Acciones",
+ "contacts_agents_empty_title": "Aún no hay agentes asociados",
+ "contacts_action_archive": "Archivar",
+ "contacts_agents_col_referrals": "Referencias",
+ "contacts_agent_email_locked_hint": "El correo electrónico de un agente es la clave de su cuenta en todas las empresas, así que no se puede cambiar aquí. Para corregir un error de escritura o pasar a una dirección nueva, archive a este agente y agregue uno nuevo (los enlaces del informe ya enviados siguen funcionando).",
+ "contacts_agents_empty_desc": "Los agentes que agregue o importe aparecen aquí. Otorgue el acceso al informe por inspección desde el panel Personas.",
+ "contacts_archive_title": "¿Archivar este contacto?",
+ "contacts_archive_confirm": "Lo quita de su lista. Sus inspecciones y los enlaces del informe que se le enviaron no se ven afectados.",
+ "contacts_archive_access_warning": "Este contacto todavía puede abrir {count} informe(s). Archivarlo no le retira ese acceso.",
+ "contacts_archive_access_warning_revoking": "Archivarlo también revocará {count} enlace(s) del informe que este contacto todavía tiene.",
+ "contacts_detail_archived": "Archivado",
+ "contacts_detail_back": "Volver a Contactos",
+ "contacts_detail_info_heading": "Datos del contacto",
+ "contacts_detail_no_notes": "Sin notas",
+ "contacts_detail_stats_heading": "Estadísticas",
+ "contacts_detail_revenue_label": "Ingresos totales",
+ "contacts_detail_history_heading": "Historial de inspecciones",
+ "contacts_detail_history_empty_title": "Sin inspecciones",
+ "contacts_detail_history_empty_desc": "Este contacto aún no tiene inspecciones vinculadas.",
+ "contacts_detail_untitled_inspection": "Inspección sin título",
+ "contacts_error_not_found": "No se pudo encontrar este contacto. Es posible que se haya eliminado.",
+ "contacts_error_forbidden": "No tiene permiso para ver este contacto.",
+ "contacts_error_generic": "Algo salió mal al abrir el contacto.",
+ "contacts_csv_title": "Importar contactos desde un CSV",
+ "contacts_csv_hint": "CSV o Excel (.xlsx): las exportaciones de Spectora e ITB funcionan sin configuración",
+ "contacts_csv_or_paste": "o pegue el contenido abajo",
+ "contacts_csv_paste_placeholder": "...o pegue aquí el contenido del CSV",
+ "contacts_csv_preview": "Vista previa",
+ "contacts_csv_confirm": "Confirmar la importación",
+ "contacts_csv_back_to_file": "Volver al archivo",
+ "contacts_csv_rows_detected": "Filas detectadas",
+ "contacts_csv_columns": "Columnas",
+ "contacts_csv_detected_columns": "Columnas detectadas: {columns}",
+ "contacts_csv_failed_title": "Error al importar",
+ "contacts_csv_failed_desc": "El servidor rechazó la importación. No se escribió nada; inténtelo de nuevo y comuníquese con soporte si el problema persiste.",
+ "contacts_csv_nothing_title": "No se importó nada",
+ "contacts_csv_nothing_desc": "El archivo se importa todo o nada. Corrija las filas de abajo y reintente; no se crearán duplicados.",
+ "contacts_csv_error_row": "Fila {row}: {message}",
+ "contacts_csv_error_more": "…y {count} más",
+ "contacts_csv_imported": "Se importaron {count} contactos",
+ "contacts_csv_skipped": "{count} omitidos (nombre en blanco o ya estaba en sus contactos)",
+ "contacts_csv_error_legacy_xls": "Los archivos .xls antiguos no son compatibles: guarde el archivo como .xlsx o CSV y reintente.",
+ "contacts_csv_error_xlsx_read": "No se pudo leer el archivo .xlsx.",
+ "contacts_label_roles": "Roles",
+ "contacts_roles_action_add": "Agregar rol",
+ "contacts_roles_empty_title": "Aún no hay perfiles de rol",
+ "contacts_roles_col_label": "Etiqueta",
+ "contacts_roles_col_kind": "Clase",
+ "contacts_roles_col_status": "Estado",
+ "contacts_roles_system_pill": "Sistema",
+ "contacts_roles_status_active": "Activo",
+ "contacts_roles_status_inactive": "Inactivo",
+ "contacts_roles_kind_client": "Cliente",
+ "contacts_roles_kind_agent": "Agente",
+ "contacts_roles_kind_other": "Otro",
+ "contacts_roles_modal_add_title": "Agregar rol",
+ "contacts_roles_modal_edit_title": "Editar rol",
+ "contacts_roles_modal_label_label": "Etiqueta",
+ "contacts_roles_modal_label_placeholder": "Administrador de propiedades",
+ "contacts_roles_modal_kind_label": "Tipo de rol",
+ "contacts_roles_modal_kind_hint": "Determina las capacidades predeterminadas del rol. Se define al crearlo y no se puede cambiar.",
+ "contacts_roles_modal_email_template_label": "Plantilla de correo electrónico",
+ "contacts_roles_modal_sms_template_label": "Plantilla de SMS",
+ "contacts_roles_modal_template_none": "Ninguno",
+ "contacts_roles_modal_caps_heading": "Capacidades",
+ "contacts_roles_modal_cap_receives_report": "Recibe el informe",
+ "contacts_roles_modal_cap_self_retrieve": "Puede obtener el informe por su cuenta",
+ "contacts_roles_modal_cap_can_have_account": "Puede tener una cuenta del portal",
+ "contacts_roles_modal_cap_account_unavailable": "Todavía no hay cuentas disponibles para este tipo de rol.",
+ "contacts_roles_modal_cap_shows_in_agent_portal": "Aparece en la lista del portal de ese agente",
+ "contacts_roles_modal_cap_repair_list_label": "Acceso a la lista de reparaciones",
+ "contacts_roles_modal_cap_repair_off": "Sin acceso",
+ "contacts_roles_modal_cap_repair_read": "Solo lectura",
+ "contacts_roles_modal_cap_repair_readwrite": "Ver y solicitar",
+ "contacts_roles_matrix_open_aria": "Referencia de capacidades",
+ "contacts_roles_matrix_title": "Qué hace cada capacidad",
+ "contacts_roles_matrix_desc_receivesReport": "Recibe el informe cuando se envía.",
+ "contacts_roles_matrix_desc_selfRetrieveReport": "Puede abrir su enlace del informe sin pedírselo al inspector.",
+ "contacts_roles_matrix_desc_canHaveAccount": "Puede tener una cuenta del portal. Por ahora solo está disponible para los roles de agente.",
+ "contacts_roles_matrix_desc_showsInAgentPortal": "La inspección aparece en la lista del portal de ese agente.",
+ "contacts_roles_matrix_desc_canAccessRepairList": "Puede abrir la lista de solicitudes de reparación del comprador. También requiere la configuración de la lista de reparaciones para agentes de la empresa; se aplica la más estricta de las dos.",
+ "contacts_type_other": "Otro",
+ "contacts_detail_access_heading": "Acceso al informe",
+ "contacts_detail_access_none": "Este contacto no puede abrir ningún informe.",
+ "contacts_detail_access_unavailable": "No se pudo cargar el acceso al informe, así que esta lista puede estar incompleta. Vuelva a cargar la página antes de suponer que este contacto no tiene acceso.",
+ "contacts_detail_access_revoked_one": "Se revocó 1 enlace del informe.",
+ "contacts_detail_access_revoked_many": "Se revocaron {count} enlaces del informe.",
+ "contacts_detail_access_revoked_none": "No hay nada que revocar: esos enlaces ya habían caducado.",
+ "contacts_detail_access_revoke_failed": "No se pudo revocar. No se cambió nada; inténtelo de nuevo.",
+ "contacts_detail_access_explainer": "Estos enlaces funcionan sin una cuenta. Revocar uno surte efecto de inmediato.",
+ "contacts_detail_access_revoke": "Revocar",
+ "contacts_detail_access_revoke_all": "Revocar todo",
+ "contacts_roles_modal_email_template_hint": "Se usa cuando envía el informe manualmente. Déjelo en Ninguno para usar el correo electrónico estándar del informe. Los mensajes automáticos se configuran en Configuración → Automatizaciones.",
+ "contacts_action_restore": "Restaurar",
+ "contacts_filter_status_active": "Activo",
+ "contacts_filter_status_archived": "Archivado",
+ "contacts_filter_status_aria": "Mostrar contactos activos o archivados",
+ "contacts_archived_empty_title": "Sin contactos archivados",
+ "contacts_archived_empty_desc": "Los contactos que archive aparecen aquí y se pueden restaurar.",
+ "contacts_roles_action_deactivate": "Desactivar",
+ "contacts_roles_action_reactivate": "Reactivar"
}
From 049934380ab04ff346e05d709e39d40cbdac36b5 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:30:27 +0800
Subject: [PATCH 041/111] i18n(es-419): translate calendar.json (61 keys)
---
messages/es-419/calendar.json | 63 ++++++++++++++++++++++++++++++++++-
1 file changed, 62 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/calendar.json b/messages/es-419/calendar.json
index 006f618aa..2625dad33 100644
--- a/messages/es-419/calendar.json
+++ b/messages/es-419/calendar.json
@@ -1,3 +1,64 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "calendar_meta_title": "Calendario - OpenInspection",
+ "calendar_page_title": "Calendario",
+ "calendar_meta_none": "No hay inspecciones programadas esta semana",
+ "calendar_meta_confirmed_drafts": "{confirmed} confirmadas · {drafts} pendiente{plural} de confirmar",
+ "calendar_meta_this_week": "{count} esta semana",
+ "calendar_action_block_save_error": "No se pudo guardar el tiempo bloqueado.",
+ "calendar_action_block_delete_error": "No se pudo eliminar el tiempo bloqueado.",
+ "calendar_action_unknown": "Acción de calendario desconocida.",
+ "calendar_nav_previous": "Anterior",
+ "calendar_nav_today": "Hoy",
+ "calendar_datepicker_open": "Ir a un mes o año",
+ "calendar_datepicker_prev_year": "Año anterior",
+ "calendar_datepicker_next_year": "Año siguiente",
+ "calendar_datepicker_prev_years": "Años anteriores",
+ "calendar_datepicker_next_years": "Años siguientes",
+ "calendar_datepicker_choose_year": "Elegir año",
+ "calendar_datepicker_year_range": "{start} – {end}",
+ "calendar_view_month": "mes",
+ "calendar_view_week": "semana",
+ "calendar_view_day": "día",
+ "calendar_scope_aria": "Alcance del calendario",
+ "calendar_scope_my": "Mi calendario",
+ "calendar_scope_team": "Equipo",
+ "calendar_scope_inspectors_aria": "Inspectores",
+ "calendar_all_day": "Todo el día",
+ "calendar_block_edit_heading": "Editar el tiempo bloqueado",
+ "calendar_block_time": "Bloquear tiempo",
+ "calendar_block_saving": "Guardando...",
+ "calendar_block_save_changes": "Guardar cambios",
+ "calendar_block_field_title": "Título",
+ "calendar_block_title_placeholder": "Cita personal",
+ "calendar_block_field_inspector": "Inspector",
+ "calendar_block_field_date": "Fecha",
+ "calendar_block_field_starts": "Comienza",
+ "calendar_block_field_ends": "Termina",
+ "calendar_block_field_notes": "Notas",
+ "calendar_block_delete_confirm_title": "¿Eliminar el tiempo bloqueado?",
+ "calendar_block_delete_keep": "Conservar el bloqueo",
+ "calendar_block_delete_confirm_body": "Este tiempo bloqueado se quitará del calendario.",
+ "calendar_event_open_inspection": "Abrir la inspección",
+ "calendar_event_date_label": "Fecha:",
+ "calendar_event_na": "N/A",
+ "calendar_event_status_label": "Estado:",
+ "calendar_weekday_sun": "Dom",
+ "calendar_weekday_mon": "Lun",
+ "calendar_weekday_tue": "Mar",
+ "calendar_weekday_wed": "Mié",
+ "calendar_weekday_thu": "Jue",
+ "calendar_weekday_fri": "Vie",
+ "calendar_weekday_sat": "Sáb",
+ "schedule_heatmap_heading": "Disponibilidad esta semana",
+ "schedule_heatmap_open": "Disponible",
+ "schedule_heatmap_full": "Completo",
+ "schedule_heatmap_closed": "Cerrado",
+ "schedule_heatmap_unconfigured": "Sin horario definido",
+ "calendar_sync_connected": "Calendario sincronizado",
+ "calendar_sync_stale": "La sincronización del calendario está desactualizada",
+ "calendar_sync_not_connected": "Calendario sin conectar",
+ "calendar_sync_stale_short": "Desincronizado",
+ "calendar_sync_not_connected_short": "Sin conectar",
+ "calendar_sync_never": "Nunca se sincronizó"
}
From a12e7954fc11ad6dd37172707ce4aae1d12649d1 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:32:42 +0800
Subject: [PATCH 042/111] i18n(es-419): translate booking.json (74 keys)
---
messages/es-419/booking.json | 76 +++++++++++++++++++++++++++++++++++-
1 file changed, 75 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/booking.json b/messages/es-419/booking.json
index 006f618aa..93c0a3eef 100644
--- a/messages/es-419/booking.json
+++ b/messages/es-419/booking.json
@@ -1,3 +1,77 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "booking_page_meta_title": "Reservar una inspección - OpenInspection",
+ "booking_embed_meta_title": "Reservar una inspección",
+ "booking_error_company_not_found": "Empresa no encontrada",
+ "booking_error_service_unavailable": "Servicio no disponible",
+ "booking_error_state_heading": "No disponible",
+ "booking_error_state_default": "Esta página de reservas no está disponible.",
+ "booking_not_open_heading": "Las reservas en línea aún no están abiertas",
+ "booking_not_open_body": "{company} todavía no habilitó la programación en línea. Comuníquese directamente con la empresa para reservar su inspección.",
+ "booking_embed_unavailable": "Reserva no disponible.",
+ "booking_inspector_default_name": "Inspector",
+ "booking_embed_company_default_name": "Empresa de inspección",
+ "booking_logo_alt": "Logotipo",
+ "booking_embed_book_with_heading": "Reserve con {name}",
+ "booking_embed_confirm_by_email": "Elija una fecha y le confirmaremos por correo electrónico.",
+ "booking_embed_not_open": "Las reservas en línea aún no están abiertas: comuníquese directamente con {name} para programar.",
+ "booking_embed_status_success": "¡Solicitud de reserva enviada! Revise su correo electrónico.",
+ "booking_embed_status_could_not_submit": "No se pudo enviar",
+ "booking_embed_status_network_error": "Error de red",
+ "booking_embed_submit": "Solicitar la reserva",
+ "booking_embed_address_placeholder": "123 Main St, Austin, TX",
+ "booking_embed_name_label": "Su nombre",
+ "booking_embed_phone_label": "Teléfono",
+ "booking_embed_phone_placeholder": "(555) 555-5555",
+ "booking_embed_date_label": "Fecha preferida",
+ "booking_field_address_label": "Dirección de la propiedad",
+ "booking_field_email_label": "Correo electrónico",
+ "booking_field_inspector_label": "Inspector",
+ "booking_placeholder_name": "Ana Pérez",
+ "booking_placeholder_email": "ana@ejemplo.com",
+ "booking_wizard_heading": "Programe una inspección",
+ "booking_wizard_subtitle": "Cuéntenos sobre la propiedad y elija un horario que le convenga.",
+ "booking_wizard_submit": "Solicitar la inspección",
+ "booking_prefill_remembered_notice": "Se completó con los datos de su última reserva en este navegador.",
+ "booking_prefill_clear": "¿No es usted? Borrar",
+ "booking_agent_on_behalf_heading": "Reservando en nombre de un cliente como {name}",
+ "booking_agent_on_behalf_body": "Ingrese abajo los datos de contacto de su cliente. Se le pedirá que confirme, y la empresa fijará la fecha y el precio.",
+ "booking_submitting": "Enviando...",
+ "booking_powered_by": "Con la tecnología de OpenInspection",
+ "booking_link_privacy_policy": "Política de privacidad",
+ "booking_link_terms": "Términos",
+ "booking_privacy_shared_notice": "Su información se comparte con {name} para programar su inspección.",
+ "booking_privacy_see_our": "Consulte nuestra",
+ "booking_step_property_heading": "Propiedad",
+ "booking_step_property_subtitle": "¿Dónde es la inspección?",
+ "booking_step_property_address_placeholder": "123 Main St, City, State ZIP",
+ "booking_step_services_heading": "Servicios",
+ "booking_step_services_subtitle": "Elija una o más inspecciones para esta visita.",
+ "booking_step_services_duration": "~{duration} min",
+ "booking_unit_inspection_one": "inspección",
+ "booking_unit_inspection_other": "inspecciones",
+ "booking_step_schedule_heading": "Agenda",
+ "booking_step_schedule_subtitle": "Elija una fecha y una franja horaria que le convengan.",
+ "booking_field_inspection_date_label": "Fecha de la inspección",
+ "booking_field_time_window_label": "Franja horaria",
+ "booking_schedule_custom_time_suffix": "en la fecha seleccionada",
+ "booking_schedule_inspector_no_preference": "Sin preferencia: el primero disponible",
+ "booking_schedule_company_fallback": "su empresa de inspección",
+ "booking_schedule_sms_optin": "Envíenme mensajes de texto de {company} con novedades sobre la cita y el informe. Al marcar esta casilla, usted acepta recibir mensajes de texto automatizados de {company} sobre su inspección. La frecuencia de los mensajes varía según su actividad de inspección. Pueden aplicarse tarifas de mensajes y datos; responda STOP para darse de baja y HELP para obtener ayuda. El consentimiento no es una condición para reservar.",
+ "booking_step_yourinfo_heading": "Sus datos",
+ "booking_step_yourinfo_subtitle": "¿Cómo le enviamos el informe?",
+ "booking_field_fullname_label": "Nombre completo",
+ "booking_field_language_label": "Idioma preferido",
+ "booking_confirm_submitted_heading": "Solicitud enviada",
+ "booking_confirm_details_heading": "Confirmar los datos",
+ "booking_confirm_subtitle": "Revise su reserva antes de enviarla.",
+ "booking_confirm_row_address": "Dirección",
+ "booking_confirm_row_date": "Fecha",
+ "booking_confirm_row_time": "Hora",
+ "booking_confirm_row_services": "Servicios",
+ "booking_confirm_services_selected": "{count} seleccionados",
+ "booking_confirm_row_total": "Total",
+ "booking_confirm_row_name": "Nombre",
+ "booking_holiday_advisory_concierge": "Es posible que la oficina esté cerrada — {name}. Solicitud recibida — la oficina confirmará.",
+ "booking_holiday_advisory_default": "Es posible que la oficina esté cerrada — {name}. Confirmaremos la disponibilidad."
}
From 02d9c2dec537c7cfc5e4e47e7613c15b869c172f Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:34:52 +0800
Subject: [PATCH 043/111] i18n(es-419): translate checkout.json (175 keys)
---
messages/es-419/checkout.json | 177 +++++++++++++++++++++++++++++++++-
1 file changed, 176 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/checkout.json b/messages/es-419/checkout.json
index 006f618aa..73202be76 100644
--- a/messages/es-419/checkout.json
+++ b/messages/es-419/checkout.json
@@ -1,3 +1,178 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "checkout_meta_title": "Firmar y pagar - OpenInspection",
+ "checkout_sign_error_signature_required": "La firma es obligatoria.",
+ "checkout_sign_error_failed": "Error al firmar. Inténtelo de nuevo.",
+ "checkout_action_error_unknown": "Acción desconocida.",
+ "checkout_declined_heading": "Acuerdo rechazado",
+ "checkout_declined_body": "Usted rechazó este acuerdo. Comuníquese con {companyName} si fue un error.",
+ "checkout_progress_eyebrow": "Firmar y pagar",
+ "checkout_step_sign": "Firmar",
+ "checkout_step_pay": "Pagar",
+ "checkout_powered_by": "Con la tecnología de OpenInspection",
+ "checkout_step_suffix_not_required": " · no requerido",
+ "checkout_step_suffix_waiting": " · en espera",
+ "checkout_complete_heading": "Todo listo. ¡Gracias!",
+ "checkout_complete_body": "Su acuerdo está firmado y el pago está saldado.",
+ "checkout_complete_view_report": "Ver su informe",
+ "checkout_sign_error_no_mark": "Dibuje su firma antes de enviar.",
+ "checkout_sign_step_label": "Paso 1 · Acuerdo",
+ "checkout_sign_signature_progress": "Firma {current} de {total}",
+ "checkout_sign_done_heading": "Acuerdo firmado",
+ "checkout_sign_waiting_body": "Gracias, {signerName}. Estamos a la espera de otro{plural} para completar este acuerdo ({signed} de {total} ya firmaron).",
+ "checkout_sign_thankyou": "Gracias, {signerName}.",
+ "checkout_sign_draw_prompt": "Dibuje su firma abajo:",
+ "checkout_sign_canvas_aria": "Panel de firma: dibuje su firma aquí",
+ "checkout_sign_submit_pending": "Firmando...",
+ "checkout_sign_submit": "Firmar el acuerdo",
+ "checkout_pay_step_label": "Paso 2 · Pago",
+ "checkout_pay_none_required": "No se requiere ningún pago para esta inspección.",
+ "checkout_pay_received": "Pago recibido — gracias.",
+ "checkout_pay_finalizing": "Estamos finalizando su recibo; aparecerá aquí en unos instantes.",
+ "checkout_pay_heading": "Pague su inspección",
+ "checkout_pay_starting": "Iniciando el pago seguro…",
+ "checkout_pay_button": "Pagar {amount}",
+ "checkout_pay_secured_by_stripe": "Protegido por Stripe",
+ "checkout_pay_already_paid": "Esta factura ya fue pagada. Actualice la página para ver su recibo.",
+ "checkout_pay_unavailable_before": "El pago seguro con tarjeta en línea no está disponible en este momento. Comuníquese con",
+ "checkout_pay_unavailable_after": " para coordinar el pago.",
+ "checkout_pay_processing": "Procesando…",
+ "checkout_pay_error_failed": "No se pudo completar el pago. Inténtelo de nuevo.",
+ "invoice_meta_title": "Factura - OpenInspection",
+ "invoice_error_not_found": "Factura no encontrada",
+ "invoice_error_link_invalid": "Este enlace de pago ya no es válido. Abra el enlace desde el correo electrónico de su inspector o pídale que le envíe uno nuevo.",
+ "invoice_error_service_unavailable": "Servicio no disponible",
+ "invoice_not_available": "Esta factura no está disponible.",
+ "invoice_brand_logo_alt": "Logotipo",
+ "agreement_printable_meta_title": "Acuerdo firmado - OpenInspection",
+ "agreement_printable_not_found": "Acuerdo no encontrado.",
+ "agreement_printable_envelope_id": "ID del sobre: {envelopeId}",
+ "agreement_printable_signed_by": "Firmado por",
+ "agreement_printable_signature_alt": "Firma",
+ "agreement_printable_date_signed": "Fecha de firma (UTC)",
+ "agreement_sign_meta_title": "Firmar el acuerdo - OpenInspection",
+ "agreement_sign_error_decline_failed": "Error al rechazar. Inténtelo de nuevo.",
+ "concierge_confirm_meta_title": "Confirme su inspección - OpenInspection",
+ "concierge_confirm_error_unavailable_title": "Enlace de confirmación no disponible",
+ "concierge_confirm_error_unavailable_body": "Es posible que el enlace se haya escrito mal o que la reserva se haya cancelado. Comuníquese con su agente para obtener un enlace de confirmación nuevo.",
+ "concierge_confirm_expired_title": "Este enlace de confirmación venció",
+ "concierge_confirm_expired_body": "Los enlaces de confirmación son válidos por 7 días. Su agente o su inspector pueden enviarle uno nuevo en un minuto.",
+ "concierge_confirm_already_title": "Ya está confirmada",
+ "concierge_confirm_already_body": "Esta inspección ya fue confirmada. No hace falta nada más: su inspector tiene los datos.",
+ "concierge_confirm_heading": "Confirme su inspección",
+ "concierge_confirm_greeting_named": "Hola, {clientName}, por favor",
+ "concierge_confirm_greeting_anon": "Por favor",
+ "concierge_confirm_greeting_rest": " revise y confirme los datos de abajo.",
+ "concierge_confirm_label_property": "Propiedad",
+ "concierge_confirm_label_date": "Fecha",
+ "concierge_confirm_label_inspector": "Inspector",
+ "concierge_confirm_agreement_notice": "Después de confirmar, pasará a firmar el acuerdo de inspección.",
+ "concierge_confirm_submit_pending": "Confirmando…",
+ "concierge_confirm_submit": "Confirmar la inspección",
+ "concierge_expired_meta_title": "Enlace de confirmación no disponible - OpenInspection",
+ "concierge_expired_unknown_title": "No pudimos encontrar ese enlace de confirmación",
+ "concierge_expired_notoken_title": "No se recibió ningún enlace de confirmación",
+ "concierge_expired_expired_body": "Los enlaces de confirmación son válidos por 7 días. Comuníquese con su agente o su inspector y pueden enviarle uno nuevo en un minuto.",
+ "concierge_expired_unknown_body": "Es posible que el enlace se haya escrito mal o que la reserva se haya cancelado. Póngase en contacto con su agente: puede emitir una confirmación nueva.",
+ "concierge_expired_notoken_body": "Parece que el enlace está incompleto. Use el correo electrónico original y vuelva a intentarlo, o comuníquese con su agente.",
+ "sms_optin_meta_title": "Novedades por mensaje de texto - OpenInspection",
+ "sms_optin_error_confirm_failed": "No pudimos confirmar su consentimiento. Es posible que el enlace haya vencido.",
+ "sms_optin_error_service_unavailable": "Servicio no disponible. Inténtelo de nuevo más tarde.",
+ "sms_optin_notfound_heading": "Enlace no encontrado",
+ "sms_optin_notfound_body": "Este enlace de consentimiento no es válido o venció. Si todavía desea recibir novedades por mensaje de texto, comuníquese con su empresa de inspección.",
+ "sms_optin_subscribed_heading": "Su suscripción está activa",
+ "sms_optin_subscribed_body_1": "Recibirá novedades sobre la cita y el informe de ",
+ "sms_optin_subscribed_body_2": " por mensaje de texto. Responda ",
+ "sms_optin_subscribed_body_3": " en cualquier momento para darse de baja.",
+ "sms_optin_heading": "Envíenme novedades por mensaje de texto",
+ "sms_optin_intro_1": "Reciba recordatorios de la cita y avisos de informe listo de",
+ "sms_optin_intro_2": " por mensaje de texto.",
+ "sms_optin_privacy_link": "Política de privacidad",
+ "sms_optin_terms_link": "Términos del servicio",
+ "sms_optin_submit_pending": "Confirmando...",
+ "sms_optin_submit": "Sí, envíenme novedades por mensaje de texto",
+ "sms_optin_footer_disclosure": "La frecuencia de los mensajes varía según su actividad de inspección. Pueden aplicarse tarifas de mensajes y datos. Responda STOP para darse de baja y HELP para obtener ayuda.",
+ "repair_request_meta_title": "Solicitud de reparación - OpenInspection",
+ "repair_request_error_service_unavailable": "Servicio no disponible",
+ "repair_request_notfound_title": "No encontrada",
+ "repair_request_notfound_body": "Este enlace de solicitud de reparación no es válido o venció.",
+ "repair_request_error_title": "Error",
+ "repair_request_notpublished_title": "Informe no publicado",
+ "repair_request_notpublished_body": "Este informe no está publicado.",
+ "repair_request_eyebrow": "Solicitud de reparación",
+ "repair_request_empty": "No se ha listado ningún elemento de reparación.",
+ "repair_request_col_section": "Sección",
+ "repair_request_col_item": "Elemento",
+ "repair_request_col_finding": "Hallazgo",
+ "repair_request_col_priority": "Prioridad",
+ "repair_request_col_location_prefix": "Ubicación:",
+ "repair_request_col_trade_prefix": "Oficio recomendado:",
+ "repair_request_col_note": "Nota",
+ "repair_request_col_credit": "Crédito",
+ "repair_request_total_label": "Crédito total solicitado",
+ "repair_request_footer_1": "Generado por",
+ "repair_request_footer_2": ". Esta lista refleja las solicitudes de reparación y de crédito del comprador y no constituye un acuerdo legalmente vinculante.",
+ "repair_builder_meta_title": "Crear una solicitud de reparación - OpenInspection",
+ "repair_builder_error_create_list": "No se pudo crear la lista.",
+ "repair_builder_error_add_item": "No se pudo agregar el elemento.",
+ "repair_builder_error_update_item": "No se pudo actualizar el elemento.",
+ "repair_builder_error_remove_item": "No se pudo quitar el elemento.",
+ "repair_builder_error_save_intro": "No se pudo guardar la introducción.",
+ "repair_builder_error_missing_recipient": "Falta shareToken o to.",
+ "repair_builder_error_send_email": "No se pudo enviar el correo electrónico.",
+ "repair_builder_error_unknown_intent": "Intención desconocida: {intent}",
+ "repair_builder_error_server": "Error del servidor.",
+ "repair_builder_noaccess_title": "Se requiere acceso",
+ "repair_builder_noaccess_body": "Necesita un token válido o iniciar sesión para ver esta página.",
+ "repair_builder_notpublished_body": "El informe debe estar publicado antes de que pueda crear una solicitud de reparación.",
+ "repair_builder_forbidden_title": "Función no disponible",
+ "repair_builder_forbidden_body": "El generador de solicitudes de reparación no está habilitado para esta empresa de inspección.",
+ "repair_builder_error_title": "Algo salió mal",
+ "repair_builder_error_body": "No se pudo cargar el generador de solicitudes de reparación. Inténtelo de nuevo.",
+ "agreement_progress_signed": "{signed}/{total} firmaron",
+ "agreement_row_untitled": "Sin título",
+ "agreement_template_status_active": "Activo",
+ "agreement_request_signed_pdf": "PDF firmado",
+ "agreement_request_certificate": "Certificado",
+ "agreement_request_evidence_pack": "Paquete de evidencia",
+ "agreement_request_sign_now": "Firmar ahora",
+ "agreement_request_hide": "Ocultar",
+ "agreement_request_view_signers": "Ver los firmantes",
+ "agreement_onbehalf_checkbox": "Estoy firmando en nombre de otra persona",
+ "agreement_onbehalf_name_label": "Nombre de la persona que representa",
+ "agreement_onbehalf_name_placeholder": "p. ej., Ana Compradora",
+ "agreement_onbehalf_disclaimer_label": "Autorización (opcional)",
+ "agreement_onbehalf_disclaimer_placeholder": "Describa su facultad para firmar por esta persona.",
+ "agreement_detail_loading": "Cargando firmantes…",
+ "agreement_detail_error_no_link": "No se pudo obtener el enlace.",
+ "agreement_detail_error_timeout": "Se agotó el tiempo al obtener el enlace.",
+ "agreement_signers_remind_terminal": "Este firmante ya no está en espera de firma.",
+ "agreement_signers_remind_ratelimited": "Ya se envió un recordatorio en la última hora. Inténtelo de nuevo más tarde.",
+ "agreement_signers_empty": "Aún no hay firmantes en este acuerdo.",
+ "agreement_signers_remind_error": "No se pudo enviar el recordatorio. Inténtelo de nuevo más tarde.",
+ "agreement_signers_copy_error": "No se pudo copiar el enlace. Inténtelo de nuevo.",
+ "agreement_signers_in_person": "En persona",
+ "agreement_signers_on_behalf_of": "En nombre de ",
+ "agreement_signers_remind_pending": "Enviando…",
+ "agreement_signers_remind": "Recordar",
+ "agreement_signers_copy": "Copiar enlace",
+ "agreement_send_error_no_signers": "Agregue al menos un firmante.",
+ "agreement_send_error_no_name": "Cada firmante necesita un nombre.",
+ "agreement_send_email_empty": "(vacío)",
+ "agreement_send_error_invalid_email": "\"{email}\" no es un correo electrónico válido.",
+ "agreement_send_error_duplicate": "Correo electrónico de firmante duplicado: {email}.",
+ "agreement_send_title": "Enviar para firma",
+ "agreement_send_pending": "Enviando…",
+ "agreement_send_submit": "Enviar",
+ "agreement_send_intro": "Agregue a cada persona que deba firmar. Cada una recibe su propio enlace privado.",
+ "agreement_send_name_placeholder": "Nombre completo",
+ "agreement_send_name_aria": "Nombre del firmante",
+ "agreement_send_email_placeholder": "correo@ejemplo.com",
+ "agreement_send_email_aria": "Correo electrónico del firmante",
+ "agreement_send_role_aria": "Rol del firmante",
+ "agreement_send_remove_aria": "Quitar firmante",
+ "agreement_send_add": "+ Agregar firmante",
+ "agreement_send_completion_legend": "Avance",
+ "agreement_send_policy_all": "Todos deben firmar",
+ "agreement_send_policy_one": "Basta con una sola firma para completarlo"
}
From abaa363f6b322a02dfdad2fc859611f4abaa1ae6 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:37:27 +0800
Subject: [PATCH 044/111] i18n(es-419): translate public.json (165 keys)
---
messages/es-419/public.json | 167 +++++++++++++++++++++++++++++++++++-
1 file changed, 166 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/public.json b/messages/es-419/public.json
index 006f618aa..bc741ca4b 100644
--- a/messages/es-419/public.json
+++ b/messages/es-419/public.json
@@ -1,3 +1,168 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "public_verify_meta_title": "Verificar firma - OpenInspection",
+ "public_verify_error_failed": "Error de verificación",
+ "public_verify_error_unavailable": "Servicio no disponible",
+ "public_verify_role_client": "Cliente",
+ "public_verify_role_co_client": "Cliente secundario",
+ "public_verify_role_agent": "Agente",
+ "public_verify_role_signer": "Firmante",
+ "public_verify_failed_heading": "Error de verificación",
+ "public_verify_failed_fallback": "No se pudo verificar esta firma.",
+ "public_verify_result_valid": "Firma verificada",
+ "public_verify_result_invalid": "Firma no válida",
+ "public_verify_document_fallback": "Acuerdo firmado",
+ "public_verify_for_client": " · para {name}",
+ "public_verify_section_signed": "Qué se firmó",
+ "public_verify_snapshot_unavailable": "Instantánea del contenido no disponible: este acuerdo se firmó antes de que existieran las instantáneas.",
+ "public_verify_section_signers": "Firmantes",
+ "public_verify_no_signers": "No hay registros de firmantes.",
+ "public_verify_signed_at": "Firmado el {signedAt}",
+ "public_verify_not_signed": "Aún sin firmar",
+ "public_verify_channel_in_person": " · en persona",
+ "public_verify_section_audit": "Cadena de auditoría",
+ "public_verify_events_label": "Eventos:",
+ "public_verify_algorithm_label": "Algoritmo:",
+ "public_verify_fingerprint_label": "Huella de la clave:",
+ "public_verify_token_meta_title": "Verificar documento - OpenInspection",
+ "public_verify_token_heading": "Verificación del documento",
+ "public_verify_token_valid": "✓ La cadena de auditoría está intacta y las firmas Ed25519 son válidas.",
+ "public_verify_token_failed": "✗ La cadena falló: {reason}",
+ "public_verify_token_unknown_reason": "motivo desconocido",
+ "public_verify_token_signer_label": "Firmante",
+ "public_verify_token_document_label": "Documento",
+ "public_verify_token_untitled": "Sin título",
+ "public_verify_token_events_label": "Eventos de auditoría",
+ "public_verify_token_fingerprint_label": "Huella de la clave",
+ "public_verify_token_algorithm_label": "Algoritmo",
+ "public_verify_token_view_document": "Ver el documento firmado",
+ "public_verify_token_download_audit": "Descargar audit-trail.json",
+ "public_verify_token_download_key": "Descargar public-key.pem",
+ "public_verify_token_offline": "Autoverificación sin conexión (avanzado)",
+ "public_verify_token_footer": "Esta página verifica el documento firmado con la clave pública Ed25519 de la cuenta. Para una auditoría independiente de este servidor, descargue el paquete de evidencia y use la página de autoverificación sin conexión (arriba).",
+ "public_viewer_tz_label": "Horarios mostrados en",
+ "public_viewer_tz_select_aria": "Zona horaria de las fechas y horas de esta página",
+ "public_viewer_tz_detected_note": "Se detectó desde su navegador. Elija otra si no es su zona.",
+ "public_legal_meta_default": "Legal - OpenInspection",
+ "public_legal_doc_privacy": "Política de privacidad",
+ "public_legal_doc_terms": "Términos del servicio",
+ "public_legal_title": "{doc} — {company}",
+ "public_legal_powered_by": "Con la tecnología de",
+ "public_legal_provided_by": ". Servicios de inspección prestados por {company}.",
+ "agent_portal_dashboard_meta_title": "Panel del agente - OpenInspection",
+ "agent_portal_status_booked": "Reservado",
+ "agent_portal_status_scheduled": "Programado",
+ "agent_portal_status_confirmed": "Confirmado",
+ "agent_portal_status_on_site": "En el sitio",
+ "agent_portal_status_completed": "Completado",
+ "agent_portal_status_published": "Publicado",
+ "agent_portal_status_cancelled": "Cancelado",
+ "agent_portal_status_pending": "Pendiente",
+ "agent_portal_dashboard_title": "Panel del agente",
+ "agent_portal_dashboard_subtitle": "Sus referencias en todos los equipos con los que trabaja.",
+ "agent_portal_dashboard_active_referrals": "Referencias activas",
+ "agent_portal_dashboard_across_team_one": "En {count} equipo",
+ "agent_portal_dashboard_across_team_other": "En {count} equipos",
+ "agent_portal_dashboard_reports_ready": "Informes listos para leer",
+ "agent_portal_dashboard_caught_up": "Está al día",
+ "agent_portal_dashboard_tap_open": "Toque una fila de abajo para abrirla",
+ "agent_portal_dashboard_empty_title": "Aún no hay referencias",
+ "agent_portal_dashboard_empty_body": "Los inspectores invitan a los agentes desde su lista de contactos. Una vez que exista el vínculo, cada inspección que refiera aparece aquí.",
+ "agent_portal_dashboard_setup_slug": "Configure su identificador de referencia",
+ "agent_portal_dashboard_referral_one": "referencia",
+ "agent_portal_dashboard_referral_other": "referencias",
+ "agent_portal_dashboard_all_companies": "Todas las empresas",
+ "agent_portal_dashboard_filter_by_company": "Filtrar las referencias por empresa",
+ "agent_portal_dashboard_welcome_banner": "¡Le damos la bienvenida! Esta es la inspección que se acaba de agregar a su lista.",
+ "agent_portal_no_address": "Sin dirección",
+ "agent_portal_dashboard_no_client": "Sin cliente",
+ "agent_portal_dashboard_with_inspector": " · con {name}",
+ "agent_portal_dashboard_build_repair": "Crear una solicitud de reparación",
+ "agent_portal_inspectors_meta_title": "Sus inspectores - OpenInspection",
+ "agent_portal_inspectors_title": "Sus inspectores",
+ "agent_portal_inspectors_subtitle": "Todos los equipos con los que trabaja. Copie un enlace de reserva para compartirlo con sus clientes.",
+ "agent_portal_inspectors_empty_title": "Aún no hay inspectores vinculados",
+ "agent_portal_inspectors_empty_body": "Los inspectores que le envíen una invitación, o cuya lista de contactos ya tenga su correo electrónico, aparecerán aquí automáticamente.",
+ "agent_portal_inspectors_photo_alt": "Inspector",
+ "agent_portal_inspectors_copy_link": "Copiar el enlace de reserva",
+ "agent_portal_inspectors_no_slug": "Este inspector todavía no publicó un identificador de reserva.",
+ "agent_portal_invite_expired_meta_title": "Invitación vencida - OpenInspection",
+ "agent_portal_invite_expired_headline_used": "Esta invitación ya se usó",
+ "agent_portal_invite_expired_headline_no_token": "Este enlace no tiene el token de la invitación",
+ "agent_portal_invite_expired_headline_default": "Esta invitación venció",
+ "agent_portal_invite_expired_explainer_used": "Parece que esta invitación ya fue reclamada. Si no fue usted, pídale al inspector que la reenvíe.",
+ "agent_portal_invite_expired_explainer_no_token": "Al enlace le falta el token de la invitación. Lo más probable es que el correo electrónico se haya alterado en el camino. Pídale al inspector que copie el enlace completo.",
+ "agent_portal_invite_expired_explainer_default": "Las invitaciones vencen a los siete días. Pídale una nueva al inspector: el enlace de abajo completa el mensaje por usted.",
+ "agent_portal_invite_expired_mailto_subject": "¿Podría reenviarme la invitación de agente asociado?",
+ "agent_portal_invite_expired_mailto_greeting": "Hola{name}:",
+ "agent_portal_invite_expired_mailto_body": "Mi invitación de agente asociado a {tenant} venció antes de que pudiera aceptarla. ¿Podría reenviármela?",
+ "agent_portal_invite_expired_mailto_thanks": "¡Gracias!",
+ "agent_portal_invite_expired_inspector_fallback": "el inspector que le envió la invitación",
+ "agent_portal_invite_expired_badge": "La invitación necesita renovarse",
+ "agent_portal_invite_expired_ask": "Pídale una invitación nueva a {inspector}",
+ "agent_portal_invite_expired_signup_instead": "Regístrese directamente",
+ "agent_portal_invite_expired_signup_no_invite": "O regístrese directamente sin una invitación",
+ "agent_portal_recommendations_meta_title": "Elementos de reparación - OpenInspection",
+ "agent_portal_repair_group_safety": "Seguridad",
+ "agent_portal_repair_group_recommendation": "Recomendación",
+ "agent_portal_repair_group_maintenance": "Mantenimiento",
+ "agent_portal_repair_inspector_added": "agregado por el inspector",
+ "agent_portal_repair_custom_note_one": "1 hallazgo tiene una categoría personalizada y se agrupa en Recomendaciones.",
+ "agent_portal_repair_custom_note_other": "{count} hallazgos tienen una categoría personalizada y se agrupan en Recomendaciones.",
+ "agent_portal_repair_items": "Elementos de reparación",
+ "agent_portal_recommendations_meta": "Todos los defectos señalados en los informes de inspección entregados, agrupados por categoría.",
+ "agent_portal_recommendations_total": " {count} elementos en total.",
+ "agent_portal_recommendations_print": "Imprimir como PDF",
+ "agent_portal_recommendations_item_one": "elemento",
+ "agent_portal_recommendations_item_other": "elementos",
+ "agent_portal_repair_empty": "Aún no hay elementos de reparación en los informes que ha referido.",
+ "agent_portal_repair_share_action": "Compartir con el cliente",
+ "agent_portal_repair_share_pending": "Preparando el enlace...",
+ "agent_portal_settings_meta_title": "Configuración del agente - OpenInspection",
+ "agent_portal_settings_title": "Configuración",
+ "agent_portal_settings_subtitle": "Su identificador público de referencia y los correos electrónicos que le enviamos.",
+ "agent_portal_settings_slug_eyebrow": "Identificador de referencia",
+ "agent_portal_settings_slug_heading": "Su enlace de referencia",
+ "agent_portal_settings_slug_desc": "Cuando comparte un enlace de reserva con un cliente, este identificador le atribuye la referencia para que el inspector sepa de dónde vino el cliente.",
+ "agent_portal_settings_slug_label": "Identificador",
+ "agent_portal_settings_slug_placeholder": "jane",
+ "agent_portal_settings_slug_save": "Guardar el identificador",
+ "agent_portal_settings_slug_hint": "Letras minúsculas, números y guiones (3-32 caracteres).",
+ "agent_portal_settings_notifications_eyebrow": "Notificaciones",
+ "agent_portal_settings_notifications_heading": "Qué le envía cada empresa",
+ "agent_portal_settings_notifications_desc": "Usted trabaja con estas empresas por separado, así que cada una tiene su propia configuración.",
+ "agent_portal_settings_notify_company_label": "Empresa",
+ "agent_portal_settings_notify_apply_all": "Aplicar mi próximo cambio a las {count} empresas",
+ "agent_portal_settings_notify_applied_all": "Se aplicó a las {count} empresas.",
+ "agent_portal_settings_notify_no_companies": "Aún no hay empresas. Cuando una empresa de inspección le dé el rol de agente, su configuración de notificaciones aparece aquí.",
+ "agent_portal_settings_slug_error_generic": "No se pudo guardar su identificador. Inténtelo de nuevo.",
+ "agent_portal_settings_notify_error_generic": "No se pudieron guardar sus preferencias de notificación. Inténtelo de nuevo.",
+ "agent_portal_settings_timezone_eyebrow": "Visualización",
+ "agent_portal_settings_timezone_heading": "Zona horaria",
+ "agent_portal_settings_timezone_desc": "Elija cómo se muestran las fechas y horas de las referencias.",
+ "agent_portal_settings_timezone_label": "Su zona horaria",
+ "agent_portal_settings_timezone_company_option": "Usar la zona horaria de cada empresa",
+ "agent_portal_settings_timezone_hint": "Cuando está definida, todas las fechas de referencia usan esta zona. De lo contrario, cada fecha se muestra en la zona horaria de la empresa que inspecciona.",
+ "agent_portal_settings_timezone_saved": "Guardado. Sus fechas de referencia ahora usan esta zona horaria.",
+ "agent_portal_settings_timezone_error_generic": "No se pudo guardar su zona horaria. Inténtelo de nuevo.",
+ "agent_portal_nav_dashboard": "Panel",
+ "agent_portal_nav_inspectors": "Inspectores",
+ "agent_portal_layout_badge": "Portal del agente",
+ "agent_portal_layout_logout": "Cerrar sesión",
+ "oauth_authorize_meta_title": "Autorizar el acceso - OpenInspection",
+ "oauth_authorize_client_fallback": "Una aplicación",
+ "oauth_authorize_heading": "Autorizar el acceso",
+ "oauth_authorize_intro": "quiere acceder a sus datos de OpenInspection. Elija qué puede leer y modificar, y luego autorice.",
+ "oauth_authorize_modules": "Módulos",
+ "oauth_authorize_select_all_read": "Seleccionar todos los permisos de lectura",
+ "oauth_authorize_none": "Ninguno",
+ "oauth_authorize_col_module": "Módulo",
+ "oauth_authorize_col_read": "Lectura",
+ "oauth_authorize_col_write": "Escritura",
+ "oauth_authorize_aria_read": "Leer {module}",
+ "oauth_authorize_aria_write": "Escribir {module}",
+ "oauth_authorize_scope_note": "Marcar Escritura también otorga Lectura. El acceso se limita a su rol y a lo que {clientName} solicitó.",
+ "oauth_authorize_submit_pending": "Autorizando…",
+ "oauth_authorize_submit": "Autorizar",
+ "public_legal_last_updated": "Última actualización: {date}"
}
From 40f740f620b555c1725d9476cfbcbdf17a2e2fb6 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:39:36 +0800
Subject: [PATCH 045/111] i18n(es-419): translate communication.json (103 keys)
---
docs/developers/i18n-glossary.md | 7 +-
messages/es-419/communication.json | 105 ++++++++++++++++++++++++++++-
2 files changed, 108 insertions(+), 4 deletions(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index e1e3ed28f..085e3a14a 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -524,9 +524,10 @@ apply at all.
-- `settings_team_resend_invite`, `settings_integrations_resend_name`, `settings_email_provider_resend` — English "Resend" is two unrelated things.
- On the team page it is the verb (send the invite again) and must be
- *Reenviar*. In the integrations catalogue and the email-provider select it is
+- `settings_team_resend_invite`, `settings_integrations_resend_name`, `settings_email_provider_resend`, `comm_action_resend` — English "Resend" is two unrelated things.
+ On the team page and in the communication outbox it is the verb (send it
+ again) and must be *Reenviar*. In the integrations catalogue and the
+ email-provider select it is
**Resend the company**, the transactional email vendor, and rule 3 forbids
translating a product name — *Reenviar* there would name a provider that does
not exist. The two readings never share a surface.
diff --git a/messages/es-419/communication.json b/messages/es-419/communication.json
index 006f618aa..4b223270d 100644
--- a/messages/es-419/communication.json
+++ b/messages/es-419/communication.json
@@ -1,3 +1,106 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "comm_block_title": "Comunicación",
+ "comm_pill_attention": "{count} requieren atención",
+ "comm_summary_line": "{delivered} entregados · {unread} sin leer · {attention} requieren atención",
+ "comm_messages_heading": "Mensajes",
+ "comm_outbox_heading": "Bandeja de salida",
+ "comm_loading": "Cargando…",
+ "comm_messages_empty_title": "Aún no hay mensajes",
+ "comm_messages_empty_body": "Cuando un cliente o un agente le escribe, la conversación aparece aquí.",
+ "comm_outbox_empty_unpublished": "Todavía no se envió nada: los avisos salen cuando se publica el informe.",
+ "comm_outbox_empty_no_rules": "Todavía no se envió nada: no hay reglas de automatización activadas. Configúrelas en Configuración → Automatizaciones.",
+ "comm_outbox_empty_nothing_yet": "Todavía no se envió nada para esta inspección.",
+ "comm_compose_to": "Para",
+ "comm_compose_to_aria": "A quién se envía este mensaje",
+ "comm_day_today": "Hoy",
+ "comm_day_yesterday": "Ayer",
+ "comm_role_client": "Cliente",
+ "comm_role_agent": "Agente",
+ "comm_role_inspector": "Inspector",
+ "comm_role_other": "Contacto",
+ "comm_send_failed": "El mensaje no se envió. Revise su conexión e inténtelo de nuevo.",
+ "comm_send_failed_inline": "No se envió: inténtelo de nuevo.",
+ "comm_attach_file": "Adjuntar",
+ "comm_attach_uploading": "Subiendo…",
+ "comm_reason_no_sms_consent": "Omitido: esta persona no aceptó recibir mensajes de texto.",
+ "comm_reason_no_review_url": "Omitido: no hay un enlace de reseñas configurado. Agregue uno en Configuración → Empresa.",
+ "comm_reason_sms_not_configured": "Omitido: los mensajes de texto no están configurados para este espacio de trabajo.",
+ "comm_reason_email_not_configured": "Omitido: el envío de correos electrónicos no está configurado para este espacio de trabajo.",
+ "comm_reason_no_sms_template": "Omitido: la regla no tiene una plantilla de texto.",
+ "comm_reason_no_email_template": "Omitido: la regla no tiene una plantilla de correo electrónico.",
+ "comm_reason_managed_not_approved": "Omitido: el número remitente todavía está en espera de la aprobación del operador.",
+ "comm_reason_fallback": "Omitido: {raw}",
+ "comm_status_sent": "Entregado",
+ "comm_status_failed": "Fallido",
+ "comm_status_skipped": "Omitido",
+ "comm_status_pending": "Enviando",
+ "comm_channel_delivered_sr": "{delivered} de {total} entregados por {channel}",
+ "comm_notice_manual": "Envío manual",
+ "comm_notice_automation": "Automatización",
+ "comm_recipient_norole": "Destinatario",
+ "comm_action_get_consent": "Obtener el consentimiento",
+ "comm_action_resend": "Reenviar",
+ "messages_meta_title": "Mensajes - OpenInspection",
+ "messages_heading": "Mensajes",
+ "messages_meta": "{count} conversaciones",
+ "messages_empty_list": "Aún no hay conversaciones. Escríbale a un cliente o a un agente desde una página de inspección y el hilo aparece aquí.",
+ "messages_pick_thread": "Elija una conversación a la izquierda.",
+ "messages_unknown_contact": "Contacto desconocido",
+ "messages_thread_empty_title": "Aún no hay mensajes con este contacto",
+ "messages_thread_empty_body": "Escriba abajo para iniciar la conversación.",
+ "messages_thread_spans": "Esta conversación abarca:",
+ "messages_mention_label": "Sobre",
+ "messages_mention_aria": "Vincular este mensaje a una inspección",
+ "messages_mention_none": "Sin inspección",
+ "nav_item_messages": "Mensajes",
+ "notice_panel_title": "Avisos",
+ "notice_panel_mark_all": "Marcar todo como leído",
+ "notice_panel_settings": "Configuración de notificaciones",
+ "portal_notif_save_error": "No se pudo guardar. Inténtelo de nuevo.",
+ "portal_notif_heading": "Configuración de notificaciones",
+ "portal_notif_desc": "Esta configuración cubre todo lo que esta empresa le envía, no solo esta inspección. Cambiar algo aquí se aplica a todas sus inspecciones con ella.",
+ "portal_notif_page_meta_title": "Configuración de notificaciones - OpenInspection",
+ "portal_notif_signin_heading": "Administre sus notificaciones",
+ "portal_notif_signin_subtitle": "Ingrese su correo electrónico y le enviaremos un enlace seguro a su configuración de notificaciones.",
+ "portal_notif_back_to_portal": "← Volver a mis inspecciones",
+ "portal_notif_submit": "Envíenme un enlace por correo electrónico",
+ "notice_bell_aria": "Avisos ({count} sin leer)",
+ "notice_bell_aria_none": "Avisos",
+ "notice_empty_title": "Aún no hay avisos",
+ "notice_empty_body": "Cuando su inspector le envíe algo —un informe, una factura, un cambio de horario—, aparece aquí.",
+ "notice_empty_body_agent": "Cuando la inspección de uno de sus clientes genere un informe, una factura o un cambio de horario, aparece aquí.",
+ "notice_dismiss": "Descartar",
+ "notice_dismiss_aria": "Descartar este aviso",
+ "notice_unread_aria": "Sin leer",
+ "notice_channel_email": "Correo electrónico",
+ "notice_channel_sms": "Texto",
+ "notice_channel_other": "Mensaje",
+ "notice_outcome_delivered": "Entregado",
+ "notice_outcome_sending": "Enviando",
+ "notice_outcome_not_delivered": "No entregado",
+ "notice_outcome_sr": "{channel}: {outcome}",
+ "notice_reason_no_sms_consent": "No tenemos su autorización para enviarle mensajes de texto.",
+ "notice_reason_bounced": "No pudimos comunicarnos con usted en {address}.",
+ "notice_action_turn_on_texts": "Activar los mensajes de texto",
+ "notice_action_tell_new_email": "Díganos su nuevo correo electrónico",
+ "notice_draft_new_email": "Mi dirección de correo electrónico cambió. Por favor, use esta dirección: ",
+ "notice_title_report_published": "Su informe de inspección está listo",
+ "notice_title_report_amended": "Su informe de inspección se actualizó",
+ "notice_title_invoice_created": "Su factura está lista",
+ "notice_title_payment_received": "Recibimos su pago",
+ "notice_title_inspection_confirmed": "Su inspección está confirmada",
+ "notice_title_inspection_reminder": "Su inspección se acerca",
+ "notice_title_agreement_signed": "Su acuerdo de inspección está firmado",
+ "notice_title_manual_send": "Un mensaje de su inspector",
+ "comm_notice_title_inspection_created": "Nueva inspección programada — {address}",
+ "comm_notice_title_inspection_confirmed": "Inspección confirmada — {address}",
+ "comm_notice_title_inspection_cancelled": "Inspección cancelada — {address}",
+ "comm_notice_title_report_published": "Informe publicado — {address}",
+ "comm_notice_title_invoice_created": "Factura creada — {address}",
+ "comm_notice_title_payment_received": "Pago recibido — {address}",
+ "comm_notice_title_generic": "{event} — {address}",
+ "notice_empty_body_staff": "Cuando llega una reserva, se firma un acuerdo o entra un pago, aparece aquí.",
+ "comm_reason_sms_opt_out": "Omitido: esta persona respondió STOP. Puede enviar START por mensaje de texto para volver a suscribirse.",
+ "notice_reason_sms_opt_out": "Usted nos pidió que dejáramos de enviarle mensajes de texto. Responda START a cualquiera de nuestros mensajes para volver a activarlos."
}
From fe88fd499d91f1b349cb89404abd49aa6fa57b26 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:50:01 +0800
Subject: [PATCH 046/111] i18n(es-419): translate components.json (183 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Shared-ui surface text — modals, the new-inspection wizard, the inspection hub
action sheets, link expiry and the notification preference matrix. Every string
here renders on more than one screen, so the pinned lexicon did most of the
work: 44 of the strings already had a committed Spanish form elsewhere and were
copied character for character.
Two strings set the standard for a later module: "Workspace" (the email group)
-> "Espacio de trabajo", which nav must match, and "Creating…" -> "Creando…",
which misc must match. "Concierge" as an email group follows the glossary's
"concierge review" -> "revisión previa" rather than the doorman sense.
The US address placeholder stays English, as already shipped elsewhere.
---
messages/es-419/components.json | 185 +++++++++++++++++++++++++++++++-
1 file changed, 184 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/components.json b/messages/es-419/components.json
index 006f618aa..db6660b59 100644
--- a/messages/es-419/components.json
+++ b/messages/es-419/components.json
@@ -1,3 +1,186 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "modal_seatlimit_title": "Límite de puestos alcanzado",
+ "modal_seatlimit_body": "Usted alcanzó su límite de puestos ({used}/{max}). Mejore su plan para invitar a más miembros del equipo.",
+ "modal_seatlimit_upgrade": "Mejorar el plan",
+ "modal_invite_title": "Invitar",
+ "modal_edit_member_title": "Editar miembro",
+ "modal_edit_member_error_failed": "No se pudieron guardar los cambios.",
+ "modal_edit_member_owner_notice": "El rol de un titular no se puede cambiar aquí. Cambiar quién es el titular del espacio de trabajo todavía no es algo que esta aplicación pueda hacer — comuníquese con soporte si lo necesita.",
+ "modal_edit_member_role_change_note": "Los cambios de permisos se aplican de inmediato. Cambiar el rol cierra la sesión de este miembro, así que su nuevo acceso empieza a regir en el siguiente inicio de sesión.",
+ "modal_invite_send": "Enviar la invitación",
+ "modal_invite_email_label": "Correo electrónico",
+ "modal_invite_notify_label": "Enviar una notificación por correo electrónico",
+ "modal_invite_role_label": "Rol",
+ "modal_invite_role_manager": "Gerente",
+ "modal_invite_role_inspector": "Inspector",
+ "modal_invite_role_agent": "Agente",
+ "modal_invite_advanced": "Permisos avanzados",
+ "modal_invite_error_failed": "Fallido",
+ "modal_invite_role_desc_manager": "Acceso completo a las inspecciones, las plantillas y la gestión del equipo.",
+ "modal_invite_role_desc_inspector": "Crear y editar las inspecciones que se le asignen.",
+ "form_field_select_placeholder": "Seleccione...",
+ "form_field_photo_hint": "La captura de fotos está disponible en el editor de inspecciones",
+ "form_rich_notes_placeholder": "Notas...",
+ "email_preview_label": "Vista previa",
+ "email_preview_updating": "Actualizando…",
+ "email_preview_sample": "Datos de ejemplo",
+ "email_preview_to": "Para: cliente@ejemplo.com",
+ "email_preview_iframe_title": "Vista previa del correo electrónico",
+ "email_list_group_client": "Cliente",
+ "email_list_group_agent": "Agente",
+ "email_list_group_concierge": "Revisión previa",
+ "email_list_group_system": "Espacio de trabajo",
+ "email_list_customized": "Personalizado",
+ "email_list_alwayson": "Siempre activo",
+ "email_list_active": "Activo",
+ "email_list_disabled": "Deshabilitado",
+ "email_list_enable_aria": "Habilitar {name}",
+ "email_list_disable_aria": "Deshabilitar {name}",
+ "newinsp_people_client_section": "Cliente",
+ "newinsp_people_name_label": "Nombre",
+ "newinsp_people_client_name_ph": "Nombre completo del cliente",
+ "newinsp_people_name_required": "El nombre es obligatorio cuando se agrega un cliente.",
+ "newinsp_people_email_hint": "Sin un correo electrónico no podrá enviar el acuerdo ni el informe más adelante.",
+ "newinsp_people_email_label": "Correo electrónico",
+ "newinsp_people_client_email_ph": "cliente@ejemplo.com",
+ "newinsp_people_phone_label": "Teléfono",
+ "newinsp_people_phone_ph": "(555) 123-4567",
+ "newinsp_people_agent_section": "Agente",
+ "newinsp_people_remove_agent_aria": "Quitar el agente seleccionado",
+ "newinsp_people_new_agent_title": "Nuevo agente",
+ "newinsp_people_agent_name_ph": "Nombre completo del agente",
+ "newinsp_people_agent_email_ph": "agente@inmobiliaria.com",
+ "newinsp_people_search_ph": "Buscar agentes…",
+ "newinsp_people_client_search_ph": "Busque clientes o escriba un nombre nuevo",
+ "newinsp_people_no_clients": "Ningún cliente coincide — este nombre se agregará como uno nuevo.",
+ "newinsp_people_searching": "Buscando…",
+ "newinsp_people_no_agents": "No se encontraron agentes.",
+ "newinsp_people_add_agent": "+ Nuevo agente",
+ "newinsp_property_type_label": "Tipo de propiedad",
+ "newinsp_property_type_single_family": "Unifamiliar",
+ "newinsp_property_type_multi_unit": "Multifamiliar",
+ "newinsp_property_type_commercial": "Comercial",
+ "newinsp_property_address_label": "Dirección",
+ "newinsp_property_address_ph": "123 Main St, City, State",
+ "newinsp_property_template_label": "Plantilla",
+ "newinsp_property_no_templates": "Todavía no hay plantillas — primero cree una en Plantillas.",
+ "newinsp_property_select_option": "Buscar plantillas…",
+ "newinsp_property_no_match": "Ninguna plantilla coincide con “{query}”.",
+ "newinsp_property_item_one": "{count} elemento",
+ "newinsp_property_item_many": "{count} elementos",
+ "newinsp_review_heading": "Revisión",
+ "newinsp_review_address": "Propiedad",
+ "newinsp_review_template": "Plantilla",
+ "newinsp_review_when": "Programado",
+ "newinsp_review_client": "Cliente",
+ "newinsp_review_agent": "Agente",
+ "newinsp_review_services": "Servicios",
+ "newinsp_review_assignee": "Inspector",
+ "newinsp_review_assignee_you": "Usted",
+ "newinsp_review_empty_hint": "Se completa a medida que usted avanza.",
+ "newinsp_team_mode_label": "Modo de equipo",
+ "newinsp_team_solo": "Individual",
+ "newinsp_team_team": "Equipo",
+ "newinsp_team_inspector_label": "Inspector",
+ "newinsp_team_select_option": "Seleccione un inspector…",
+ "newinsp_team_inspector_ph": "ID o nombre del inspector",
+ "newinsp_conflict_title": "Conflicto de programación:",
+ "newinsp_conflict_one": "este inspector ya tiene una inspección en {address}",
+ "newinsp_conflict_many": "este inspector ya tiene {count} inspecciones",
+ "newinsp_conflict_suffix": "en este horario. Aun así, usted puede programarla.",
+ "newinsp_schedule_date_label": "Fecha",
+ "newinsp_schedule_time_label": "Hora",
+ "newinsp_schedule_time_zone_hint": "Los horarios están en {zone}, la zona horaria del espacio de trabajo.",
+ "newinsp_schedule_holiday_advisory": "Está programando en {name}. Aun así, puede guardar esta inspección.",
+ "newinsp_schedule_holiday_block": "No se puede programar en {name} — los feriados de la empresa están bloqueados.",
+ "newinsp_schedule_holiday_block_generic": "No se puede programar en un día de cierre de la empresa.",
+ "newinsp_quota_title": "Límite del plan gratuito alcanzado",
+ "newinsp_quota_body": "Usted ya usó las 5 inspecciones gratuitas. Todo lo que creó sigue siendo totalmente utilizable — suscríbase para crear nuevas.",
+ "newinsp_quota_subscribe": "Suscribirse",
+ "newinsp_services_label": "Seleccione los servicios",
+ "newinsp_services_price_aria": "Precio de {name}",
+ "newinsp_services_total": "Total:",
+ "hub_reinspect_title": "Crear una reinspección",
+ "hub_reinspect_pending": "Creando…",
+ "hub_reinspect_submit": "Crear una reinspección",
+ "hub_reinspect_help": "Elija qué elementos trasladar. Los elementos marcados que siguen abiertos vienen preseleccionados.",
+ "hub_reinspect_empty": "Este informe no tiene elementos disponibles para trasladar.",
+ "hub_publish_title": "Publicar el informe",
+ "hub_publish_pending": "Publicando…",
+ "hub_publish_submit": "Publicar el informe",
+ "hub_publish_notify_client": "Notificar al cliente por correo electrónico",
+ "hub_publish_notify_client_amendment": "Notificar al cliente de esta modificación por correo electrónico",
+ "hub_publish_notify_agent": "Notificar al agente",
+ "hub_publish_require_signature": "Exigir la firma antes de ver el informe",
+ "hub_publish_require_payment": "Exigir el pago antes de ver el informe",
+ "hub_payment_title": "Solicitar el pago",
+ "hub_payment_title_resend": "Reenviar la solicitud de pago",
+ "hub_payment_submit": "Enviar la solicitud",
+ "hub_payment_submit_resend": "Reenviar la solicitud",
+ "hub_payment_pending": "Enviando…",
+ "hub_payment_recipient_label": "Destinatario",
+ "hub_payment_no_email": "Esta inspección no tiene correo electrónico del cliente",
+ "hub_payment_amount_label": "Monto",
+ "hub_agreement_title": "Enviar acuerdo",
+ "hub_agreement_pending": "Enviando…",
+ "hub_agreement_submit": "Enviar acuerdo",
+ "hub_agreement_email_label": "Correo electrónico del cliente",
+ "hub_agreement_email_ph": "cliente@ejemplo.com",
+ "hub_agreement_template_label": "Acuerdo",
+ "hub_agreement_no_template": "No hay ninguna plantilla de acuerdo disponible",
+ "newinsp_gate_address": "Ingrese la dirección de la propiedad para continuar",
+ "newinsp_gate_template": "Elija una plantilla para continuar",
+ "newinsp_gate_client_name": "Agregue el nombre del cliente o borre su correo electrónico y su teléfono",
+ "newinsp_gate_service": "Seleccione al menos un servicio",
+ "newinsp_gate_date": "Elija una fecha para continuar",
+ "newinsp_gate_holiday": "Elija otra fecha — esta está bloqueada",
+ "newinsp_people_client_linked": "Contacto existente — esta inspección se suma a su historial.",
+ "newinsp_property_clear_template": "Borrar la plantilla",
+ "link_expiry_never": "Nunca vence",
+ "link_expiry_after": "Vence después de",
+ "link_expiry_mode_label": "Si los enlaces del informe vencen",
+ "link_expiry_count_label": "Cuánto duran los enlaces del informe",
+ "link_expiry_unit_label": "Unidad de tiempo",
+ "link_expiry_unit_days": "días",
+ "link_expiry_unit_months": "meses",
+ "link_expiry_unit_years": "años",
+ "link_expiry_preview_never": "Los enlaces del informe siguen funcionando hasta que usted los restablezca o los elimine.",
+ "link_expiry_preview_date": "Un enlace creado hoy dejaría de funcionar el {date}.",
+ "notif_prefs_always_heading": "Siempre se envían",
+ "notif_prefs_always_reason": "Enviamos estos mensajes porque usted los necesita para entrar en su cuenta, o porque son su constancia de algo que firmó o que debe. No se pueden desactivar.",
+ "notif_prefs_always_show": "Ver cuáles son",
+ "notif_prefs_choose_heading": "Usted puede desactivar",
+ "notif_prefs_saving": "Guardando…",
+ "notif_prefs_saved": "Guardado",
+ "notif_prefs_bulk_all": "Activar o desactivar todas las notificaciones",
+ "notif_prefs_bulk_all_short": "Todo",
+ "notif_prefs_bulk_column": "Activar o desactivar {channel} en todas las notificaciones",
+ "notif_prefs_bulk_row": "Activar o desactivar {notification} en todos los canales",
+ "notif_prefs_sms_heading": "Mensajes de texto",
+ "notif_prefs_sms_on": "Los mensajes de texto a {phone} están ACTIVADOS.",
+ "notif_prefs_sms_on_no_phone": "Los mensajes de texto están ACTIVADOS.",
+ "notif_prefs_sms_captured": "Usted los activó el {date} desde {source}.",
+ "notif_prefs_sms_implied": "Podemos enviarle mensajes de texto sobre el trabajo que ya estamos haciendo juntos. Usted puede detenerlos en cualquier momento.",
+ "notif_prefs_sms_revoked": "Los mensajes de texto están DESACTIVADOS. Usted los detuvo el {date}.",
+ "notif_prefs_sms_none": "Los mensajes de texto están DESACTIVADOS. No tenemos constancia de que usted los haya pedido.",
+ "notif_prefs_sms_stop_hint": "Para detenerlos, responda STOP a cualquier mensaje.",
+ "notif_prefs_sms_stop": "Detener los mensajes de texto",
+ "notif_prefs_sms_grant_ack": "Sí, envíenme mensajes de texto a este número.",
+ "notif_prefs_sms_grant": "Activar los mensajes de texto",
+ "notif_prefs_sms_resume": "Volver a activar los mensajes de texto",
+ "notif_prefs_sms_disclosure_show": "Lea a qué está dando su consentimiento",
+ "notif_prefs_sms_locked": "El canal de texto está desactivado arriba, así que esto no aplica.",
+ "notif_prefs_sms_manage": "Administrar los mensajes de texto",
+ "notif_prefs_source_settings_page": "esta página",
+ "notif_prefs_source_booking_form": "un formulario de reserva",
+ "notif_prefs_source_optin_link": "un enlace de consentimiento",
+ "notif_prefs_source_admin": "su inspector",
+ "notif_prefs_email_off_note": "Desactivar el correo electrónico detiene solo los opcionales. Igual le enviamos por correo electrónico las cosas que usted no puede desactivar arriba.",
+ "notif_prefs_save_failed": "No se pudo guardar. Inténtelo de nuevo.",
+ "notif_prefs_legend": "Aquí se enumeran todas las notificaciones, en todos los canales. Desactivar una se aplica de inmediato y sigue aplicándose si más adelante empezamos a enviarla de una forma nueva.",
+ "notif_prefs_choose_empty": "Todavía no hay nada aquí. Cuando haya algo que usted pueda desactivar, aparecerá aquí.",
+ "notif_prefs_channel_email": "Correo electrónico",
+ "notif_prefs_channel_sms": "Texto",
+ "notif_prefs_channel_in_app": "En la aplicación"
}
From a2fff9001ad8c8d4bd4c2c08a081b62cb58394df Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:51:42 +0800
Subject: [PATCH 047/111] i18n(es-419): translate nav.json (30 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The chrome every user sees on every page. Fifteen of the thirty were already
pinned by earlier modules, including "Workspace" -> "Espacio de trabajo" set
one commit ago in components.json. "Metrics" -> "Métricas" is set here and
binds metrics.json and misc.json.
Sidebar labels set the width of the rail: Settings -> Configuración (+63%),
Inspections -> Inspecciones, and the Workspace section header -> Espacio de
trabajo (+100%) are the ones the browser pass must look at in the collapsed and
expanded rail and in the mobile drawer. Roughly half these keys render only as
aria-label or title (command palette, menu open/close, sidebar expand/collapse,
the theme switcher), so they need a DOM-attribute sweep, not a visual read.
---
messages/es-419/nav.json | 32 +++++++++++++++++++++++++++++++-
1 file changed, 31 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/nav.json b/messages/es-419/nav.json
index 006f618aa..dd1c976b1 100644
--- a/messages/es-419/nav.json
+++ b/messages/es-419/nav.json
@@ -1,3 +1,33 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "nav_item_inspections": "Inspecciones",
+ "nav_item_calendar": "Calendario",
+ "nav_item_contacts": "Contactos",
+ "nav_item_invoices": "Facturas",
+ "nav_item_metrics": "Métricas",
+ "nav_item_team": "Equipo",
+ "nav_item_library": "Biblioteca",
+ "nav_item_settings": "Configuración",
+ "nav_section_workspace": "Espacio de trabajo",
+ "nav_action_notifications": "Notificaciones",
+ "nav_action_search": "Buscar…",
+ "nav_action_command_palette": "Abrir la paleta de comandos",
+ "nav_action_open_menu": "Abrir el menú",
+ "nav_action_close_menu": "Cerrar el menú",
+ "nav_action_expand_sidebar": "Expandir la barra lateral",
+ "nav_action_collapse_sidebar": "Contraer la barra lateral",
+ "nav_action_expand": "Expandir",
+ "nav_action_collapse": "Contraer",
+ "nav_user_menu_aria": "Menú de usuario",
+ "nav_user_profile": "Mi perfil",
+ "nav_user_switch_workspace": "Cambiar de espacio de trabajo…",
+ "nav_user_privacy": "Política de privacidad",
+ "nav_user_logout": "Cerrar sesión",
+ "nav_theme_label": "Tema",
+ "nav_theme_auto": "Auto",
+ "nav_theme_light": "Claro",
+ "nav_theme_dark": "Oscuro",
+ "nav_theme_field": "Campo",
+ "nav_theme_field_title": "Tipografía grande de alto contraste para uso en exteriores",
+ "nav_theme_aria": "Tema de color"
}
From ad964f4f63bb1aae1bfbb59d9dc134c551cf04a6 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:54:56 +0800
Subject: [PATCH 048/111] i18n(es-419): translate helpers.json (54 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Defaults and derived strings the helper modules hand to the UI: booking steps
and time windows, rating descriptions, the PDF service errors, the Google
Calendar OAuth failure set, onboarding checklist labels and the not-found
titles.
Two judgement calls worth stating. The booking window details are display-only
(booking-constants.ts passes them straight through), so the clock strings become
"8:00 a.m. - 12:00 p.m." to match what Intl actually renders for es-419 rather
than staying English. And "Morning"/"Afternoon" became "Por la mañana"/"Por la
tarde": bare "Mañana" reads as "tomorrow" in a screen where the user has just
picked a date.
"Confirm" -> "Confirmar" is set here and binds misc.json.
---
messages/es-419/helpers.json | 56 +++++++++++++++++++++++++++++++++++-
1 file changed, 55 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/helpers.json b/messages/es-419/helpers.json
index 006f618aa..1018e26fd 100644
--- a/messages/es-419/helpers.json
+++ b/messages/es-419/helpers.json
@@ -1,3 +1,57 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "helper_booking_inspector_first_available": "El primero disponible",
+ "helper_booking_inspector_default": "Inspector",
+ "helper_booking_submit_success": "¡Se envió la solicitud de reserva! Usted recibirá en breve un correo electrónico de confirmación.",
+ "helper_booking_submit_error": "Algo salió mal. Inténtelo de nuevo.",
+ "helper_booking_network_error": "Error de red. Revise su conexión.",
+ "helper_rating_desc_satisfactory": "El elemento funciona como corresponde; no se observaron inconvenientes.",
+ "helper_rating_desc_monitor": "El elemento es funcional pero muestra desgaste; se recomienda una reinspección periódica.",
+ "helper_rating_desc_defect": "El elemento está roto, deteriorado o es inseguro; se recomienda repararlo o reemplazarlo.",
+ "helper_rating_desc_defective": "El elemento no funciona como corresponde; se recomienda repararlo o reemplazarlo.",
+ "helper_rating_desc_not_inspected": "No se pudo inspeccionar el elemento (inaccesible, inseguro o excluido).",
+ "helper_rating_desc_not_present": "El elemento no está presente en esta propiedad.",
+ "helper_rating_desc_inspected": "El elemento se inspeccionó y cumple con las Normas de Práctica.",
+ "helper_rating_desc_functional": "El elemento se inspeccionó visualmente y se observó en condiciones de servicio y de funcionamiento.",
+ "helper_rating_desc_hazardous": "El elemento presenta un peligro de seguridad inmediato y se debe atender sin demora.",
+ "helper_pdf_rate_limit": "El servicio de PDF está ocupado en este momento. Espere a que termine la cuenta regresiva y luego inténtelo de nuevo.",
+ "helper_pdf_network": "No se pudo conectar con el servicio de PDF. Espere y luego inténtelo de nuevo.",
+ "helper_pdf_retry_in": "Reintentar en {seconds} s",
+ "helper_pdf_generating": "Generando…",
+ "helper_structure_new_section_default": "Nueva sección",
+ "helper_structure_new_item_default": "Nuevo elemento",
+ "helper_structure_custom_template_default": "Plantilla personalizada",
+ "helper_caloauth_cancelled": "Se canceló la conexión con Google Calendar.",
+ "helper_caloauth_interaction_required": "El inicio de sesión de Google necesita un paso más. Intente conectarse de nuevo.",
+ "helper_caloauth_login_required": "La sesión de inicio de sesión de Google venció. Intente conectarse de nuevo.",
+ "helper_caloauth_consent_required": "Google necesita que se confirmen los permisos. Intente conectarse de nuevo.",
+ "helper_caloauth_session_expired": "La sesión de conexión venció. Inténtelo de nuevo.",
+ "helper_caloauth_not_configured": "Google Calendar no está configurado para este espacio de trabajo. Comuníquese con su administrador.",
+ "helper_caloauth_no_refresh_token": "Google no otorgó acceso continuo. Intente conectarse de nuevo y apruebe todos los permisos.",
+ "helper_caloauth_exchange_failed": "No se pudo completar la autorización de Google Calendar. Inténtelo de nuevo.",
+ "helper_caloauth_generic": "No se pudo conectar Google Calendar. Inténtelo de nuevo.",
+ "helper_onboarding_company_label": "Defina el nombre de su empresa",
+ "helper_onboarding_timezone_label": "Defina su zona horaria",
+ "helper_onboarding_template_label": "Tener una plantilla de inspección",
+ "helper_onboarding_services_label": "Ponga precio a sus servicios",
+ "helper_onboarding_schedule_label": "Configure su agenda",
+ "helper_onboarding_first_inspection_label": "Cree su primera inspección",
+ "helper_section_report_not_found": "Informe no encontrado",
+ "helper_section_service_unavailable": "Servicio no disponible",
+ "helper_section_inspection_not_found": "Inspección no encontrada",
+ "helper_section_invoice_not_found": "Factura no encontrada",
+ "helper_section_agreement_not_found": "Acuerdo no encontrado",
+ "helper_pdf_busy_hint": "Se está generando su PDF — esto puede tardar hasta un minuto.",
+ "helper_booking_step_property": "Propiedad",
+ "helper_booking_step_services": "Servicios",
+ "helper_booking_step_schedule": "Agenda",
+ "helper_booking_step_confirm": "Confirmar",
+ "helper_booking_window_morning_label": "Por la mañana",
+ "helper_booking_window_morning_detail": "8:00 a.m. - 12:00 p.m.",
+ "helper_booking_window_afternoon_label": "Por la tarde",
+ "helper_booking_window_afternoon_detail": "12:00 p.m. - 5:00 p.m.",
+ "helper_booking_window_allday_label": "Todo el día",
+ "helper_booking_window_allday_detail": "Horario flexible",
+ "helper_booking_window_custom_label": "Personalizado",
+ "helper_booking_window_custom_detail": "Elija una hora específica"
}
From 40e56b3577f8b83f2df43b5ca86b2df47d5198c9 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:56:50 +0800
Subject: [PATCH 049/111] i18n(es-419): translate validation.json (28 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Form error text — the copy a user meets at their most frustrated moment, and
the file in the catalogue most exposed to the tú/usted trap because it is
almost entirely imperatives and second person. Every imperative here is usted:
Ingrese, Confirme, Vuelva a escribir. A sweep for tú possessives, tú preterites
and the bare tú imperatives the register gate cannot see returned nothing in
this file.
"acct_" stays English inside the Stripe account-ID message: it is the literal
prefix the validator matches on.
"Enter a valid email address" follows the already-shipped settings-integrations
wording (dirección de correo electrónico válida) rather than inventing a second
phrasing for the same failure.
---
messages/es-419/validation.json | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/validation.json b/messages/es-419/validation.json
index 006f618aa..6f56489e3 100644
--- a/messages/es-419/validation.json
+++ b/messages/es-419/validation.json
@@ -1,3 +1,31 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "validation_contact_name_required": "El nombre es obligatorio",
+ "validation_contact_email_invalid": "Ingrese un correo electrónico válido",
+ "validation_stripe_account_id_required": "El ID de la cuenta de Stripe es obligatorio",
+ "validation_stripe_account_id_invalid": "Ingrese un ID de cuenta de Stripe válido (empieza con acct_).",
+ "validation_comm_email_invalid": "Ingrese una dirección de correo electrónico válida",
+ "validation_password_min8": "La contraseña debe tener al menos 8 caracteres",
+ "validation_password_uppercase": "Debe contener al menos una letra mayúscula",
+ "validation_password_number": "Debe contener al menos un número",
+ "validation_password_special": "Debe contener al menos un carácter especial",
+ "validation_delete_account_email_required": "Vuelva a escribir el correo electrónico de su cuenta para confirmar la eliminación",
+ "validation_delete_account_email_invalid": "Ingrese una dirección de correo electrónico válida",
+ "validation_change_password_current_required": "La contraseña actual es obligatoria",
+ "validation_change_password_confirm_required": "Confirme su nueva contraseña",
+ "validation_change_password_mismatch": "Las contraseñas nuevas no coinciden",
+ "validation_profile_name_too_long": "El nombre es demasiado largo",
+ "validation_profile_phone_too_long": "El teléfono es demasiado largo",
+ "validation_profile_license_too_long": "El número de licencia es demasiado largo",
+ "validation_workspace_name_required": "El nombre del espacio de trabajo es obligatorio",
+ "validation_workspace_name_too_long": "El nombre del espacio de trabajo es demasiado largo",
+ "validation_workspace_color_invalid": "Color hexadecimal no válido",
+ "validation_workspace_company_address_too_long": "La dirección de la empresa es demasiado larga",
+ "validation_service_name_required": "El nombre del servicio es obligatorio",
+ "validation_service_name_too_long": "El nombre del servicio es demasiado largo",
+ "validation_service_description_too_long": "La descripción es demasiado larga",
+ "validation_service_price_invalid": "Ingrese un precio de 0 o más",
+ "validation_service_duration_invalid": "Ingrese minutos enteros, menos de 24 horas",
+ "validation_role_label_required": "La etiqueta es obligatoria",
+ "validation_service_id_required": "Falta indicar qué servicio se debe guardar"
}
From 738be69d04539b1344ca6f0ff552127996ba9caf Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 13:58:45 +0800
Subject: [PATCH 050/111] i18n(es-419): translate metrics.json (47 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The metrics dashboard: KPI tiles, two charts, three tables and the date-range
picker.
"Avg Order Value" became "Valor promedio por inspección" rather than anything
built on *orden*. The product forbids "Order" as a name for an inspection in
English and the Spanish glossary bans *orden de trabajo* for the same reason;
the figure is revenue divided by inspections, so naming it after the inspection
is both the accurate reading and the one the terminology allows.
"{count} insp" keeps its abbreviation — it is the same clipping in Spanish and
it sits in a narrow bar-chart label.
---
messages/es-419/metrics.json | 49 +++++++++++++++++++++++++++++++++++-
1 file changed, 48 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/metrics.json b/messages/es-419/metrics.json
index 006f618aa..aa4a0d987 100644
--- a/messages/es-419/metrics.json
+++ b/messages/es-419/metrics.json
@@ -1,3 +1,50 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "metrics_meta_title": "Métricas - OpenInspection",
+ "metrics_heading": "Métricas",
+ "metrics_meta": "{count} inspecciones",
+ "metrics_loading": "Cargando...",
+ "metrics_kpi_revenue": "Ingresos totales",
+ "metrics_kpi_inspections": "Inspecciones totales",
+ "metrics_kpi_aov": "Valor promedio por inspección",
+ "metrics_chart_inspections": "Inspecciones por mes",
+ "metrics_no_data": "No hay datos en este rango de fechas.",
+ "metrics_chart_revenue": "Ingresos por mes",
+ "metrics_no_revenue": "No hay ingresos en este rango de fechas.",
+ "metrics_top_agents": "Principales agentes que refieren",
+ "metrics_agent_count": "{count} insp",
+ "metrics_no_agents": "Todavía no hay datos de agentes.",
+ "metrics_by_inspector": "Por inspector",
+ "metrics_col_inspector": "Inspector",
+ "metrics_col_inspections": "Inspecciones",
+ "metrics_col_revenue": "Ingresos",
+ "metrics_col_turnaround": "Tiempo promedio de entrega",
+ "metrics_turnaround_days": "{days} d",
+ "metrics_turnaround_na": "—",
+ "metrics_no_inspectors": "Todavía no hay datos de inspectores.",
+ "metrics_findings_title": "Hallazgos por sección",
+ "metrics_col_section": "Sección",
+ "metrics_col_total": "Total",
+ "metrics_no_findings": "No hay hallazgos calificados en este rango de fechas.",
+ "metrics_findings_system_aria": "Sistema de calificación con el que se cuentan estos hallazgos",
+ "metrics_findings_system_option": "{name} · {count}",
+ "metrics_findings_other_systems": "Otros {count} hallazgos se calificaron con un sistema de calificación distinto. Cambie de sistema arriba para verlos — los conteos no son comparables entre sistemas, así que nunca se combinan.",
+ "metrics_section_removed": "Secciones eliminadas",
+ "metrics_section_removed_hint": "Hallazgos registrados en una sección que ya no forma parte de ninguna plantilla.",
+ "metrics_services_title": "Combinación de servicios",
+ "metrics_col_service": "Servicio",
+ "metrics_no_services": "Todavía no hay datos de servicios.",
+ "metrics_range_7d": "Últimos 7 días",
+ "metrics_range_14d": "Últimos 14 días",
+ "metrics_range_30d": "Últimos 30 días",
+ "metrics_range_3m": "Últimos 3 meses",
+ "metrics_range_6m": "Últimos 6 meses",
+ "metrics_range_12m": "Últimos 12 meses",
+ "metrics_range_ytd": "Año hasta la fecha",
+ "metrics_range_custom": "Rango personalizado",
+ "metrics_range_custom_heading": "Rango personalizado",
+ "metrics_range_from": "Fecha de inicio",
+ "metrics_range_to": "Fecha de fin",
+ "metrics_range_apply": "Aplicar el rango",
+ "metrics_range_aria": "Elija el rango de fechas que cubren estas cifras"
}
From 3485796143d3ff9dd38fe36d9bfb14d127033e56 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 14:02:35 +0800
Subject: [PATCH 051/111] i18n(es-419): translate media.json (93 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Photo Studio, the cropper, the signature pad and the video walk-through.
A third divergence is declared with it. English "Free" is price on the
agent-portal invite (Gratis) and shape in the photo cropper, where it sits
beside Portrait and Landscape and means the unconstrained aspect ratio (Libre).
Both keys are on the bullet's first line, because the parser only reads keys
from a line beginning with a dash and .every() means an unlisted key voids the
whole declaration. Verified the declaration is load-bearing: with the bullet
disabled the gate fails naming "Free", with it restored the gate is green.
The three-part video privacy sentence is reordered for Spanish and still joins
correctly — VideoCapture.tsx concatenates before + " " + no +
" " + after, so the emphasis lands on the negation as it does in English.
---
docs/developers/i18n-glossary.md | 2 +
messages/es-419/media.json | 95 +++++++++++++++++++++++++++++++-
2 files changed, 96 insertions(+), 1 deletion(-)
diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md
index 085e3a14a..59cbd196a 100644
--- a/docs/developers/i18n-glossary.md
+++ b/docs/developers/i18n-glossary.md
@@ -534,6 +534,8 @@ apply at all.
- `settings_comms_template_subject_label`, `settings_compliance_col_subject` — English "Subject" is a homograph, not a shared concept. On the email-template editor it is the subject line (*Asunto*); in the erasure log it is the GDPR **data subject**, a person (*Interesado*). No Spanish word covers both, and picking either would make one of the two screens nonsense.
+- `auth_agent_invite_prop3_title`, `media_cropper_free` — English "Free" is price in one place and shape in the other. On the agent-portal invite it is the cost of the account (*Gratis*); in the photo cropper it is the unconstrained aspect ratio beside Portrait and Landscape (*Libre*). *Gratis* on a crop button says the crop costs nothing, which is not a thing anyone was wondering.
+
*(Add further divergences as `- \`key_one\`, \`key_two\` — reason.)*
## Working through a module
diff --git a/messages/es-419/media.json b/messages/es-419/media.json
index 006f618aa..1b10ac4f6 100644
--- a/messages/es-419/media.json
+++ b/messages/es-419/media.json
@@ -1,3 +1,96 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "media_common_zoom": "Zoom",
+ "media_annotate_tool_pan": "Desplazar",
+ "media_annotate_tool_circle": "Círculo",
+ "media_annotate_tool_arrow": "Flecha",
+ "media_annotate_tool_free": "Dibujar",
+ "media_annotate_tool_text": "Etiqueta",
+ "media_annotate_tool_measure": "Medir",
+ "media_annotate_caption_placeholder": "Agregue una leyenda...",
+ "media_annotate_title": "Estudio de fotos",
+ "media_annotate_photo_of": "Foto {index} de {total}",
+ "media_annotate_arrow_hint": "Haga clic para fijar el extremo de la flecha",
+ "media_annotate_measure_hint": "Haga clic para fijar el extremo de la medición",
+ "media_annotate_draw_hint": "Haga clic y arrastre para dibujar",
+ "media_annotate_cover_current": "Esta es la portada del informe",
+ "media_annotate_cover_set_title": "Establecer como portada del informe",
+ "media_annotate_cover_badge": "Portada",
+ "media_annotate_cover_set": "Establecer como portada",
+ "media_annotate_close_aria": "Cerrar el estudio de fotos",
+ "media_annotate_undo_title": "Deshacer la última anotación",
+ "media_annotate_label_placeholder": "Ingrese la etiqueta...",
+ "media_annotate_empty_title": "Ninguna foto seleccionada",
+ "media_annotate_empty_subtitle": "Tome o suba una foto para anotarla",
+ "media_annotate_zoom_in": "Acercar",
+ "media_annotate_zoom_reset": "Restablecer el zoom",
+ "media_annotate_zoom_out": "Alejar",
+ "media_annotate_count_one": "{count} anotación",
+ "media_annotate_count_other": "{count} anotaciones",
+ "media_measure_reference_label": "Longitud de referencia:",
+ "media_measure_placeholder": "p. ej. 12",
+ "media_measure_set": "Fijar",
+ "media_measure_cancel_aria": "Cancelar la calibración",
+ "media_cover_crop_title": "Recortar la foto de portada",
+ "media_cover_crop_save": "Guardar la portada",
+ "media_cropper_title": "Recortar foto",
+ "media_cropper_save": "Guardar el recorte",
+ "media_cropper_free": "Libre",
+ "media_cropper_portrait": "Vertical",
+ "media_cropper_landscape": "Horizontal",
+ "media_cropper_orientation_toggle": "Cambiar entre vertical y horizontal",
+ "media_avatar_crop_aria": "Recortar el avatar",
+ "media_avatar_save": "Guardar la foto",
+ "media_logo_alt": "Logotipo",
+ "media_logo_uploading": "Subiendo…",
+ "media_logo_upload": "Subir el logotipo",
+ "media_logo_hint": "Se recomienda PNG / SVG (transparente)",
+ "media_gallery_loading": "Cargando fotos…",
+ "media_gallery_empty": "Todavía no hay fotos en esta inspección.",
+ "media_signature_heading": "Firme aquí",
+ "media_signature_pen_mouse_touch": "mouse / táctil",
+ "media_signature_pen_pressure": "lápiz · presión",
+ "media_signature_pen_touch": "táctil",
+ "media_signature_aria": "Panel de firma: dibuje su firma aquí",
+ "media_signature_hint": "firme sobre la línea",
+ "media_viewer_poster": "Fotograma de portada",
+ "media_viewer_cover": "Establecer portada",
+ "media_viewer_caption": "Leyenda",
+ "media_viewer_crop": "Recortar",
+ "media_viewer_annotate": "Anotar",
+ "media_viewer_rotate": "Girar",
+ "media_viewer_revert": "Revertir",
+ "media_video_unavailable": "Video no disponible",
+ "media_video_processing": "Procesando…",
+ "media_video_uploading": "Subiendo…",
+ "media_video_walkthrough_title": "Recorrido en video",
+ "media_video_add_aria": "Agregar video",
+ "media_video_add_heading": "Agregar un recorrido en video",
+ "media_video_pick": "Elija o grabe un clip",
+ "media_video_formats_hint": "MP4 / MOV / WebM · hasta {maxSec} s · máx. 200 MB",
+ "media_video_err_format": "Formato no admitido. Use MP4, MOV o WebM.",
+ "media_video_err_too_large": "El video es demasiado grande (máx. 200 MB).",
+ "media_video_err_unexpected": "El servicio de video devolvió una respuesta inesperada. Inténtelo de nuevo.",
+ "media_video_err_upload": "Falló la subida. Revise su conexión e inténtelo de nuevo.",
+ "media_video_err_upload_status": "Falló la subida ({status}). Inténtelo de nuevo.",
+ "media_video_privacy_before": "Este clip se almacena tal como se grabó. Los datos de ubicación incrustados en el archivo",
+ "media_video_privacy_not": "no",
+ "media_video_privacy_after": "se eliminan. Entiendo y quiero subirlo.",
+ "media_poster_heading": "Elija el fotograma de portada",
+ "media_poster_unavailable": "Video no disponible — los fotogramas de portada no se pueden cargar en este momento.",
+ "media_poster_frame_alt": "Fotograma a los {sec} s",
+ "media_poster_pick_from_photos": "Elegir de las fotos…",
+ "media_poster_set": "Establecer el fotograma",
+ "media_strip_move_to": "Mover a",
+ "media_strip_choose_item": "Elija un elemento…",
+ "media_strip_delete_count": "Eliminar {count}",
+ "media_strip_select": "Seleccionar",
+ "media_strip_uploading": "Subiendo",
+ "media_strip_cover": "Portada",
+ "media_strip_add_photo_aria": "Agregar foto",
+ "media_cropper_rotate_left": "Girar a la izquierda",
+ "media_cropper_rotate_right": "Girar a la derecha",
+ "media_badge_crop_aria": "Recortar la insignia",
+ "media_badge_save": "Guardar la insignia",
+ "media_cropper_bake_failed": "No se pudo procesar esa imagen. Pruebe con otro archivo."
}
From 82c62f0be78f1faaae66be0385b6d871189d591a Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 14:07:45 +0800
Subject: [PATCH 052/111] i18n(es-419): translate misc.json (221 keys)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The catch-all, and the last file in the catalogue. Command palette, audit trail,
the canned-comment / defect-category / rating-system editors, the documents
panel, the legal checkbox, the template combobox and the whole invoices surface.
Fifty-four of the strings were already pinned by the twenty-eight modules that
came before, which is what a catch-all looks like when it is translated last.
Two things needed reading the render, not the key. The legal checkbox is built
from four fragments, so the articles have to agree across the seam: "Acepto
los" + Términos del servicio + "y reconozco la" + Política de privacidad. And
the keyboard hints keep their letters — "G luego I" — because G and I are the
keys the shortcut handler listens for, not words.
es-419 coverage is now 4323 of 4323.
---
messages/es-419/misc.json | 223 +++++++++++++++++++++++++++++++++++++-
1 file changed, 222 insertions(+), 1 deletion(-)
diff --git a/messages/es-419/misc.json b/messages/es-419/misc.json
index 006f618aa..0c9329cb6 100644
--- a/messages/es-419/misc.json
+++ b/messages/es-419/misc.json
@@ -1,3 +1,224 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format"
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "command_palette_group_pages": "Páginas",
+ "command_palette_group_settings": "Configuración",
+ "command_palette_group_quick_actions": "Acciones rápidas",
+ "command_palette_group_recent": "Inspecciones recientes",
+ "command_palette_page_inspections": "Inspecciones",
+ "command_palette_page_reports": "Informes",
+ "command_palette_page_templates": "Plantillas",
+ "command_palette_page_marketplace": "Marketplace",
+ "command_palette_page_agreements": "Acuerdos",
+ "command_palette_page_comments": "Comentarios predefinidos",
+ "command_palette_page_repair": "Elementos de reparación",
+ "command_palette_page_contacts": "Contactos",
+ "command_palette_page_calendar": "Calendario",
+ "command_palette_page_invoices": "Facturas",
+ "command_palette_page_ratings": "Sistemas de calificación",
+ "command_palette_page_metrics": "Métricas",
+ "command_palette_page_team": "Equipo",
+ "command_palette_page_notifications": "Notificaciones",
+ "command_palette_hint_g_then_i": "G luego I",
+ "command_palette_hint_g_then_r": "G luego R",
+ "command_palette_hint_g_then_t": "G luego T",
+ "command_palette_hint_g_then_c": "G luego C",
+ "command_palette_settings_main": "Configuración",
+ "command_palette_settings_profile": "Configuración - Perfil",
+ "command_palette_settings_company": "Configuración - Empresa",
+ "command_palette_settings_theme": "Configuración - Tema del informe",
+ "command_palette_settings_services": "Configuración - Servicios y precios",
+ "audit_trail_history": "Historial",
+ "audit_trail_hide": "Ocultar el historial",
+ "audit_trail_loading": "Cargando…",
+ "audit_trail_empty": "Todavía no hay cambios registrados.",
+ "audit_trail_unknown_actor": "Desconocido",
+ "audit_trail_last_edited": "Última edición por {name}",
+ "audit_action_created": "Creado",
+ "audit_action_updated": "Actualizado",
+ "audit_action_deleted": "Eliminado",
+ "audit_action_other": "Modificado",
+ "command_palette_settings_email": "Configuración - Correo electrónico",
+ "command_palette_settings_email_templates": "Configuración - Plantillas de correo electrónico",
+ "command_palette_settings_automations": "Configuración - Automatizaciones",
+ "command_palette_settings_integrations": "Configuración - Integraciones",
+ "command_palette_settings_qbo": "Configuración - QuickBooks",
+ "command_palette_settings_password": "Configuración - Cambiar contraseña",
+ "command_palette_settings_2fa": "Configuración - Doble factor (2FA)",
+ "command_palette_settings_account": "Configuración - Cuenta y seguridad",
+ "command_palette_settings_payments": "Configuración - Pagos",
+ "command_palette_settings_ai": "Configuración - IA",
+ "command_palette_settings_data": "Configuración - Importación / exportación de datos",
+ "command_palette_action_new_inspection": "Nueva inspección",
+ "command_palette_action_new_template": "Nueva plantilla",
+ "command_palette_action_new_contact": "Nuevo contacto",
+ "command_palette_action_import": "Importar Spectora",
+ "command_palette_action_hint_create": "crear",
+ "command_palette_action_copy_booking_link": "Copiar mi enlace de reservas",
+ "command_palette_recent_fallback": "Inspección n.º {id}",
+ "command_palette_search_placeholder": "Escriba un comando o busque...",
+ "command_palette_prefix_actions": "acciones",
+ "command_palette_prefix_people": "personas",
+ "command_palette_no_results": "No se encontraron resultados",
+ "command_palette_footer_navigate": "navegar",
+ "command_palette_footer_select": "seleccionar",
+ "command_palette_footer_close": "cerrar",
+ "comment_editor_error_text_required": "El texto del comentario es obligatorio",
+ "comment_editor_title_edit": "Editar el comentario",
+ "comment_editor_title_new": "Nuevo comentario",
+ "comment_editor_save_changes": "Guardar cambios",
+ "comment_editor_add": "Agregar comentario",
+ "comment_editor_text_label": "Texto del comentario",
+ "comment_editor_text_placeholder": "Se observaron indicios de una reparación anterior.",
+ "comment_editor_section_label": "Sección",
+ "comment_editor_optional": "· opcional",
+ "comment_editor_section_placeholder": "Techo",
+ "comment_editor_item_label": "Etiqueta del elemento",
+ "comment_editor_item_placeholder": "Cubierta del techo",
+ "comment_editor_severity_label": "Gravedad",
+ "comment_editor_severity_unclassified": "Sin clasificar",
+ "comment_editor_repair_label": "Resumen de la reparación",
+ "comment_editor_repair_placeholder": "Se recomienda que un techador con licencia evalúe y repare.",
+ "comment_editor_est_low": "Est. mín",
+ "comment_editor_est_high": "Est. máx",
+ "comment_editor_contractor_label": "Tipo de contratista",
+ "comment_editor_contractor_none": "Ninguno",
+ "cost_export_csv_label": "Exportar los costos (CSV)",
+ "cost_export_xlsx_label": "Exportar los costos (Excel)",
+ "cost_export_csv_hint": "Descargue las tablas de costos (Opinión de Costo + Programa de reservas) como una hoja de cálculo CSV",
+ "cost_export_xlsx_hint": "Descargue las tablas de costos (Opinión de Costo + Programa de reservas) como un libro de Excel",
+ "cost_export_csv_short": "Exportar CSV",
+ "cost_export_xlsx_short": "Exportar Excel",
+ "defect_category_editor_error_name_required": "El nombre es obligatorio",
+ "defect_category_editor_title_edit": "Editar la categoría de defecto",
+ "defect_category_editor_title_new": "Nueva categoría de defecto",
+ "defect_category_editor_save_changes": "Guardar cambios",
+ "defect_category_editor_create": "Crear la categoría",
+ "defect_category_editor_pick_color": "Elegir color",
+ "defect_category_editor_color_aria": "Color de la categoría",
+ "defect_category_editor_name_label": "Nombre",
+ "defect_category_editor_name_placeholder": "p. ej. Seguridad",
+ "defect_category_editor_sort_label": "Orden de clasificación",
+ "defect_category_editor_drives_summary": "Incluir los defectos de esta categoría en el Resumen del informe",
+ "documents_error_rejected": "Ese tipo o tamaño de archivo no está permitido (máx. 100 MB).",
+ "documents_heading": "Documentos",
+ "documents_description": "Comparta archivos para esta inspección. PDF, imágenes, documentos de Office y CAD de hasta 100 MB.",
+ "documents_category_label": "Categoría",
+ "documents_visibility_label": "Visibilidad",
+ "documents_visibility_client": "Visible para el cliente",
+ "documents_visibility_internal": "Solo interno",
+ "documents_label_optional": "Etiqueta (opcional)",
+ "documents_label_placeholder": "p. ej. informe de termitas de 2019",
+ "documents_uploading": "Subiendo…",
+ "documents_drag": "Arrastre un archivo aquí, o",
+ "documents_choose_file": "Elegir archivo",
+ "documents_uploader_inspector": "Inspector",
+ "documents_uploader_client": "Cliente",
+ "documents_internal_badge": "Interno",
+ "documents_empty": "Todavía no hay documentos.",
+ "documents_delete_title": "¿Eliminar el documento?",
+ "documents_delete_message": "Esto elimina el archivo de forma permanente. Esta acción no se puede deshacer.",
+ "legal_checkbox_agree": "Acepto los",
+ "legal_checkbox_terms": "Términos del servicio",
+ "legal_checkbox_and_ack": "y reconozco la",
+ "legal_checkbox_privacy": "Política de privacidad",
+ "new_inspection_step_property": "Propiedad",
+ "new_inspection_step_people": "Personas",
+ "new_inspection_step_services": "Servicios",
+ "new_inspection_step_schedule": "Agenda",
+ "new_inspection_step_team": "Equipo",
+ "new_inspection_step_confirm": "Confirmar",
+ "new_inspection_title": "Nueva inspección",
+ "new_inspection_create": "Crear la inspección",
+ "rating_editor_error_name_required": "El nombre es obligatorio",
+ "rating_editor_error_slug": "El nombre debe producir un identificador de 2 caracteres o más",
+ "rating_editor_error_min_levels": "Agregue al menos 2 niveles",
+ "rating_editor_error_level_fields": "Cada nivel necesita una abreviatura y una etiqueta",
+ "rating_editor_title_edit": "Editar el sistema de calificación",
+ "rating_editor_title_new": "Nuevo sistema de calificación",
+ "rating_editor_save_changes": "Guardar cambios",
+ "rating_editor_create": "Crear el sistema",
+ "rating_editor_name_label": "Nombre",
+ "rating_editor_name_placeholder": "p. ej. Estándar de 5 niveles",
+ "rating_editor_description_label": "Descripción",
+ "rating_editor_optional": "· opcional",
+ "rating_editor_description_placeholder": "Cuándo conviene que los inspectores usen esta escala",
+ "rating_editor_levels_heading": "Niveles de calificación",
+ "rating_editor_start_from": "Partir de",
+ "rating_editor_pick_color": "Elegir color",
+ "rating_editor_level_color_aria": "Color del nivel",
+ "rating_editor_abbr_placeholder": "ABREV",
+ "rating_editor_label_placeholder": "Etiqueta completa, p. ej. Satisfactorio",
+ "rating_editor_severity_title": "Gravedad",
+ "rating_editor_defect": "Defecto",
+ "rating_editor_defect_title": "Cuenta como defecto en los totales",
+ "rating_editor_pause": "Pausar",
+ "rating_editor_pause_title": "Pausar el avance automático después de seleccionar este nivel",
+ "rating_editor_move_up": "Subir",
+ "rating_editor_move_down": "Bajar",
+ "rating_editor_remove_level": "Quitar el nivel",
+ "rating_editor_add_level": "+ Agregar nivel",
+ "rating_editor_default_toggle": "Usar como el sistema de calificación predeterminado para las plantillas nuevas",
+ "template_combobox_placeholder": "--- Seleccione una plantilla ---",
+ "template_combobox_search_placeholder": "Buscar plantillas...",
+ "template_combobox_no_match": "Ninguna plantilla coincide con su búsqueda",
+ "template_combobox_no_templates": "No se encontraron plantillas",
+ "template_combobox_clear": "--- Borrar la selección ---",
+ "template_combobox_load_more": "Cargar más",
+ "access_denied_title": "Solo administradores",
+ "access_denied_body": "Usted no tiene permiso para ver esta página.",
+ "breadcrumb_aria": "Ruta de navegación",
+ "template_menu_template": "Plantilla",
+ "template_menu_actions_aria": "Acciones de la plantilla",
+ "template_menu_change": "Cambiar de plantilla…",
+ "template_menu_save_new": "Guardar como plantilla nueva…",
+ "template_menu_update_source": "Actualizar la plantilla de origen",
+ "invoices_meta_title": "Facturas - OpenInspection",
+ "invoices_pay_method_check": "Cheque",
+ "invoices_pay_method_cash": "Efectivo",
+ "invoices_pay_method_offline": "Banco / Otro medio fuera de línea",
+ "invoices_pay_method_other": "Otro",
+ "invoices_action_error_amount": "Se exigen el nombre del cliente y un monto positivo.",
+ "invoices_action_error_create": "No se pudo crear la factura.",
+ "invoices_action_error_mark_paid": "No se pudo registrar ese pago. La factura no cambió — inténtelo de nuevo.",
+ "invoices_method_label_card": "Tarjeta",
+ "invoices_method_label_check": "Cheque",
+ "invoices_method_label_cash": "Efectivo",
+ "invoices_method_label_offline": "Fuera de línea",
+ "invoices_method_label_other": "Otro",
+ "invoices_new_button": "+ Nueva factura",
+ "invoices_new_title": "Nueva factura",
+ "invoices_new_inspection_label": "Inspección (vincula la página de pago)",
+ "invoices_new_no_inspection": "— Sin inspección (factura independiente) —",
+ "invoices_new_client_label": "Nombre del cliente",
+ "invoices_new_amount_label": "Monto (USD)",
+ "invoices_new_due_label": "Fecha de vencimiento",
+ "invoices_new_notes_label": "Notas",
+ "invoices_new_creating": "Creando…",
+ "invoices_new_create": "Crear la factura",
+ "invoices_count_singular": "Factura",
+ "invoices_count_plural": "Facturas",
+ "invoices_meta_singular": "factura",
+ "invoices_meta_plural": "facturas",
+ "invoices_meta_unpaid": "sin pagar",
+ "invoices_row_view_inspection": "Ver la inspección",
+ "invoices_stat_total": "TOTAL",
+ "invoices_stat_unpaid": "SIN PAGAR",
+ "invoices_stat_paid": "PAGADO",
+ "invoices_stat_revenue": "INGRESOS",
+ "invoices_empty_title": "Todavía no hay facturas",
+ "invoices_col_client": "Cliente",
+ "invoices_col_amount": "Monto",
+ "invoices_col_due": "Fecha de vencimiento",
+ "invoices_col_status": "Estado",
+ "invoices_col_action": "Acción",
+ "invoices_paid_by": "Pagado con:",
+ "invoices_mark_paid": "Marcar como pagada",
+ "invoices_footer_note": "“Marcar como pagada” registra un pago fuera de línea (cheque, efectivo, transferencia bancaria) y desbloquea el informe. Los pagos con tarjeta en línea se marcan automáticamente cuando el cliente paga.",
+ "settings_inspection_save_failed": "Ese cambio no se pudo guardar y se deshizo. Revise su conexión e inténtelo de nuevo.",
+ "invoices_action_void": "Anular",
+ "invoices_void_title": "¿Anular esta factura?",
+ "invoices_void_confirm": "Deja de contar para los ingresos y deja de retener el informe. El registro se conserva para su pista de auditoría en lugar de eliminarse, y esto no se puede deshacer aquí.",
+ "invoices_action_error_void": "No se pudo anular esta factura. No se cambió nada — inténtelo de nuevo.",
+ "load_failed_generic": "Esto no se pudo cargar, así que puede estar incompleto. Vuelva a cargar antes de darlo por vacío.",
+ "load_failed_named": "No se pudo cargar {what}, así que esto puede estar incompleto. Vuelva a cargar antes de darlo por vacío."
}
From 6fe51fde5748e7ee0d26921efced4f92bffcef62 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 14:14:32 +0800
Subject: [PATCH 053/111] chore(i18n): restore the catalog parity gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Coverage is 4323/4323 across all 29 modules, so a missing es-419 key is no
longer the expected state — it is a gap that reaches a Spanish-speaking user as
English mid-sentence. FALLBACK_ALLOW stays empty: nothing needed a grace entry.
Proven red before landing: deleting nav_item_inspections from
messages/es-419/nav.json made the gate exit 1 naming that key; restoring the
file (backup-and-move, never `git checkout --`, which would have destroyed an
uncommitted module while leaving the gates green) returned it to 4323/4323.
Closes #268.
---
scripts/check-i18n-catalog.mjs | 28 ++++++++++++++++++++--------
1 file changed, 20 insertions(+), 8 deletions(-)
diff --git a/scripts/check-i18n-catalog.mjs b/scripts/check-i18n-catalog.mjs
index 873c18771..6bdd9e401 100644
--- a/scripts/check-i18n-catalog.mjs
+++ b/scripts/check-i18n-catalog.mjs
@@ -13,8 +13,11 @@
* be a *deliberate* choice, never silent drift). Also flags STALE target keys and
* DUPLICATE keys across modules (a real conflict in the shared namespace).
*
- * As phases 3-5 extract more surfaces, add newly-extracted-but-untranslated keys
- * to FALLBACK_ALLOW, then remove each as its es-419 translation lands.
+ * The English-only phase ENDED with #268: es-419 reached full parity, so a key
+ * added in English and never translated is no longer the expected state — it is a
+ * gap that reaches a Spanish-speaking user as English mid-sentence. Adding an
+ * English key now means translating it in the same commit, or naming it in
+ * FALLBACK_ALLOW with a reason.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -97,16 +100,25 @@ for (const locale of TARGET_LOCALES) {
const target = load(locale);
const targetKeys = new Set(Object.keys(target));
- // English-only extraction phase: a target translation is OPTIONAL. Keys without
- // one fall back to English at runtime (safe). We only REPORT coverage here — we
- // do NOT fail on untranslated keys, so the extraction sweep can add English keys
- // without blocking on translation. (The parity hard-gate returns in the
- // translation phase; FALLBACK_ALLOW / `allow` is retained for that.)
const translated = sourceKeys.filter(
(k) => targetKeys.has(k) && String(target[k]).trim() !== '',
).length;
coverage.push(`${locale} ${translated}/${sourceKeys.length}`);
+ // Translation phase (#268 complete): a missing target key is now a FAILURE.
+ // During the English-only phase this only reported, so the sweep could add
+ // English keys without blocking. That trade is over — from here, an
+ // untranslated key is a gap that ships to a Spanish-speaking user as English
+ // mid-sentence. Keys that must stay English go in FALLBACK_ALLOW with a reason.
+ const missing = sourceKeys.filter((k) => !targetKeys.has(k) && !allow.has(k));
+ if (missing.length) {
+ failed = true;
+ console.error(
+ `[i18n-catalog] ${locale}: ${missing.length} untranslated key(s):\n` +
+ missing.map((k) => ` - ${k}`).join('\n'),
+ );
+ }
+
// Guard: any PRESENT translation must be non-empty (an empty string is a
// mistake — it renders blank instead of falling back to English).
const blank = [...targetKeys].filter((k) => String(target[k]).trim() === '');
@@ -133,4 +145,4 @@ if (failed) {
console.error('[i18n-catalog] FAIL — resolve the catalog drift above.');
process.exit(1);
}
-console.log(`[i18n-catalog] OK (English-only phase) — ${sourceKeys.length} source key(s); translation coverage: ${coverage.join(', ')}.`);
+console.log(`[i18n-catalog] OK (parity enforced) — ${sourceKeys.length} source key(s); translation coverage: ${coverage.join(', ')}.`);
From ae62cf3bbaeab8889cf0258e1ba936b2074a0306 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 15:30:58 +0800
Subject: [PATCH 054/111] fix(i18n): three es-419 layouts that were sized for
the English word
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Found by running the app under es-419 — the browser pass the translation
waves deferred. All three are containers sized for an English label that the
Spanish one outgrows; none of them is visible in the JSON.
- Notification preference matrix: channel columns were 5rem and "Correo
electrónico" measures 85px, so it bled out of its column into the next one.
6rem fits it; English is unaffected.
- Editor item-filter strip: the column is a fixed 280px, so "Sin calificar"
broke mid-phrase inside its own chip while its neighbours stayed on one
line. The strip now wraps (the batch toggle drops to a second row) and the
chip labels do not.
- Services DURATION column: durationLabel's own comment says the English was
already compacted because "1 hr 30 min" wrapped. es-419 "2 h 30 min" has
spaces to break at and split as "2 h 30 / min"; a duration is one token.
Verified at 1280px in light and dark by applying each fix to the live DOM and
re-measuring: no clipping, no spill, no page overflow. Catalogue untouched —
coverage stays 4323/4323.
---
app/components/notifications/NotificationPreferences.tsx | 9 +++++++--
.../settings/services/ServicesCatalogPanel.tsx | 5 ++++-
app/routes/inspection-edit.tsx | 8 ++++----
3 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx
index 588739a98..6b6074e47 100644
--- a/app/components/notifications/NotificationPreferences.tsx
+++ b/app/components/notifications/NotificationPreferences.tsx
@@ -230,7 +230,12 @@ export function NotificationPreferences({
// screen-reader user gets row/column context, and the header
// row is only a visual convenience for everyone else.
-
+ {/* Channel columns are 6rem, not 5: the widest header is
+ the localized channel name, and es-419 "Correo
+ electrónico" measures 85px — it outgrew a 5rem column
+ and bled into the next one. Sized for the label, not
+ for the English word "Email". */}
+
{bulk && bulkStateOf(youChoose, {}) && (
<>
@@ -267,7 +272,7 @@ export function NotificationPreferences({
{bulk && bulkStateOf(youChoose, { classId: row.id }) && (
diff --git a/app/components/settings/services/ServicesCatalogPanel.tsx b/app/components/settings/services/ServicesCatalogPanel.tsx
index e7d26f50c..06157e487 100644
--- a/app/components/settings/services/ServicesCatalogPanel.tsx
+++ b/app/components/settings/services/ServicesCatalogPanel.tsx
@@ -92,8 +92,11 @@ export function ServicesCatalogPanel({
},
{
label: m.settings_services_col_duration(),
+ // `whitespace-nowrap`: a duration is one token. Compacting the
+ // English (see durationLabel) was not enough — es-419 "2 h 30 min"
+ // has spaces to break at and split as "2 h 30 / min".
cell: (svc) => (
-
+
{durationLabel(svc.durationMinutes)}
),
diff --git a/app/routes/inspection-edit.tsx b/app/routes/inspection-edit.tsx
index 9fcab50c9..98b88a810 100644
--- a/app/routes/inspection-edit.tsx
+++ b/app/routes/inspection-edit.tsx
@@ -2296,8 +2296,8 @@ export default function InspectionEditPage() {
{/* Column 2: Item List (280px, items-only) — hidden in fullscreen */}
{!state.itemFullscreen && (
- {/* Item filter tabs */}
-
+ {/* Filter tabs. Fixed 280px: es-419 "Sin calificar" broke mid-phrase inside its chip, so the STRIP wraps and labels do not. */}
+
({
value: f,
label: (
- <>
+
{f === "all" ? m.editor_route_filter_all() : f === "unrated" ? m.editor_route_filter_unrated() : f === "issues" ? m.editor_route_filter_issues() : m.editor_route_filter_flagged()}
{f !== "all" && (
{f === "unrated" ? state.filterCounts.unrated : f === "issues" ? state.filterCounts.issues : state.filterCounts.flagged}
)}
- >
+
),
}))}
/>
From d48ecee3d149cdc54a739d5dcf8e44eacb649a82 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 15:44:55 +0800
Subject: [PATCH 055/111] chore(mcp): record the per-report delete route in the
OpenAPI snapshot
Task 4b added DELETE /api/inspections/:id/reports/:reportId and the snapshot gate
lives only in the full unit run, not pre-commit, so the batch boundary is the
first place it could be seen. Purely additive: 452 routes, was 451.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
server/lib/mcp/openapi-snapshot.json | 58 ++++++++++++++++++++++++++++
1 file changed, 58 insertions(+)
diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json
index db1c0fe82..77e5d35c7 100644
--- a/server/lib/mcp/openapi-snapshot.json
+++ b/server/lib/mcp/openapi-snapshot.json
@@ -5752,6 +5752,45 @@
"summary": "Delete a pool photo (cancel an upload)",
"description": "Auto-generated placeholder for deleteInspectionMediaPool (DELETE /{id}/media/pool/{poolId}, inspections domain). TODO: replace with a real description sourced from the handler."
},
+ {
+ "operationId": "deleteInspectionReport",
+ "method": "DELETE",
+ "pathTemplate": "/api/inspections/{id}/reports/{reportId}",
+ "scopes": [
+ "write"
+ ],
+ "tag": "inspections",
+ "tier": "primary",
+ "inputSchema": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Inspection the report belongs to.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Inspection the report belongs to."
+ }
+ },
+ {
+ "name": "reportId",
+ "in": "path",
+ "required": true,
+ "description": "reports.id of the deliverable to delete.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "reports.id of the deliverable to delete."
+ }
+ }
+ ],
+ "body": null
+ },
+ "summary": "Delete one deliverable from an inspection",
+ "description": "Permanently deletes one report and everything belonging only to it — its findings document, the collaborative Yjs state, and its version rows. The billing line that produced it is untouched. Refused for the primary report (every order keeps one; without it the order cannot be edited) and for a published report (it has been delivered and its signed versions are what let a client verify what they hold)."
+ },
{
"operationId": "deleteInspectionTemplate",
"method": "DELETE",
@@ -14461,6 +14500,25 @@
"locale": {
"type": "string",
"description": "Per-user display locale (BCP-47). Empty string clears the override (inherit tenant)."
+ },
+ "dateFormat": {
+ "type": "string",
+ "enum": [
+ "",
+ "us",
+ "iso",
+ "eu"
+ ],
+ "description": "Per-user date order (us|iso|eu). Empty string clears the override (inherit tenant)."
+ },
+ "timeFormat": {
+ "type": "string",
+ "enum": [
+ "",
+ "12h",
+ "24h"
+ ],
+ "description": "Per-user clock (12h|24h). Empty string clears the override (inherit tenant)."
}
}
}
From 503d40850d8d050fdb880bba68f43b62946a40cc Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 15:53:55 +0800
Subject: [PATCH 056/111] test(collab): stub the session hooks in the second
VersionHistoryPanel suite
#270 Task 4 routed VersionHistoryPanel through useDisplayTimeZone, whose
useRouteLoaderData invariants outside a data router. version-history-panel.test.ts
got the stub; this suite renders the same component and did not, so it went red at
the batch boundary rather than at the commit that caused it.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
app/components/collab/version-compare.test.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/app/components/collab/version-compare.test.ts b/app/components/collab/version-compare.test.ts
index 839be63ff..50946daac 100644
--- a/app/components/collab/version-compare.test.ts
+++ b/app/components/collab/version-compare.test.ts
@@ -8,6 +8,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createElement, act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
+
+// #270 — the panel reads the TENANT's zone + date shape, and those hooks bottom
+// out in `useRouteLoaderData`, which invariants in this router-free harness.
+// Kept identical to version-history-panel.test.ts: both suites render the same
+// component, so a stub added to one and not the other only looks green.
+vi.mock('~/hooks/useSessionContext', () => ({
+ useDisplayTimeZone: () => 'UTC',
+ useInspectionDateTimeFormat: () => ({ locale: 'en-US', dateFormat: 'us', timeFormat: '12h' }),
+}));
+
import { VersionCompare } from '~/components/collab/VersionCompare';
import { VersionHistoryPanel } from '~/components/collab/VersionHistoryPanel';
import type { FindingDiff } from '~/lib/collab/snapshot-diff';
From 4f58532584ed900d66e2d1ce60a28775cf6fe957 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 16:30:15 +0800
Subject: [PATCH 057/111] test(e2e): scope the People 'Primary' assertions now
that reports have their own
The per-report card added by the reports work renders a Primary badge on the same
page, so two page-wide exact-text matches for 'Primary' started resolving to two
nodes. Asserted as relationships instead: the badge in the person's own row, and
the count taken inside the people-groups container. Note people-group-client is on
the group HEADING -- its rows are siblings, so scoping to that testid finds nothing.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
tests/e2e/people-role-profiles.spec.ts | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/tests/e2e/people-role-profiles.spec.ts b/tests/e2e/people-role-profiles.spec.ts
index 9a1dcfc33..b446fc52b 100644
--- a/tests/e2e/people-role-profiles.spec.ts
+++ b/tests/e2e/people-role-profiles.spec.ts
@@ -152,7 +152,17 @@ test.describe.serial('People / Role Profiles (Plan 1B)', () => {
// Primary before this test ever opens the modal.
await expect(page.getByTestId('people-group-client')).toBeVisible();
await expect(page.getByText('Editor Seed Client')).toBeVisible();
- await expect(page.getByText('Primary', { exact: true })).toBeVisible();
+ // Asserted as a relationship, not as page-wide existence: the per-report card
+ // puts a second "Primary" badge on this same page, so a bare exact-text match
+ // resolves to two nodes. The pill sits in the same as the person's link —
+ // note `people-group-client` is on the group HEADING, whose rows are siblings,
+ // so scoping to that testid finds nothing.
+ await expect(
+ page
+ .getByRole('link', { name: 'Editor Seed Client' })
+ .locator('xpath=..')
+ .getByText('Primary', { exact: true }),
+ ).toBeVisible();
// IA-36 ⑬ — this used to 409 ("an inspection already has a primary
// client"). It no longer does. "Exactly one primary client" is now upheld
@@ -171,8 +181,14 @@ test.describe.serial('People / Role Profiles (Plan 1B)', () => {
await expect(page.getByRole('link', { name: SEARCH_CONTACT_CLIENT.name })).toBeVisible();
// The seat moved rather than duplicating: exactly one Primary badge, and
- // it is not on the incumbent any more.
- await expect(page.getByText('Primary', { exact: true })).toHaveCount(1);
+ // it is not on the incumbent any more. Counted inside the People card only —
+ // the per-report card carries its own "Primary" badge for the primary
+ // deliverable, which has nothing to do with who holds the client seat.
+ // `people-group-client` marks the group HEADING; its grandparent is the
+ // container holding every people group, which is the smallest node that
+ // provably contains all the badges and none of the report card's.
+ const peopleGroups = page.getByTestId('people-group-client').locator('xpath=../..');
+ await expect(peopleGroups.getByText('Primary', { exact: true })).toHaveCount(1);
// The incumbent stayed on the inspection, demoted to the company's
// co-client role — losing the seat must not mean losing access.
await expect(page.getByText('Editor Seed Client')).toBeVisible();
From a1627bea5631d845c20d9a447052546cd98b5abd Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 16:52:25 +0800
Subject: [PATCH 058/111] feat(events): make results_received notify
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`results_received` was a dead state: it existed in the schema, in the API's
Zod enum, and in EventService, which wrote `results_received_at` and told
nobody — the automation trigger list had only `event.created` and
`event.completed`. The single moment a radon client has been waiting 48 hours
for notified no one, while "we completed the pickup" did.
Adds the `event.results_received` trigger (type-layer enum only, no DDL) with
client and buyer's-agent seeds on both seed paths, email and SMS, plus the two
notification classes the send boundary needs to name them.
It fires through the ordinary automation fan-out rather than the hand-rolled
pre-insert the reminder/follow-up paths use: those exist only because their
send time is COMPUTED, and they pay for it by addressing one recipient on one
channel. Results are "now", so every rule, channel and recipient applies.
TriggerContext gains an optional `eventId` so the delivered copy can name the
event type and a retried transition dedupes on uq_automation_logs_event
instead of notifying the client twice.
Also fixes the existing follow-up seed's copy, which pointed the client at
"your inspection report", singular — the wrong document once an order carries
more than one deliverable.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
app/routes/settings-automations.tsx | 1 +
messages/en/labels.json | 1 +
messages/es-419/labels.json | 1 +
server/data/automation-seeds.ts | 32 ++++-
server/lib/db/schema/inspection/automation.ts | 8 ++
server/lib/integration/standalone.ts | 7 +
.../lib/notifications/automation-classes.ts | 2 +
server/lib/notifications/classes.ts | 5 +
server/lib/validations/automation.schema.ts | 3 +
server/services/automation/shared.ts | 11 ++
server/services/automation/trigger.ts | 12 +-
server/services/event.service.ts | 41 +++++-
.../calendar/event-results-received.spec.ts | 135 ++++++++++++++++++
tests/unit/notifications/classes.spec.ts | 1 +
14 files changed, 253 insertions(+), 7 deletions(-)
create mode 100644 tests/unit/calendar/event-results-received.spec.ts
diff --git a/app/routes/settings-automations.tsx b/app/routes/settings-automations.tsx
index f8890ff14..53befb806 100644
--- a/app/routes/settings-automations.tsx
+++ b/app/routes/settings-automations.tsx
@@ -52,6 +52,7 @@ export const TRIGGER_LABELS: Record = {
get "agreement.expired"() { return m.label_trigger_agreement_expired(); },
get "event.created"() { return m.label_trigger_event_created(); },
get "event.completed"() { return m.label_trigger_event_completed(); },
+ get "event.results_received"() { return m.label_trigger_event_results_received(); },
get "booking.received"() { return m.label_trigger_booking_received(); },
get "inspection.completed"() { return m.label_trigger_inspection_completed(); },
};
diff --git a/messages/en/labels.json b/messages/en/labels.json
index 897615bb3..dd90f3697 100644
--- a/messages/en/labels.json
+++ b/messages/en/labels.json
@@ -96,6 +96,7 @@
"label_trigger_agreement_expired": "Agreement expired",
"label_trigger_event_created": "Event created",
"label_trigger_event_completed": "Event completed",
+ "label_trigger_event_results_received": "Event results received",
"label_cap_view_communication": "View sent messages & notices",
"label_trigger_booking_received": "Booking received",
"label_trigger_inspection_completed": "Inspection completed"
diff --git a/messages/es-419/labels.json b/messages/es-419/labels.json
index e52a43a36..499518f6b 100644
--- a/messages/es-419/labels.json
+++ b/messages/es-419/labels.json
@@ -96,6 +96,7 @@
"label_trigger_agreement_expired": "Acuerdo vencido",
"label_trigger_event_created": "Evento creado",
"label_trigger_event_completed": "Evento completado",
+ "label_trigger_event_results_received": "Resultados del evento recibidos",
"label_cap_view_communication": "Ver mensajes y avisos enviados",
"label_trigger_booking_received": "Reserva recibida",
"label_trigger_inspection_completed": "Inspección completada"
diff --git a/server/data/automation-seeds.ts b/server/data/automation-seeds.ts
index f65f9b704..9451b6d96 100644
--- a/server/data/automation-seeds.ts
+++ b/server/data/automation-seeds.ts
@@ -202,7 +202,37 @@ export const AUTOMATION_SEEDS = [
recipientRoleKey: 'client' as const,
delayMinutes: 0,
subjectTemplate: '{{event_type_name}} results — {{property_address}}',
- bodyTemplate: 'Hi {{client_name}},
The results for your {{event_type_name}} at {{property_address}} are now available in your inspection report.
— {{company_name}}
',
+ // Names the report the results belong to. "your inspection report",
+ // singular, is the wrong document once an order carries more than one
+ // deliverable — the radon numbers are in the radon report, and pointing
+ // the client at the standard report sends them looking for something
+ // that is not there.
+ bodyTemplate: 'Hi {{client_name}},
The results for your {{event_type_name}} at {{property_address}} are now available in your {{event_type_name}} report.
— {{company_name}}
',
+ isDefault: true,
+ },
+ // The lab result ARRIVING. Fires off `event.results_received`, which the
+ // office marks days after the pickup was completed — a different moment,
+ // a different actor, and the one the client is actually waiting on.
+ {
+ name: 'Event Results Received',
+ trigger: 'event.results_received' as const,
+ recipientKind: 'role' as const,
+ recipientRoleKey: 'client' as const,
+ delayMinutes: 0,
+ subjectTemplate: 'Your {{event_type_name}} results are in — {{property_address}}',
+ bodyTemplate: 'Hi {{client_name}},
The results for your {{event_type_name}} at {{property_address}} have arrived and are now in your {{event_type_name}} report.
View the report
— {{company_name}}
',
+ smsBody: '{{company_name}}: your {{event_type_name}} results for {{property_address}} are in: {{report_url}} Reply STOP to opt out; questions? call {{company_phone}}',
+ isDefault: true,
+ },
+ {
+ name: "Event Results Received (Buyer's Agent)",
+ trigger: 'event.results_received' as const,
+ recipientKind: 'role' as const,
+ recipientRoleKey: 'buyer_agent' as const,
+ delayMinutes: 0,
+ subjectTemplate: '{{event_type_name}} results are in — {{property_address}}',
+ bodyTemplate: 'Hello,
The results for the {{event_type_name}} at {{property_address}} have arrived and are now in the {{event_type_name}} report.
View the report
— {{company_name}}
',
+ smsBody: '{{company_name}}: the {{event_type_name}} results for {{property_address}} are in: {{report_url}} Reply STOP to opt out; questions? call {{company_phone}}',
isDefault: true,
},
// Track J (#122) — post-delivery follow-up. One day after the report is
diff --git a/server/lib/db/schema/inspection/automation.ts b/server/lib/db/schema/inspection/automation.ts
index 926c5d59b..9f279d5c9 100644
--- a/server/lib/db/schema/inspection/automation.ts
+++ b/server/lib/db/schema/inspection/automation.ts
@@ -18,6 +18,14 @@ export const automations = sqliteTable('automations', {
'agreement.signer_signed',
'agreement.viewed', 'agreement.declined', 'agreement.expired',
'event.created', 'event.completed',
+ // `event.results_received` is the lab result ARRIVING, which is the
+ // moment a radon client has been waiting 48 hours for. It is not
+ // `event.completed`: completing the pickup is the inspector's work
+ // finishing, and the sample only reaches the lab afterwards. The
+ // status was already writable (`inspection_events.results_received_at`)
+ // with no trigger to hang a rule on, so the single most important
+ // moment in a radon job notified nobody. Type-layer only — no DDL.
+ 'event.results_received',
// B3 — two events that raised a hard-coded staff alert but had no
// trigger to hang a rule on. `booking.received` is NOT
// `inspection.created`: a booking is a stranger arriving through
diff --git a/server/lib/integration/standalone.ts b/server/lib/integration/standalone.ts
index 02cf58de9..0a93b8e1f 100644
--- a/server/lib/integration/standalone.ts
+++ b/server/lib/integration/standalone.ts
@@ -97,6 +97,13 @@ async function seedDefaultAutomations(db: D1Database, tenantId: string): Promise
['invoice.created', 'client', 'Invoice / Payment Request', 'Invoice for your inspection — {{property_address}}', 'Hi {{client_name}},
An invoice has been created for your inspection at {{property_address}} .
View & Pay Invoice
— {{company_name}}
', 1, null],
['payment.received', null, 'Payment Received (Inspector)', 'Payment received — {{property_address}}', 'Payment has been received for the inspection at {{property_address}} (client: {{client_name}}).
— {{company_name}}
', 1, null],
['payment.received', 'client', 'Payment Received (Client Receipt)', 'Receipt: payment received — {{property_address}}', 'Hi {{client_name}},
Thank you — your payment for the inspection at {{property_address}} has been received.
— {{company_name}}
', 1, null],
+ // The lab result arriving. Names kept BYTE-IDENTICAL to the matching
+ // AUTOMATION_SEEDS rows: ensureSeeds dedupes on (name, trigger), and
+ // automation-classes.ts keys its notification class on the same pair —
+ // a standalone-only name would re-seed the rule twice and leave both
+ // copies unclassifiable.
+ ['event.results_received', 'client', 'Event Results Received', 'Your {{event_type_name}} results are in — {{property_address}}', 'Hi {{client_name}},
The results for your {{event_type_name}} at {{property_address}} have arrived and are now in your {{event_type_name}} report.
View the report
— {{company_name}}
', 1, '{{company_name}}: your {{event_type_name}} results for {{property_address}} are in: {{report_url}} Reply STOP to opt out; questions? call {{company_phone}}'],
+ ['event.results_received', 'buyer_agent', "Event Results Received (Buyer's Agent)", '{{event_type_name}} results are in — {{property_address}}', 'Hello,
The results for the {{event_type_name}} at {{property_address}} have arrived and are now in the {{event_type_name}} report.
View the report
— {{company_name}}
', 1, '{{company_name}}: the {{event_type_name}} results for {{property_address}} are in: {{report_url}} Reply STOP to opt out; questions? call {{company_phone}}'],
['report.published', 'client', 'Post-inspection follow-up', 'Following up on your inspection — {{property_address}}', 'Hi {{client_name}},
We hope your inspection report for {{property_address}} was helpful. If anything raised a question, just reply — we are happy to help.
— {{company_name}}
', 1, null],
['report.published', 'client', 'Review request', 'How did we do? — {{property_address}}', 'Hi {{client_name}},
Thanks for choosing us for your inspection at {{property_address}} . A short review helps other homebuyers find us:
Leave a review
— {{company_name}}
', 0, null], // active=0: inactive until review_url configured
];
diff --git a/server/lib/notifications/automation-classes.ts b/server/lib/notifications/automation-classes.ts
index f76707490..46d796d5b 100644
--- a/server/lib/notifications/automation-classes.ts
+++ b/server/lib/notifications/automation-classes.ts
@@ -46,6 +46,8 @@ const CLASS_BY_SEED: Record = {
"report.amended::Report Updated (Buyer's Agent)": 'report-amended-buyers-agent',
'event.created::Event Reminder (24h before)': 'event-reminder',
'event.completed::Event Follow-up (results ready)': 'event-followup',
+ 'event.results_received::Event Results Received': 'event-results-received',
+ "event.results_received::Event Results Received (Buyer's Agent)": 'event-results-received-buyers-agent',
'report.published::Post-inspection follow-up': 'post-inspection-followup',
'report.published::Review request': 'review-request',
diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts
index 2e536ef79..4fe70fc0c 100644
--- a/server/lib/notifications/classes.ts
+++ b/server/lib/notifications/classes.ts
@@ -188,6 +188,11 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [
{ id: 'report-amended-buyers-agent', label: 'A report you follow was updated', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] },
{ id: 'event-reminder', label: 'Reminder before your appointment', category: 'transactional', required: false, channels: ['email'], audience: ['client'] },
{ id: 'event-followup', label: 'Your results are ready', category: 'transactional', required: false, channels: ['email'], audience: ['client'] },
+ // Distinct from `event-followup`, which is timed off the pickup and says
+ // "we expect results by now". This one fires when the lab result actually
+ // landed, so a recipient who muted the estimate can still be told the fact.
+ { id: 'event-results-received', label: 'Your results have arrived', category: 'transactional', required: false, channels: ['email', 'sms'], audience: ['client'] },
+ { id: 'event-results-received-buyers-agent', label: 'Results you follow have arrived', category: 'transactional', required: false, channels: ['email', 'sms'], audience: ['agent'] },
{ id: 'post-inspection-followup', label: 'Following up after your inspection', category: 'transactional', required: false, channels: ['email'], audience: ['client'] },
{ id: 'review-request', label: 'How did we do?', category: 'marketing', required: false, channels: ['email'], audience: ['client'] },
diff --git a/server/lib/validations/automation.schema.ts b/server/lib/validations/automation.schema.ts
index c65850f0b..290d684ee 100644
--- a/server/lib/validations/automation.schema.ts
+++ b/server/lib/validations/automation.schema.ts
@@ -7,6 +7,9 @@ const AUTOMATION_TRIGGERS = [
'agreement.signer_signed',
'agreement.viewed', 'agreement.declined', 'agreement.expired',
'event.created', 'event.completed',
+ // The lab result arriving — see the schema comment: not the same event as
+ // the pickup being completed.
+ 'event.results_received',
// B3 — see the schema comment: a booking is not any inspection creation,
// and completing an inspection is not publishing its report.
'booking.received', 'inspection.completed',
diff --git a/server/services/automation/shared.ts b/server/services/automation/shared.ts
index eb96c1349..1b812dd4b 100644
--- a/server/services/automation/shared.ts
+++ b/server/services/automation/shared.ts
@@ -40,6 +40,17 @@ export interface TriggerContext {
* the first. Absent for every non-report trigger.
*/
reportId?: string;
+ /**
+ * The `inspection_events` row this firing is about, when there is one.
+ *
+ * Two things depend on it and neither has another source: the delivered
+ * copy names the event type ({{event_type_name}}, resolved from the log's
+ * `event_id` in deliver-email.ts), and a retried status transition conflicts
+ * on `uq_automation_logs_event` instead of notifying the client twice —
+ * which holds only because these logs also carry an automation_id (see that
+ * index's comment). Absent for every trigger that is not about one visit.
+ */
+ eventId?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/server/services/automation/trigger.ts b/server/services/automation/trigger.ts
index 089e5aab6..47583b0f4 100644
--- a/server/services/automation/trigger.ts
+++ b/server/services/automation/trigger.ts
@@ -95,9 +95,15 @@ export function AutomationTrigger {
+ try {
+ const { AutomationService } = await import('./automation.service');
+ await new AutomationService(this.db).trigger({
+ tenantId, inspectionId,
+ triggerEvent: 'event.results_received',
+ companyName: '', reportBaseUrl: '',
+ // Carries the visit through to delivery: the copy names the
+ // event type from it, and a retry dedupes on it.
+ eventId,
+ });
+ } catch (err) {
+ logger.error('automation trigger failed', { event: 'event.results_received', eventId },
+ err instanceof Error ? err : undefined);
}
}
diff --git a/tests/unit/calendar/event-results-received.spec.ts b/tests/unit/calendar/event-results-received.spec.ts
new file mode 100644
index 000000000..e527177c2
--- /dev/null
+++ b/tests/unit/calendar/event-results-received.spec.ts
@@ -0,0 +1,135 @@
+// @vitest-environment node
+/**
+ * `results_received` used to be a dead state.
+ *
+ * It existed in the schema, in the API's Zod enum, and in EventService, which
+ * wrote `results_received_at` and told nobody — the automation trigger list had
+ * only `event.created` and `event.completed`. So the single moment a radon
+ * client has been waiting 48 hours for notified no one, while "we completed the
+ * pickup" did.
+ *
+ * These specs assert the two halves of that fix separately, because a trigger
+ * that fires on BOTH transitions would pass a test that only looked at the
+ * results one: completing the pickup is not the lab result arriving.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { eq } from 'drizzle-orm';
+import { EventService } from '../../../server/services/event.service';
+import { PeopleService } from '../../../server/services/people.service';
+import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+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-000000000098';
+const CLIENT = 'contact-client-rr';
+const INSP = 'insp-event-rr';
+
+describe('EventService — event.results_received', () => {
+ let svc: EventService;
+ let testDb: BetterSQLite3Database;
+ let eventTypeId: string;
+ const roleProfileId = (key: string) => `crp_${TENANT}_${key}`;
+
+ /**
+ * Which TRIGGERS actually fired for an inspection, read back the way the
+ * product does it — a queued `automation_logs` row, resolved through the
+ * rule that produced it. Asserting on the rows alone would pass for a log
+ * queued by the wrong rule entirely.
+ */
+ async function firedTriggers(inspectionId: string): Promise {
+ const logs = await testDb.select().from(schema.automationLogs)
+ .where(eq(schema.automationLogs.inspectionId, inspectionId)).all();
+ const rules = await testDb.select().from(schema.automations).all();
+ const triggerById = new Map(rules.map(r => [r.id as string, r.trigger as string]));
+ return logs
+ .map(l => triggerById.get(l.automationId as string))
+ .filter((t): t is string => Boolean(t));
+ }
+
+ 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);
+ svc = new EventService({} as D1Database);
+
+ await testDb.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await seedRoleProfiles(testDb, TENANT, new Date(1));
+ await testDb.insert(schema.contacts).values({
+ id: CLIENT, tenantId: TENANT, type: 'client', name: 'Jane Client',
+ email: 'jane@example.com', phone: null, createdAt: new Date(),
+ });
+ await testDb.insert(schema.inspections).values({
+ id: INSP, tenantId: TENANT, propertyAddress: '1 Main St',
+ clientName: null, clientEmail: null, clientPhone: null,
+ date: '2026-08-01', status: 'confirmed', paymentStatus: 'unpaid', price: 0,
+ agreementRequired: false, paymentRequired: false, createdAt: new Date(),
+ });
+ await new PeopleService({ DB: {} as D1Database })
+ .addPerson(TENANT, INSP, CLIENT, roleProfileId('client'));
+
+ await svc.bulkSeed(TENANT);
+ eventTypeId = (await svc.listEventTypes(TENANT))[0].id as string;
+
+ // Both rules exist up front, so "did not fire" below is a statement
+ // about the trigger and not about a missing rule.
+ await testDb.insert(schema.automations).values([
+ {
+ id: 'auto-results-received', tenantId: TENANT, name: 'Results Received',
+ trigger: 'event.results_received', recipientKind: 'role',
+ recipientRoleProfileId: roleProfileId('client'), delayMinutes: 0,
+ subjectTemplate: 'x', bodyTemplate: 'x', active: true, createdAt: new Date(),
+ },
+ {
+ id: 'auto-followup', tenantId: TENANT, name: 'Followup',
+ trigger: 'event.completed', recipientKind: 'role',
+ recipientRoleProfileId: roleProfileId('client'), delayMinutes: 0,
+ subjectTemplate: 'x', bodyTemplate: 'x', active: true, createdAt: new Date(),
+ },
+ ]);
+ });
+
+ async function createEvent() {
+ return svc.createEvent(TENANT, INSP, {
+ eventTypeId, durationMin: 60,
+ scheduledAt: new Date(Date.now() + 7 * 86_400_000),
+ });
+ }
+
+ it('fires event.results_received when results are marked received', async () => {
+ const event = await createEvent();
+ await svc.updateEventStatus(TENANT, event.id, 'results_received');
+ expect(await firedTriggers(INSP)).toContain('event.results_received');
+ });
+
+ it('does not fire it on completion', async () => {
+ // Completing the pickup is not the same as the lab result arriving —
+ // the sample only reaches the lab afterwards.
+ const event = await createEvent();
+ await svc.updateEventStatus(TENANT, event.id, 'completed');
+ const fired = await firedTriggers(INSP);
+ expect(fired).toContain('event.completed');
+ expect(fired).not.toContain('event.results_received');
+ });
+
+ it('stamps the visit on the queued log so the copy can name the event type', async () => {
+ // {{event_type_name}} is resolved from automation_logs.event_id at
+ // delivery (deliver-email.ts). Without the stamp the client is told
+ // "your results are in".
+ const event = await createEvent();
+ await svc.updateEventStatus(TENANT, event.id, 'results_received');
+ const logs = await testDb.select().from(schema.automationLogs)
+ .where(eq(schema.automationLogs.automationId, 'auto-results-received')).all();
+ expect(logs).toHaveLength(1);
+ expect(logs[0].eventId).toBe(event.id);
+ expect(logs[0].recipient).toBe('jane@example.com');
+ });
+});
diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts
index 476dabe6a..343490ebd 100644
--- a/tests/unit/notifications/classes.spec.ts
+++ b/tests/unit/notifications/classes.spec.ts
@@ -65,6 +65,7 @@ const RECIPIENT_MAY_MUTE = [
'inspection-reminder', 'inspection-cancelled', 'report-amended',
'report-ready-listing-agent', 'booking-confirmation-buyers-agent',
'report-amended-buyers-agent', 'event-reminder', 'event-followup',
+ 'event-results-received', 'event-results-received-buyers-agent',
'post-inspection-followup', 'review-request',
];
From 7dac07a7e5564b553e45deb9981e11c6d7063af5 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 17:09:23 +0800
Subject: [PATCH 059/111] feat(events): make the follow-up delay per event type
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 72-hour follow-up delay was a constant in EventService. That is a radon
answer — sampling is a 48-hour standard and the lab takes its own time — and it
is wrong for a sewer scope, whose results exist the moment the camera comes
out, where a follow-up three days later tells the client something they already
knew.
Adds `event_types.follow_up_delay_hours`, appended at table end, defaulting to
the 72 hours that used to be hard-coded so nothing moves for any existing
tenant on deploy. Reachable through both event-type CRUD surfaces (the app API
and the admin/MCP one) — a column no caller can set would be a setting in name
only.
Zero is a legitimate value, so the read uses `??` and neither schema rejects
it; the specs fail against `||`, which is the trap this shape invites.
Migration 0035, local-applied; `db:check` clean at 87/87 tables, migchain
intact at 36 snapshots. The file-size baseline moves for admin-settings.ts,
which the two schema fields push 6 lines past its recorded size.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
migrations/0035_awesome_ben_grimm.sql | 1 +
migrations/meta/0035_snapshot.json | 10354 ++++++++++++++++
migrations/meta/_journal.json | 7 +
scripts/file-size-baseline.json | 2 +-
server/api/admin/admin-settings.ts | 6 +
server/api/events.ts | 8 +
server/lib/db/schema/inspection/automation.ts | 12 +
server/services/event.service.ts | 42 +-
.../calendar/event-followup-delay.spec.ts | 128 +
9 files changed, 10555 insertions(+), 5 deletions(-)
create mode 100644 migrations/0035_awesome_ben_grimm.sql
create mode 100644 migrations/meta/0035_snapshot.json
create mode 100644 tests/unit/calendar/event-followup-delay.spec.ts
diff --git a/migrations/0035_awesome_ben_grimm.sql b/migrations/0035_awesome_ben_grimm.sql
new file mode 100644
index 000000000..36d2c0f9f
--- /dev/null
+++ b/migrations/0035_awesome_ben_grimm.sql
@@ -0,0 +1 @@
+ALTER TABLE `event_types` ADD `follow_up_delay_hours` integer DEFAULT 72 NOT NULL;
\ No newline at end of file
diff --git a/migrations/meta/0035_snapshot.json b/migrations/meta/0035_snapshot.json
new file mode 100644
index 000000000..4d39a13eb
--- /dev/null
+++ b/migrations/meta/0035_snapshot.json
@@ -0,0 +1,10354 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "bfb151b9-1234-4ea4-8047-f60f1b7d6b16",
+ "prevId": "7da9b9a9-1cc7-493a-9cc6-51e7e23056ce",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "language_disclosure_version": {
+ "name": "language_disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "follow_up_delay_hours": {
+ "name": "follow_up_delay_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 72
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'us'"
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'12h'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index 4214cadf1..9354f69b6 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -246,6 +246,13 @@
"when": 1785805642371,
"tag": "0034_sweet_smiling_tiger",
"breakpoints": true
+ },
+ {
+ "idx": 35,
+ "version": "6",
+ "when": 1785834126085,
+ "tag": "0035_awesome_ben_grimm",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index fbb81d24b..1a40a3e41 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -10,7 +10,7 @@
"server/api/sms.ts": 843,
"app/components/portal/sections/ReportView.tsx": 813,
"app/routes/settings-communication.tsx": 777,
- "server/api/admin/admin-settings.ts": 742,
+ "server/api/admin/admin-settings.ts": 748,
"server/services/inspection.service.ts": 741,
"server/api/inspections/report-delivery.ts": 736,
"app/routes/settings-communication-templates.tsx": 731,
diff --git a/server/api/admin/admin-settings.ts b/server/api/admin/admin-settings.ts
index 605d81ef8..ee1ad5a97 100644
--- a/server/api/admin/admin-settings.ts
+++ b/server/api/admin/admin-settings.ts
@@ -236,6 +236,7 @@ const EventTypeRowSchema = z.object({
color: z.string().nullable().describe('Calendar color hex.'),
sortOrder: z.number().nullable().describe('Display sort order.'),
active: z.boolean().describe('Whether the type is selectable.'),
+ followUpDelayHours: z.number().nullable().describe('Hours after a visit is completed before its follow-up is queued. 0 = immediately.'),
});
const EventTypeCreateSchema = z.object({
name: z.string().min(1).describe('Display name.'),
@@ -244,6 +245,11 @@ const EventTypeCreateSchema = z.object({
defaultPriceCents: z.number().int().optional().describe('Default price in cents.'),
color: z.string().optional().describe('Calendar color hex.'),
sortOrder: z.number().int().optional().describe('Display sort order.'),
+ // 0 is legitimate — see the column comment. No `.default()`: the Update
+ // schema is this one `.partial()`, and a default would survive that and
+ // overwrite a configured delay on any patch that omits the field.
+ followUpDelayHours: z.number().int().min(0).max(8760).optional()
+ .describe('Hours after a visit is completed before its follow-up is queued. 0 = immediately.'),
}).openapi('EventTypeCreate');
const EventTypeUpdateSchema = EventTypeCreateSchema.partial().openapi('EventTypeUpdate');
const EventTypeIdParam = z.object({ id: z.string().describe('Event-type id.') });
diff --git a/server/api/events.ts b/server/api/events.ts
index 84e79a3dc..ee2a019aa 100644
--- a/server/api/events.ts
+++ b/server/api/events.ts
@@ -11,6 +11,14 @@ const TypeBody = z.object({
defaultPriceCents: z.number().int().min(0).default(0).describe('TODO describe defaultPriceCents field for the OpenInspection MCP integration'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#6366f1').describe('TODO describe color field for the OpenInspection MCP integration'),
sortOrder: z.number().int().min(0).default(0).describe('TODO describe sortOrder field for the OpenInspection MCP integration'),
+ // Hours after completion before the follow-up is queued. `.optional()` with
+ // NO `.default()`: this schema is re-used as `.partial()` for PUT, and a
+ // default survives `.partial()` — it would reset a tenant's configured delay
+ // to 72 on every update that omits the field. Omitted on create leaves the
+ // column default (72). `min(0)` because zero is a real setting: a sewer
+ // scope's results exist when the camera comes out.
+ followUpDelayHours: z.number().int().min(0).max(8760).optional()
+ .describe('Hours after a visit is completed before its follow-up is queued. 0 = immediately.'),
});
const EventBody = z.object({
diff --git a/server/lib/db/schema/inspection/automation.ts b/server/lib/db/schema/inspection/automation.ts
index 9f279d5c9..1538e66a8 100644
--- a/server/lib/db/schema/inspection/automation.ts
+++ b/server/lib/db/schema/inspection/automation.ts
@@ -189,6 +189,18 @@ export const eventTypes = sqliteTable('event_types', {
sortOrder: integer('sort_order').notNull().default(0),
active: integer('is_active', { mode: 'boolean' }).notNull().default(true),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ // How long after a visit is marked COMPLETED its follow-up notification is
+ // queued. This was a constant in EventService: reasonable for radon, where
+ // sampling is a 48-hour standard and the lab takes its own time, and wrong
+ // for a sewer scope, whose results exist the moment the camera comes out.
+ //
+ // ZERO IS A LEGITIMATE VALUE — "tell them when the visit ends" — so every
+ // read of this column must use `??`, never `||`, and no validation may
+ // reject it.
+ //
+ // Defaults to the 72 hours that used to be hard-coded, so nothing moves for
+ // any existing tenant on deploy. Appended at table end for D1 rebuild safety.
+ followUpDelayHours: integer('follow_up_delay_hours').notNull().default(72),
}, (t) => [
uniqueIndex('uq_event_types_tenant_slug').on(t.tenantId, t.slug),
]);
diff --git a/server/services/event.service.ts b/server/services/event.service.ts
index 8c1893089..57f2797b9 100644
--- a/server/services/event.service.ts
+++ b/server/services/event.service.ts
@@ -7,7 +7,13 @@ import { PeopleService } from './people.service';
const REMINDER_MIN_DELAY_MS = 5 * 60_000;
const REMINDER_LEAD_MS = 24 * 3600_000;
-const FOLLOWUP_DELAY_MS = 72 * 3600_000;
+/**
+ * Used only when an event's type cannot be resolved at all. The real value is
+ * `event_types.follow_up_delay_hours`, whose column default is this same 72 —
+ * so a tenant who has never touched the setting sees exactly the behaviour this
+ * constant used to impose on everyone.
+ */
+const DEFAULT_FOLLOWUP_DELAY_HOURS = 72;
export type EventStatus = 'scheduled' | 'completed' | 'results_received' | 'cancelled';
@@ -131,7 +137,9 @@ export class EventService {
const ev = await d.select().from(inspectionEvents)
.where(and(eq(inspectionEvents.id, id), eq(inspectionEvents.tenantId, tenantId))).get();
if (ev && status === 'completed') {
- await this.scheduleFollowupLog(tenantId, id, ev.inspectionId as string, Date.now());
+ await this.scheduleFollowupLog(
+ tenantId, id, ev.inspectionId as string, ev.eventTypeId as string, Date.now(),
+ );
} else if (ev) {
await this.fireResultsReceived(tenantId, id, ev.inspectionId as string);
}
@@ -197,7 +205,33 @@ export class EventService {
logger.info('Event reminder log queued', { tenantId, eventId, sendAt });
}
- private async scheduleFollowupLog(tenantId: string, eventId: string, inspectionId: string, completedAtMs: number) {
+ /**
+ * When a completed visit's follow-up should be sent.
+ *
+ * Per event type, because 72 hours is a radon answer: a sewer scope's
+ * results exist the moment the camera comes out, and a follow-up three days
+ * later is telling the client something they already knew. Zero is
+ * therefore a real setting and is read with `??` — `||` would silently
+ * restore the 72-hour default for exactly the case the column exists for.
+ *
+ * A missing event type falls back to the same 72 hours the column defaults
+ * to, so an orphaned event behaves as it did before this was configurable.
+ */
+ async followUpSendAt(tenantId: string, eventTypeId: string | null, completedAtMs: number): Promise {
+ let hours = DEFAULT_FOLLOWUP_DELAY_HOURS;
+ if (eventTypeId) {
+ const row = await drizzle(this.db)
+ .select({ followUpDelayHours: eventTypes.followUpDelayHours }).from(eventTypes)
+ .where(and(eq(eventTypes.id, eventTypeId), eq(eventTypes.tenantId, tenantId))).get();
+ hours = row?.followUpDelayHours ?? DEFAULT_FOLLOWUP_DELAY_HOURS;
+ }
+ return completedAtMs + hours * 3600_000;
+ }
+
+ private async scheduleFollowupLog(
+ tenantId: string, eventId: string, inspectionId: string,
+ eventTypeId: string | null, completedAtMs: number,
+ ) {
const d = drizzle(this.db);
const rule = await d.select().from(automations)
.where(and(eq(automations.tenantId, tenantId), eq(automations.trigger, 'event.completed' as never))).get();
@@ -207,7 +241,7 @@ export class EventService {
// inspection.clientEmail column (dropped, Task 13).
const client = await new PeopleService({ DB: this.db }).getPrimaryClient(tenantId, inspectionId);
if (!client?.email) return;
- const sendAt = completedAtMs + FOLLOWUP_DELAY_MS;
+ const sendAt = await this.followUpSendAt(tenantId, eventTypeId, completedAtMs);
await d.insert(automationLogs).values({
id: crypto.randomUUID(),
tenantId,
diff --git a/tests/unit/calendar/event-followup-delay.spec.ts b/tests/unit/calendar/event-followup-delay.spec.ts
new file mode 100644
index 000000000..a7f8a1a26
--- /dev/null
+++ b/tests/unit/calendar/event-followup-delay.spec.ts
@@ -0,0 +1,128 @@
+// @vitest-environment node
+/**
+ * The follow-up delay is a property of the EVENT TYPE, not of the codebase.
+ *
+ * 72 hours was hard-coded in EventService. That is a radon answer — sampling is
+ * a 48-hour standard and the lab takes its own time — and it is wrong for a
+ * sewer scope, whose results exist the moment the camera comes out.
+ *
+ * Two properties matter and they pull in opposite directions, so both are
+ * asserted: an untouched tenant must see exactly the old 72 hours on deploy,
+ * and a tenant who configures ZERO must get zero. `||` instead of `??` passes
+ * the first and silently fails the second, which is the whole trap.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { eq, and } from 'drizzle-orm';
+import { EventService } from '../../../server/services/event.service';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+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-000000000097';
+const hours = (n: number) => n * 3600_000;
+const COMPLETED_AT = Date.UTC(2026, 7, 3, 14, 0, 0);
+
+describe('EventService.followUpSendAt — per-event-type delay', () => {
+ let svc: EventService;
+ let testDb: BetterSQLite3Database;
+ let sewerEventTypeId: string;
+
+ const setFollowUpHours = (id: string, followUpDelayHours: number) =>
+ testDb.update(schema.eventTypes).set({ followUpDelayHours })
+ .where(and(eq(schema.eventTypes.id, id), eq(schema.eventTypes.tenantId, TENANT)));
+
+ 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);
+ svc = new EventService({} as D1Database);
+ await testDb.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await svc.bulkSeed(TENANT);
+ const types = await svc.listEventTypes(TENANT);
+ sewerEventTypeId = types.find(t => t.slug === 'sewer_scope')!.id as string;
+ });
+
+ it('defaults to the existing 72 hours when unset', async () => {
+ // Nothing moves for anyone on deploy. This is the whole safety property:
+ // the seeded types carry no explicit value, so they land on the column
+ // default, which is the constant that used to be in the code.
+ const at = await svc.followUpSendAt(TENANT, sewerEventTypeId, COMPLETED_AT);
+ expect(at - COMPLETED_AT).toBe(hours(72));
+ });
+
+ it('uses the event type value when configured', async () => {
+ await setFollowUpHours(sewerEventTypeId, 6);
+ const at = await svc.followUpSendAt(TENANT, sewerEventTypeId, COMPLETED_AT);
+ expect(at - COMPLETED_AT).toBe(hours(6));
+ });
+
+ it('treats zero as a real value, not as unset', async () => {
+ // A sewer scope's results exist when the camera comes out. Read with
+ // `||`, this returns 72 hours and the setting cannot express its most
+ // useful value at all.
+ await setFollowUpHours(sewerEventTypeId, 0);
+ expect(await svc.followUpSendAt(TENANT, sewerEventTypeId, COMPLETED_AT)).toBe(COMPLETED_AT);
+ });
+
+ it('falls back to 72 hours when the event type cannot be resolved', async () => {
+ expect(await svc.followUpSendAt(TENANT, 'no-such-type', COMPLETED_AT) - COMPLETED_AT).toBe(hours(72));
+ expect(await svc.followUpSendAt(TENANT, null, COMPLETED_AT) - COMPLETED_AT).toBe(hours(72));
+ });
+
+ it('never reads another tenant\'s setting', async () => {
+ await testDb.insert(schema.tenants).values({
+ id: 'other', name: 'Other', slug: 'other', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await setFollowUpHours(sewerEventTypeId, 0);
+ // Same row id, different tenant asking: the scoped read finds nothing
+ // and falls back rather than honouring a neighbour's configuration.
+ expect(await svc.followUpSendAt('other', sewerEventTypeId, COMPLETED_AT) - COMPLETED_AT)
+ .toBe(hours(72));
+ });
+
+ it('queues the follow-up log at the configured delay', async () => {
+ // The value has to reach the row the cron reads, not just the helper.
+ await testDb.insert(schema.contacts).values({
+ id: 'contact-followup', tenantId: TENANT, type: 'client', name: 'Jane Client',
+ email: 'jane@example.com', phone: null, createdAt: new Date(),
+ });
+ await testDb.insert(schema.inspections).values({
+ id: 'insp-followup', tenantId: TENANT, propertyAddress: '1 Main St',
+ clientName: null, clientEmail: null, clientPhone: null,
+ date: '2026-08-01', status: 'confirmed', paymentStatus: 'unpaid', price: 0,
+ agreementRequired: false, paymentRequired: false, createdAt: new Date(),
+ });
+ const { seedRoleProfiles } = await import('../../../server/services/seed/seed-role-profiles');
+ await seedRoleProfiles(testDb, TENANT, new Date(1));
+ const { PeopleService } = await import('../../../server/services/people.service');
+ await new PeopleService({ DB: {} as D1Database })
+ .addPerson(TENANT, 'insp-followup', 'contact-followup', `crp_${TENANT}_client`);
+ await testDb.insert(schema.automations).values({
+ id: 'auto-followup-delay', tenantId: TENANT, name: 'Followup', trigger: 'event.completed',
+ recipientKind: 'role', recipientRoleProfileId: `crp_${TENANT}_client`, delayMinutes: 0,
+ subjectTemplate: 'x', bodyTemplate: 'x', active: true, createdAt: new Date(),
+ });
+ await setFollowUpHours(sewerEventTypeId, 0);
+
+ const before = Date.now();
+ const event = await svc.createEvent(TENANT, 'insp-followup', {
+ eventTypeId: sewerEventTypeId, durationMin: 60,
+ scheduledAt: new Date(Date.now() + 7 * 86_400_000),
+ });
+ await svc.updateEventStatus(TENANT, event.id, 'completed');
+
+ const log = await testDb.select().from(schema.automationLogs)
+ .where(eq(schema.automationLogs.automationId, 'auto-followup-delay')).get();
+ // Zero delay: "now", not three days from now.
+ expect(new Date(log!.sendAt as Date).getTime()).toBeLessThan(before + hours(1));
+ });
+});
From 43552d380941896a012062e462faa1d307c2f3ed Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 18:14:04 +0800
Subject: [PATCH 060/111] feat(events): give the inspection hub the visits that
make up the job
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`inspection_events` has had a table, a full CRUD API and an automation
trigger per transition since it shipped, and no frontend anywhere — which
is why production holds zero rows. A radon job is a drop-off and a pickup
two days apart; without this card the second half of it lived in the
inspector's head and nowhere else.
The card sits beside Services on purpose: the visits ARE what the
services committed the company to turning up for. Its add picker leads
with the visit types the order's own services imply
(`services.default_event_type_slugs`), so booking a radon test proposes
its drop-off and its pickup in that order rather than asking anyone to
remember them. A slug with no surviving event type is simply not
proposed — the same rule `ServiceService.proposeEventsForService` applies
server-side.
Who may do what comes from ONE function, `visitActions(role, status)`,
read by the card and the row alike, so the page cannot grow a second
opinion. Completion is the FIELD's act: the inspector in the crawlspace
is the person who knows the visit is over, so every role is offered it.
Recording that lab results ARRIVED is an office act about a different
moment entirely — the sample reaching the lab is not the inspector
finishing — so it is owner/manager. This governs what the UI INVITES;
the server is where it is enforced.
The loader and both handlers live in `app/lib/inspection-visits.ts` and
the add picker in its own component, rather than inline: the route is
against its size ceiling, which is the same reason
`inspection-order-actions.ts` exists beside it. The route's baseline
still moves by 40 lines for the card's own wiring.
One trap worth naming, because it cost a browser pass to find: `api` is a
plain record of named clients, not a Hono proxy, so `api["event-types"]`
is `undefined` and — behind the optional chaining these best-effort
fetches use — degrades to an empty list SILENTLY. The symptom was a visit
row titled with a raw UUID. Everything hangs off `api.events`.
Twenty strings, both catalogues, es-419 at full parity.
---
.../inspector-portal/AddVisitModal.tsx | 135 ++++++++
.../inspector-portal/VisitsCard.tsx | 291 ++++++++++++++++++
.../inspector-portal/visits-card.test.tsx | 161 ++++++++++
app/lib/inspection-visits.ts | 133 ++++++++
app/routes/inspector-portal.tsx | 54 +++-
messages/en/inspections.json | 22 +-
messages/es-419/inspections.json | 22 +-
scripts/file-size-baseline.json | 2 +-
8 files changed, 810 insertions(+), 10 deletions(-)
create mode 100644 app/components/inspector-portal/AddVisitModal.tsx
create mode 100644 app/components/inspector-portal/VisitsCard.tsx
create mode 100644 app/components/inspector-portal/visits-card.test.tsx
create mode 100644 app/lib/inspection-visits.ts
diff --git a/app/components/inspector-portal/AddVisitModal.tsx b/app/components/inspector-portal/AddVisitModal.tsx
new file mode 100644
index 000000000..685d6edfa
--- /dev/null
+++ b/app/components/inspector-portal/AddVisitModal.tsx
@@ -0,0 +1,135 @@
+import { useState } from "react";
+import { Button, Modal } from "@core/shared-ui";
+import { fromLocalInputValue } from "~/lib/datetime-local";
+import type { VisitTypeOption } from "./VisitsCard";
+import { m } from "~/paraglide/messages";
+
+/**
+ * Choosing the next visit on a job.
+ *
+ * Its own file because `VisitsCard` crossed the 400-line ceiling with it
+ * inline, and because it is a genuinely separate decision: the card shows what
+ * has been committed to, this asks what to commit to next.
+ */
+export function AddVisitModal({
+ open,
+ visitTypes,
+ suggestedTypeIds,
+ submitting,
+ onClose,
+ onAdd,
+}: {
+ open: boolean;
+ visitTypes: VisitTypeOption[];
+ suggestedTypeIds: string[];
+ submitting: boolean;
+ onClose: () => void;
+ onAdd: (eventTypeId: string, scheduledAt: string, durationMin: number) => void;
+}) {
+ const [eventTypeId, setEventTypeId] = useState("");
+ const [when, setWhen] = useState("");
+ const [durationMin, setDurationMin] = useState(30);
+
+ if (!open) return null;
+
+ const active = visitTypes.filter((t) => t.active);
+ const suggested = active.filter((t) => suggestedTypeIds.includes(t.id));
+ const others = active.filter((t) => !suggestedTypeIds.includes(t.id));
+
+ // Picking a type seeds its own default duration, so the common case is no
+ // typing at all — a radon pickup is not a 30-minute job because 30 happened
+ // to be the control's initial value.
+ const choose = (id: string) => {
+ setEventTypeId(id);
+ setDurationMin(active.find((t) => t.id === id)?.defaultDurationMin ?? 30);
+ };
+
+ return (
+
+
+ {m.common_cancel()}
+
+ onAdd(eventTypeId, fromLocalInputValue(when), durationMin)}
+ >
+ {m.inspections_hub_visits_add()}
+
+ >
+ }
+ >
+ {active.length === 0 ? (
+ {m.inspections_hub_visits_types_empty()}
+ ) : (
+
+
+
+ {m.inspections_hub_visits_field_type()}
+
+ choose(e.target.value)}
+ className="mt-1 w-full h-10 px-3 rounded-md border border-ih-border bg-ih-bg-card text-ih-fg-1 text-[14px] font-medium focus:border-ih-primary focus:shadow-ih-focus outline-none"
+ data-testid="hub-add-visit-select"
+ >
+ {m.inspections_hub_visits_select_type()}
+ {suggested.length > 0 && (
+
+ {suggested.map((t) => (
+ {t.name}
+ ))}
+
+ )}
+ {others.length > 0 && (
+ 0
+ ? m.inspections_hub_visits_group_other()
+ : m.inspections_hub_visits_field_type()
+ }
+ >
+ {others.map((t) => (
+ {t.name}
+ ))}
+
+ )}
+
+
+
+
+ {m.inspections_hub_schedule_field_datetime()}
+
+ setWhen(e.target.value)}
+ className="mt-1 w-full h-10 px-3 rounded-md border border-ih-border bg-ih-bg-card text-ih-fg-1 text-[14px] font-medium focus:border-ih-primary focus:shadow-ih-focus outline-none"
+ data-testid="hub-add-visit-when"
+ />
+
+
+
+ {m.settings_event_types_duration_label()}
+
+ setDurationMin(Number(e.target.value) || 1)}
+ className="mt-1 w-full h-10 px-3 rounded-md border border-ih-border bg-ih-bg-card text-ih-fg-1 text-[14px] font-medium focus:border-ih-primary focus:shadow-ih-focus outline-none"
+ data-testid="hub-add-visit-duration"
+ />
+
+
+ )}
+
+ );
+}
diff --git a/app/components/inspector-portal/VisitsCard.tsx b/app/components/inspector-portal/VisitsCard.tsx
new file mode 100644
index 000000000..521a43ba0
--- /dev/null
+++ b/app/components/inspector-portal/VisitsCard.tsx
@@ -0,0 +1,291 @@
+import { useState } from "react";
+import { useFetcher } from "react-router";
+import { Card, Button, Pill } from "@core/shared-ui";
+import { BlockHeading } from "./BlockHeading";
+import { ConfirmDialog } from "~/components/ConfirmDialog";
+import { isAdminRole } from "~/lib/access";
+import { AddVisitModal } from "./AddVisitModal";
+import { m } from "~/paraglide/messages";
+import type { action } from "~/routes/inspector-portal";
+
+export type VisitStatus = "scheduled" | "completed" | "results_received" | "cancelled";
+
+/**
+ * One `inspection_events` row as the hub loader hands it over. The timestamps
+ * arrive as ISO strings (drizzle `timestamp_ms` → `Date` → JSON), which is why
+ * every one of them is formatted through the caller's `formatDate` rather than
+ * being sliced here.
+ */
+export interface VisitRowData {
+ id: string;
+ eventTypeId: string;
+ scheduledAt: string;
+ durationMin: number;
+ status: VisitStatus;
+ notes: string | null;
+ completedAt: string | null;
+ resultsReceivedAt: string | null;
+ cancelledAt: string | null;
+}
+
+export interface VisitTypeOption {
+ id: string;
+ name: string;
+ slug: string;
+ defaultDurationMin: number | null;
+ color: string | null;
+ active: boolean;
+}
+
+export type VisitAction = "complete" | "results" | "cancel";
+
+/**
+ * Which verbs a viewer may see on a visit in a given state.
+ *
+ * ONE function, read by both the card and the row, because "capabilities come
+ * from one function, not from a page". Completion is the FIELD's own act — the
+ * inspector standing in the crawlspace is the person who knows the visit is
+ * over, so it is offered to every role. Recording that the lab results ARRIVED
+ * is an office act about a different event entirely (the sample reaching the
+ * lab is not the inspector finishing), so it is owner/manager only.
+ *
+ * This governs what the UI INVITES. The server is where it is enforced; the two
+ * must not be allowed to disagree, which is why this is a pure function a test
+ * can pin rather than a set of inline `&&`s.
+ */
+export function visitActions(role: string, status: VisitStatus): VisitAction[] {
+ const admin = isAdminRole(role);
+ if (status === "scheduled") return admin ? ["complete", "cancel"] : ["complete"];
+ if (status === "completed") return admin ? ["results", "cancel"] : [];
+ // results_received and cancelled are terminal: there is nothing left to offer.
+ return [];
+}
+
+function statusLabel(status: VisitStatus): string {
+ if (status === "completed") return m.label_status_completed();
+ if (status === "results_received") return m.inspections_hub_visits_status_results();
+ if (status === "cancelled") return m.label_status_cancelled();
+ return m.label_status_scheduled();
+}
+
+function statusTone(status: VisitStatus): "sat" | "monitor" | "neutral" {
+ if (status === "results_received") return "sat";
+ if (status === "cancelled") return "neutral";
+ return "monitor";
+}
+
+/**
+ * One visit and the verbs its state allows.
+ *
+ * Exported on its own so the action matrix can be rendered in isolation: the
+ * question "does an inspector get offered results-received" is about this row,
+ * not about the page that contains it.
+ */
+export function VisitRow({
+ visit,
+ typeName,
+ role,
+ formatDate,
+ onAction,
+ busy = false,
+}: {
+ visit: VisitRowData;
+ typeName: string;
+ role: string;
+ formatDate: (iso: string) => string;
+ onAction: (action: VisitAction, visit: VisitRowData) => void;
+ busy?: boolean;
+}) {
+ const actions = visitActions(role, visit.status);
+
+ // The transition trail. `inspection_events` records WHEN each transition
+ // happened but not WHO made it — there is no actor column — so the row
+ // states the times it can prove and claims no attribution it cannot.
+ const trail = [
+ visit.completedAt && m.inspections_hub_visits_completed_on({ date: formatDate(visit.completedAt) }),
+ visit.resultsReceivedAt
+ && m.inspections_hub_visits_results_on({ date: formatDate(visit.resultsReceivedAt) }),
+ visit.cancelledAt && m.inspections_hub_visits_cancelled_on({ date: formatDate(visit.cancelledAt) }),
+ ].filter(Boolean) as string[];
+
+ return (
+
+
+
+ {typeName}
+ {statusLabel(visit.status)}
+
+
+ {formatDate(visit.scheduledAt)}
+ {trail.length > 0 && ` · ${trail.join(" · ")}`}
+
+
+
+ {actions.length > 0 && (
+
+ {actions.includes("complete") && (
+ onAction("complete", visit)}
+ className="text-[12px] font-bold text-ih-primary enabled:hover:underline disabled:opacity-40"
+ >
+ {m.inspections_hub_visits_action_complete()}
+
+ )}
+ {actions.includes("results") && (
+ onAction("results", visit)}
+ className="text-[12px] font-bold text-ih-primary enabled:hover:underline disabled:opacity-40"
+ >
+ {m.inspections_hub_visits_action_results()}
+
+ )}
+ {actions.includes("cancel") && (
+ onAction("cancel", visit)}
+ className="text-[12px] font-bold text-ih-fg-3 enabled:hover:text-ih-bad-fg enabled:hover:underline disabled:opacity-40"
+ >
+ {m.inspections_hub_visits_action_cancel()}
+
+ )}
+
+ )}
+
+ );
+}
+
+/**
+ * The visits that make up this job.
+ *
+ * `inspection_events` has existed, with a full API and an automation trigger per
+ * transition, and NO frontend — which is why production holds zero rows. A radon
+ * job is a drop-off and a pickup two days apart; without this card the second
+ * half of it lived only in the inspector's head.
+ *
+ * The add picker leads with the visit types the order's own services imply
+ * (`services.default_event_type_slugs`), so booking a radon test proposes its
+ * drop-off and its pickup instead of leaving the user to remember them. A slug
+ * with no surviving event type is simply not proposed — same rule the server's
+ * `proposeEventsForService` uses.
+ */
+export function VisitsCard({
+ visits,
+ visitTypes,
+ suggestedTypeIds,
+ role,
+ formatDate,
+}: {
+ visits: VisitRowData[];
+ visitTypes: VisitTypeOption[];
+ suggestedTypeIds: string[];
+ role: string;
+ formatDate: (iso: string) => string;
+}) {
+ const statusFetcher = useFetcher();
+ const addFetcher = useFetcher();
+ const [addOpen, setAddOpen] = useState(false);
+ const [cancelling, setCancelling] = useState(null);
+
+ const canManage = isAdminRole(role);
+ const busy = statusFetcher.state !== "idle" || addFetcher.state !== "idle";
+ const typeName = (id: string) => visitTypes.find((t) => t.id === id)?.name ?? id;
+
+ const error = [statusFetcher, addFetcher]
+ .map((f) => {
+ const d = f.state === "idle" ? f.data : undefined;
+ if (!d || !("ok" in d) || d.ok) return undefined;
+ return d.intent?.startsWith("visit-") ? d.error : undefined;
+ })
+ .find(Boolean);
+
+ const submitStatus = (visit: VisitRowData, status: VisitStatus) =>
+ statusFetcher.submit(
+ { intent: "visit-status", eventId: visit.id, status },
+ { method: "post" },
+ );
+
+ const handleAction = (verb: VisitAction, visit: VisitRowData) => {
+ if (verb === "complete") return submitStatus(visit, "completed");
+ if (verb === "results") return submitStatus(visit, "results_received");
+ setCancelling(visit);
+ };
+
+ return (
+
+
+
+ {visits.length === 0 ? (
+ {m.inspections_hub_visits_empty()}
+ ) : (
+
+ {visits.map((visit) => (
+
+ ))}
+
+ )}
+
+ {error && {error}
}
+
+ {canManage && (
+
+ setAddOpen(true)} disabled={busy}>
+ {m.inspections_hub_visits_add()}
+
+
+ )}
+
+ setAddOpen(false)}
+ onAdd={(eventTypeId, scheduledAt, durationMin) => {
+ addFetcher.submit(
+ {
+ intent: "visit-add",
+ eventTypeId,
+ scheduledAt,
+ durationMin: String(durationMin),
+ },
+ { method: "post" },
+ );
+ setAddOpen(false);
+ }}
+ />
+
+ {/* Never window.confirm: a cancelled visit is a commitment withdrawn
+ from somebody's calendar, so the question names it. */}
+ setCancelling(null)}
+ onConfirm={() => {
+ if (!cancelling) return;
+ submitStatus(cancelling, "cancelled");
+ setCancelling(null);
+ }}
+ />
+
+ );
+}
diff --git a/app/components/inspector-portal/visits-card.test.tsx b/app/components/inspector-portal/visits-card.test.tsx
new file mode 100644
index 000000000..465aba741
--- /dev/null
+++ b/app/components/inspector-portal/visits-card.test.tsx
@@ -0,0 +1,161 @@
+// @vitest-environment happy-dom
+/**
+ * `inspection_events` had a table, a full CRUD API and an automation trigger per
+ * transition — and no frontend, which is why production holds zero rows.
+ *
+ * The half of that worth pinning is not "does a list render" but WHO IS INVITED
+ * TO DO WHAT. Completing a visit is the field's own act: the inspector standing
+ * in the crawlspace is the person who knows it is over. Recording that the lab
+ * results ARRIVED is an office act about a different moment entirely — the
+ * sample reaching the lab is not the inspector finishing — and a card that
+ * offers it to an inspector is a card offering an action the server refuses.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { createRoutesStub } from "react-router";
+
+import {
+ VisitRow,
+ VisitsCard,
+ visitActions,
+ type VisitRowData,
+ type VisitTypeOption,
+} from "./VisitsCard";
+
+const TYPE: VisitTypeOption = {
+ id: "et-radon-pickup",
+ name: "Radon pickup",
+ slug: "radon_pickup",
+ defaultDurationMin: 20,
+ color: "#4a72ff",
+ active: true,
+};
+
+const SCHEDULED: VisitRowData = {
+ id: "ev1",
+ eventTypeId: TYPE.id,
+ scheduledAt: "2026-08-06T15:00:00.000Z",
+ durationMin: 20,
+ status: "scheduled",
+ notes: null,
+ completedAt: null,
+ resultsReceivedAt: null,
+ cancelledAt: null,
+};
+
+const COMPLETED: VisitRowData = {
+ ...SCHEDULED,
+ status: "completed",
+ completedAt: "2026-08-06T15:25:00.000Z",
+};
+
+const fmt = (iso: string) => iso.slice(0, 16);
+
+function renderRow(visit: VisitRowData, role: string) {
+ const Stub = createRoutesStub([
+ {
+ path: "/",
+ Component: () => (
+
+ ),
+ },
+ ]);
+ return render( );
+}
+
+function renderCard(props: Partial[0]> = {}) {
+ const Stub = createRoutesStub([
+ {
+ path: "/",
+ Component: () => (
+
+ ),
+ action: () => ({ ok: true }),
+ },
+ ]);
+ return render( );
+}
+
+describe("visitActions", () => {
+ it("offers completion to the field", () => {
+ expect(visitActions("inspector", "scheduled")).toContain("complete");
+ });
+ it("keeps results-received in the office", () => {
+ expect(visitActions("inspector", "completed")).not.toContain("results");
+ expect(visitActions("owner", "completed")).toContain("results");
+ expect(visitActions("manager", "completed")).toContain("results");
+ });
+ it("offers nothing on a terminal visit", () => {
+ expect(visitActions("owner", "results_received")).toEqual([]);
+ expect(visitActions("owner", "cancelled")).toEqual([]);
+ });
+});
+
+describe("VisitRow", () => {
+ it("offers the complete action to an inspector", () => {
+ renderRow(SCHEDULED, "inspector");
+ expect(screen.getByRole("button", { name: /complete/i })).toBeEnabled();
+ });
+
+ it("does not offer results-received to an inspector", () => {
+ // Office action, different actor. The gate is enforced server-side; this
+ // only checks the UI does not invite it.
+ renderRow(COMPLETED, "inspector");
+ expect(screen.queryByRole("button", { name: /results/i })).toBeNull();
+ });
+
+ it("offers results-received to a manager once the visit is completed", () => {
+ renderRow(COMPLETED, "manager");
+ expect(screen.getByRole("button", { name: /results/i })).toBeEnabled();
+ });
+
+ it("shows the transition trail it can prove", () => {
+ renderRow(COMPLETED, "owner");
+ expect(screen.getByTestId("hub-visit-row").textContent).toContain("2026-08-06T15:25");
+ });
+});
+
+describe("VisitsCard", () => {
+ it("says the inspection has no visits rather than rendering an empty list", () => {
+ renderCard({ visits: [] });
+ expect(screen.queryByTestId("hub-visits-list")).toBeNull();
+ expect(screen.getByText(/no visits/i)).toBeTruthy();
+ });
+
+ it("names the visit by its type", () => {
+ renderCard();
+ expect(screen.getByTestId("hub-visit-row").textContent).toContain("Radon pickup");
+ });
+
+ it("does not offer the add verb to an inspector", () => {
+ renderCard({ role: "inspector" });
+ expect(screen.queryByRole("button", { name: /add visit/i })).toBeNull();
+ });
+
+ it("leads the picker with the visit types this inspection's services imply", () => {
+ const other: VisitTypeOption = { ...TYPE, id: "et-sewer", name: "Sewer scope", slug: "sewer_scope" };
+ renderCard({ visitTypes: [other, TYPE], suggestedTypeIds: [TYPE.id] });
+ fireEvent.click(screen.getByRole("button", { name: /add visit/i }));
+ const groups = Array.from(document.querySelectorAll("optgroup")).map((g) => g.label);
+ expect(groups[0]).toMatch(/suggested/i);
+ const suggestedOptions = Array.from(
+ document.querySelectorAll("optgroup")[0].querySelectorAll("option"),
+ ).map((o) => o.textContent);
+ expect(suggestedOptions).toEqual(["Radon pickup"]);
+ });
+});
diff --git a/app/lib/inspection-visits.ts b/app/lib/inspection-visits.ts
new file mode 100644
index 000000000..de6eeee8f
--- /dev/null
+++ b/app/lib/inspection-visits.ts
@@ -0,0 +1,133 @@
+/* ------------------------------------------------------------------ */
+/* Inspection-hub VISIT read + write helpers (pure — no React) */
+/* ------------------------------------------------------------------ */
+
+/**
+ * `inspection_events` — the visits that make up a job. A radon test is a
+ * drop-off and a pickup two days apart; the table, its CRUD API and an
+ * automation trigger per transition all existed with no frontend, which is why
+ * production holds zero rows.
+ *
+ * Its own module rather than inline in the route: `inspector-portal.tsx` sits
+ * against its file-size ceiling, which is the same reason
+ * `inspection-order-actions.ts` exists beside it.
+ */
+
+import type { Api } from "~/lib/api-client.server";
+import { toActionResult } from "~/lib/inspector-portal-actions";
+import type {
+ VisitRowData,
+ VisitTypeOption,
+} from "~/components/inspector-portal/VisitsCard";
+import { m } from "~/paraglide/messages";
+
+/** A catalogue row as far as visit proposal is concerned. */
+export interface VisitProposalService {
+ id: string;
+ defaultEventTypeSlugs?: string[] | null;
+}
+
+export interface VisitsPayload {
+ visits: VisitRowData[];
+ visitTypes: VisitTypeOption[];
+ suggestedTypeIds: string[];
+}
+
+/**
+ * The visits on this inspection, the tenant's visit-type catalogue, and which
+ * of those types the order's own services imply.
+ *
+ * Everything hangs off `api.events` — the EventsApi client, mounted at `/api`.
+ * NOT `api.inspections` and NOT a bare `api["event-types"]`: `api` is a plain
+ * record of named clients, so an unknown key is `undefined` and, with the
+ * optional chaining this file uses for graceful degradation, resolves to an
+ * empty list SILENTLY. The visible symptom is a visit row titled with a raw
+ * UUID, because the name lookup had no types to look in.
+ *
+ * Best-effort throughout, like every other secondary fetch on the hub loader:
+ * the card degrades to an empty list rather than 500-ing a page whose primary
+ * payload already arrived.
+ */
+export async function loadVisits(
+ api: Api,
+ inspectionId: string,
+ bookedServiceIds: Set,
+ catalogRows: VisitProposalService[],
+): Promise {
+ const visitsGet = api.events?.inspections?.[":inspectionId"]?.events?.$get as unknown as
+ | ((args: { param: { inspectionId: string } }) => Promise)
+ | undefined;
+ const visitsRes = visitsGet
+ ? await visitsGet({ param: { inspectionId } }).catch(() => null)
+ : null;
+ const visits: VisitRowData[] =
+ visitsRes && visitsRes.ok
+ ? (((await visitsRes.json()) as { data?: VisitRowData[] }).data ?? [])
+ : [];
+
+ const typesGet = api.events?.["event-types"]?.$get as unknown as
+ | ((args?: unknown) => Promise)
+ | undefined;
+ const typesRes = typesGet ? await typesGet({}).catch(() => null) : null;
+ const visitTypes: VisitTypeOption[] =
+ typesRes && typesRes.ok
+ ? (((await typesRes.json()) as { data?: VisitTypeOption[] }).data ?? [])
+ : [];
+
+ // Which visit types THIS order's services imply. Resolved from slugs, and a
+ // slug with no surviving event type is silently dropped — the same rule
+ // `ServiceService.proposeEventsForService` applies server-side, so tidying
+ // up the event-type list shortens the proposal instead of breaking the page.
+ const suggestedSlugs = new Set(
+ catalogRows
+ .filter((s) => bookedServiceIds.has(s.id))
+ .flatMap((s) => s.defaultEventTypeSlugs ?? []),
+ );
+ const suggestedTypeIds = visitTypes
+ .filter((t) => suggestedSlugs.has(t.slug))
+ .map((t) => t.id);
+
+ return { visits, visitTypes, suggestedTypeIds };
+}
+
+export async function handleVisitAdd(
+ api: Api,
+ inspectionId: string,
+ formData: FormData,
+): Promise<{ ok: boolean; intent: "visit-add"; error: string | undefined }> {
+ const addVisit = api.events?.inspections?.[":inspectionId"]?.events?.$post as unknown as
+ | ((args: {
+ param: { inspectionId: string };
+ json: Record;
+ }) => Promise)
+ | undefined;
+ if (!addVisit) {
+ return { ok: false, intent: "visit-add", error: m.inspections_hub_error_visit_add() };
+ }
+ const res = await addVisit({
+ param: { inspectionId },
+ json: {
+ eventTypeId: String(formData.get("eventTypeId") ?? ""),
+ scheduledAt: String(formData.get("scheduledAt") ?? ""),
+ durationMin: Number(formData.get("durationMin") ?? 30),
+ },
+ });
+ return toActionResult(res, "visit-add", m.inspections_hub_error_visit_add());
+}
+
+export async function handleVisitStatus(
+ api: Api,
+ formData: FormData,
+): Promise<{ ok: boolean; intent: "visit-status"; error: string | undefined }> {
+ const setStatus = api.events?.events?.[":id"]?.$put as unknown as
+ | ((args: { param: { id: string }; json: { status: string } }) => Promise)
+ | undefined;
+ if (!setStatus) {
+ return { ok: false, intent: "visit-status", error: m.inspections_hub_error_visit_status() };
+ }
+ const res = await setStatus({
+ param: { id: String(formData.get("eventId") ?? "") },
+ json: { status: String(formData.get("status") ?? "") },
+ });
+ return toActionResult(res, "visit-status", m.inspections_hub_error_visit_status());
+}
diff --git a/app/routes/inspector-portal.tsx b/app/routes/inspector-portal.tsx
index 8b4437cf6..7c200b4f1 100644
--- a/app/routes/inspector-portal.tsx
+++ b/app/routes/inspector-portal.tsx
@@ -60,6 +60,8 @@ import {
import { ScheduleCard, type TeamMember } from "~/components/inspector-portal/ScheduleCard";
import { ServicesCard, type CatalogService } from "~/components/inspector-portal/ServicesCard";
import { ReportsCard, type ReportRow } from "~/components/inspector-portal/ReportsCard";
+import { VisitsCard } from "~/components/inspector-portal/VisitsCard";
+import { loadVisits, handleVisitAdd, handleVisitStatus } from "~/lib/inspection-visits";
import { OrderDetailsCard } from "~/components/inspector-portal/OrderDetailsCard";
import { InvoiceCard } from "~/components/inspector-portal/InvoiceCard";
import { GateToggle } from "~/components/inspector-portal/GateToggle";
@@ -193,6 +195,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
let canPublishCap = false;
let canViewCommunication = false;
let isAdmin = false;
+ // The raw role travels too: the Visits card decides its verbs through ONE
+ // function (`visitActions`) that takes a role, so the page cannot grow a
+ // second, divergent opinion about who may record lab results.
+ let role = "inspector";
const meGet = api.auth?.me?.$get as unknown as ((args?: unknown) => Promise) | undefined;
const meRes = meGet ? await meGet().catch(() => null) : null;
if (meRes && meRes.ok) {
@@ -201,7 +207,8 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
};
canPublishCap = publishCapFromMe(meBody);
canViewCommunication = viewCommunicationCapFromMe(meBody);
- isAdmin = isAdminRole(meBody.data?.user?.role ?? 'inspector');
+ role = meBody.data?.user?.role ?? 'inspector';
+ isAdmin = isAdminRole(role);
}
// Plan 1B Task 5 — editable People section: every contact/role pairing on
@@ -266,11 +273,15 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const catalogGet = api.services?.index?.$get as unknown as ((args?: unknown) => Promise) | undefined;
const catalogRes = catalogGet ? await catalogGet({}).catch(() => null) : null;
- const serviceCatalog: CatalogService[] = catalogRes && catalogRes.ok
- ? (((await catalogRes.json()) as { data?: Array<{ id: string; name: string; price: number; active?: boolean }> }).data ?? [])
- .filter((s) => s.active !== false)
- .map((s) => ({ id: s.id, name: s.name, price: s.price }))
+ // `defaultEventTypeSlugs` rides along: it is what makes the Visits card's add
+ // picker propose a radon test's drop-off AND its pickup instead of asking the
+ // user to remember that a radon job is two visits.
+ const catalogRows = catalogRes && catalogRes.ok
+ ? (((await catalogRes.json()) as {
+ data?: Array<{ id: string; name: string; price: number; active?: boolean; defaultEventTypeSlugs?: string[] | null }>;
+ }).data ?? []).filter((s) => s.active !== false)
: [];
+ const serviceCatalog: CatalogService[] = catalogRows.map((s) => ({ id: s.id, name: s.name, price: s.price }));
const brandingGet = api.adminBranding?.branding?.$get as unknown as ((args?: unknown) => Promise) | undefined;
const brandingRes = brandingGet ? await brandingGet({}).catch(() => null) : null;
@@ -295,9 +306,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
? (((await versionsRes.json()) as { data?: { versions?: ReportVersionRow[] } }).data?.versions ?? [])
: [];
+ // The visits that make up the job (`inspection_events`), the tenant's
+ // visit-type catalogue and the types this order's services imply. See
+ // `~/lib/inspection-visits` for why all three hang off `api.events`.
+ const { visits, visitTypes, suggestedTypeIds } = await loadVisits(
+ api,
+ id,
+ new Set((hub.services ?? []).map((s) => s.serviceId)),
+ catalogRows,
+ );
+
return {
hub, smsConsent, reinspectCandidates, canPublishCap, canViewCommunication, documents, people, roleProfiles, isAdmin, versions,
- members, serviceCatalog, referralSources,
+ members, serviceCatalog, referralSources, visits, visitTypes, suggestedTypeIds, role,
};
}
@@ -328,6 +349,12 @@ export async function action({ request, params, context }: Route.ActionArgs) {
if (intent === "unlock-report") return handleUnlockReport(api, id, formData);
if (intent === "relock-report") return handleRelockReport(api, id);
+ // The visits that make up the job. Both endpoints already existed with no
+ // caller — `inspection_events` shipped with an API, an automation trigger per
+ // transition, and no frontend at all, which is why production holds no rows.
+ if (intent === "visit-add") return handleVisitAdd(api, id, formData);
+ if (intent === "visit-status") return handleVisitStatus(api, formData);
+
if (intent === "request-payment") {
const res = await api.invoices["request-payment"].$post({
json: { inspectionId: id },
@@ -507,7 +534,7 @@ export function reportActions(
export default function InspectionHubPage() {
const {
hub, smsConsent, reinspectCandidates, canPublishCap, canViewCommunication, documents, people, roleProfiles, isAdmin, versions,
- members, serviceCatalog, referralSources,
+ members, serviceCatalog, referralSources, visits, visitTypes, suggestedTypeIds, role,
} = useLoaderData();
// `peopleCard` is the read-only getPeopleCard() projection (client/agents/
// inspector — still used for the header meta line + modal default emails);
@@ -811,6 +838,19 @@ export default function InspectionHubPage() {
price box. ------------------------------------------------- */}
+ {/* 3a. Visits — the job as it actually happens. A radon test is a
+ drop-off and a pickup two days apart; until this card existed the
+ second half of the job was in the inspector's head and nowhere
+ else. Sits beside Services on purpose: the visits ARE what the
+ services committed the company to turning up for. -------- */}
+ formatInspectionDateTime(iso, undefined, displayTz, fmt)}
+ />
+
{/* 3b. Reports — what gets DELIVERED. The order-wide report pill above
answers "is the report out"; with several deliverables on one order
that question no longer has one answer. ------------------- */}
diff --git a/messages/en/inspections.json b/messages/en/inspections.json
index 13a603aee..836f050e7 100644
--- a/messages/en/inspections.json
+++ b/messages/en/inspections.json
@@ -250,5 +250,25 @@
"inspections_hub_people_reset_confirm_restore": "{name} has no working link right now. This issues a fresh one — send the report again to deliver it.",
"inspections_hub_details_referred_by": "Referred by",
"inspections_hub_details_referred_by_placeholder": "Search any contact…",
- "inspections_hub_details_referred_by_clear": "Clear"
+ "inspections_hub_details_referred_by_clear": "Clear",
+ "inspections_hub_block_visits": "Visits",
+ "inspections_hub_visits_empty": "No visits are scheduled for this inspection.",
+ "inspections_hub_visits_add": "Add visit",
+ "inspections_hub_visits_add_title": "Add a visit",
+ "inspections_hub_visits_field_type": "Visit type",
+ "inspections_hub_visits_select_type": "Choose a visit type",
+ "inspections_hub_visits_group_suggested": "Suggested by this inspection's services",
+ "inspections_hub_visits_group_other": "Other visit types",
+ "inspections_hub_visits_types_empty": "No visit types are configured yet. Add one under Settings, Event types.",
+ "inspections_hub_visits_status_results": "Results received",
+ "inspections_hub_visits_action_complete": "Mark completed",
+ "inspections_hub_visits_action_results": "Log results received",
+ "inspections_hub_visits_action_cancel": "Cancel visit",
+ "inspections_hub_visits_completed_on": "Completed {date}",
+ "inspections_hub_visits_results_on": "Results {date}",
+ "inspections_hub_visits_cancelled_on": "Cancelled {date}",
+ "inspections_hub_visits_cancel_title": "Cancel this visit?",
+ "inspections_hub_visits_cancel_body": "{name} will be marked cancelled. It stays on the inspection so the history is not lost.",
+ "inspections_hub_error_visit_add": "Could not add the visit. Please try again.",
+ "inspections_hub_error_visit_status": "Could not update the visit. Please try again."
}
diff --git a/messages/es-419/inspections.json b/messages/es-419/inspections.json
index 082b4a29c..455b62a78 100644
--- a/messages/es-419/inspections.json
+++ b/messages/es-419/inspections.json
@@ -250,5 +250,25 @@
"inspections_hub_people_reset_confirm_restore": "{name} no tiene ningún enlace activo en este momento. Esto emite uno nuevo; envíe el informe de nuevo para entregarlo.",
"inspections_hub_details_referred_by": "Referido por",
"inspections_hub_details_referred_by_placeholder": "Buscar cualquier contacto…",
- "inspections_hub_details_referred_by_clear": "Borrar"
+ "inspections_hub_details_referred_by_clear": "Borrar",
+ "inspections_hub_block_visits": "Visitas",
+ "inspections_hub_visits_empty": "No hay visitas programadas para esta inspección.",
+ "inspections_hub_visits_add": "Agregar visita",
+ "inspections_hub_visits_add_title": "Agregar una visita",
+ "inspections_hub_visits_field_type": "Tipo de visita",
+ "inspections_hub_visits_select_type": "Elija un tipo de visita",
+ "inspections_hub_visits_group_suggested": "Sugeridas por los servicios de esta inspección",
+ "inspections_hub_visits_group_other": "Otros tipos de visita",
+ "inspections_hub_visits_types_empty": "Todavía no hay tipos de visita configurados. Agregue uno en Configuración, Tipos de evento.",
+ "inspections_hub_visits_status_results": "Resultados recibidos",
+ "inspections_hub_visits_action_complete": "Marcar como completada",
+ "inspections_hub_visits_action_results": "Registrar resultados recibidos",
+ "inspections_hub_visits_action_cancel": "Cancelar visita",
+ "inspections_hub_visits_completed_on": "Completada {date}",
+ "inspections_hub_visits_results_on": "Resultados {date}",
+ "inspections_hub_visits_cancelled_on": "Cancelada {date}",
+ "inspections_hub_visits_cancel_title": "¿Cancelar esta visita?",
+ "inspections_hub_visits_cancel_body": "{name} quedará marcada como cancelada. Permanece en la inspección para no perder el historial.",
+ "inspections_hub_error_visit_add": "No se pudo agregar la visita. Inténtelo de nuevo.",
+ "inspections_hub_error_visit_status": "No se pudo actualizar la visita. Inténtelo de nuevo."
}
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 1a40a3e41..be0a81861 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -1,6 +1,6 @@
{
"app/routes/inspection-edit.tsx": 2530,
- "app/routes/inspector-portal.tsx": 1218,
+ "app/routes/inspector-portal.tsx": 1258,
"server/services/inspection/inspection-core.service.ts": 1132,
"server/services/booking.service.ts": 972,
"server/services/inspection/inspection-report.service.ts": 952,
From e130c4b81c0bad8fa8faff457611bb3eb2ca56eb Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 18:17:24 +0800
Subject: [PATCH 061/111] feat(events): give the follow-up delay a control a
human can reach
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`event_types.follow_up_delay_hours` shipped with a column, a default and
both CRUD schemas, and no UI — Settings > Event types is the only screen
that edits an event type and it did not offer the field, so the setting
was API-only and in practice unsettable.
Zero is a legitimate value ("a sewer scope's results exist when the
camera comes out"), which makes the control's two failure modes
symmetrical and both easy to write by accident:
- a READ that treats 0 as unset. The list cell uses `??` and an
explicit `=== 0`, and renders "Immediately" rather than "0 h".
- a WRITE that treats "untouched" as 0. The form field holds a STRING,
not a number, because `Number("")` is 0: an emptied box is OMITTED
from the PATCH, and since the PUT schema is a `.partial()` an absent
key leaves the stored delay alone.
Both are pinned by tests confirmed to fail when the fix is removed —
`||` for the first, an unconditional key for the second.
The hint is always-on, against the usual rule, because the field's whole
subtlety is that 0 is a setting rather than a blank and neither a label
nor a placeholder can say that.
Five strings, in both catalogues; es-419 stays at full parity.
---
app/routes/settings-event-types.test.tsx | 103 +++++++++++++++++++++++
app/routes/settings-event-types.tsx | 49 +++++++++++
messages/en/settings-catalog.json | 7 +-
messages/es-419/settings-catalog.json | 7 +-
4 files changed, 164 insertions(+), 2 deletions(-)
create mode 100644 app/routes/settings-event-types.test.tsx
diff --git a/app/routes/settings-event-types.test.tsx b/app/routes/settings-event-types.test.tsx
new file mode 100644
index 000000000..7ed5ab7ff
--- /dev/null
+++ b/app/routes/settings-event-types.test.tsx
@@ -0,0 +1,103 @@
+// @vitest-environment happy-dom
+/**
+ * `event_types.follow_up_delay_hours` shipped with no way for a human to set it:
+ * the column and both CRUD schemas existed, and this page — the only screen that
+ * edits an event type — did not offer the field. These tests hold the three
+ * things that make the control correct rather than merely present.
+ *
+ * ZERO IS A LEGITIMATE VALUE ("the results exist when the camera comes out"), so
+ * the two failure modes worth guarding are symmetrical: a read that treats 0 as
+ * unset, and a write that treats "untouched" as 0.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { createRoutesStub } from "react-router";
+
+import SettingsEventTypes from "~/routes/settings-event-types";
+
+const TYPE = {
+ id: "et1",
+ name: "Sewer scope",
+ slug: "sewer_scope",
+ defaultDurationMin: 45,
+ defaultPriceCents: 15000,
+ color: "#4a72ff",
+ sortOrder: 0,
+ active: true,
+ followUpDelayHours: 72,
+};
+
+function renderPage(types: Array>) {
+ const Stub = createRoutesStub([
+ {
+ path: "/settings/event-types",
+ Component: SettingsEventTypes,
+ loader: () => ({ types, loadFailed: false }),
+ },
+ ]);
+ return render( );
+}
+
+/** The PATCH body the page last sent, parsed. */
+function lastPatchBody(fetchMock: ReturnType): Record {
+ const call = fetchMock.mock.calls.at(-1);
+ return JSON.parse(String((call?.[1] as RequestInit).body));
+}
+
+describe("settings → event types: follow-up delay", () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ fetchMock = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ data: TYPE }),
+ })) as unknown as ReturnType;
+ vi.stubGlobal("fetch", fetchMock);
+ });
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("shows the configured delay in the list", async () => {
+ renderPage([TYPE]);
+ expect((await screen.findByTestId("event-type-followup")).textContent).toContain("72");
+ });
+
+ it("reads zero as immediately, not as unset", async () => {
+ // `||` passes the 72-hour case and fails exactly here.
+ renderPage([{ ...TYPE, followUpDelayHours: 0 }]);
+ const cell = await screen.findByTestId("event-type-followup");
+ expect(cell.textContent).not.toContain("0 h");
+ expect(cell.textContent).toContain("Immediately");
+ });
+
+ it("seeds the edit form from the stored value", async () => {
+ renderPage([{ ...TYPE, followUpDelayHours: 0 }]);
+ fireEvent.click(await screen.findByRole("button", { name: /edit/i }));
+ expect((await screen.findByTestId("event-type-followup-input") as HTMLInputElement).value)
+ .toBe("0");
+ });
+
+ it("sends zero when zero is typed", async () => {
+ renderPage([TYPE]);
+ fireEvent.click(await screen.findByRole("button", { name: /edit/i }));
+ fireEvent.change(await screen.findByTestId("event-type-followup-input"), {
+ target: { value: "0" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ expect(lastPatchBody(fetchMock).followUpDelayHours).toBe(0);
+ });
+
+ it("omits the field when the box is emptied, rather than sending 0", async () => {
+ // The PUT schema is a `.partial()`: an absent key leaves the stored delay
+ // alone, a 0 rewrites it to "immediately". "I did not touch that" must
+ // not be transmitted as the most aggressive setting available.
+ renderPage([TYPE]);
+ fireEvent.click(await screen.findByRole("button", { name: /edit/i }));
+ fireEvent.change(await screen.findByTestId("event-type-followup-input"), {
+ target: { value: "" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ expect(lastPatchBody(fetchMock)).not.toHaveProperty("followUpDelayHours");
+ });
+});
diff --git a/app/routes/settings-event-types.tsx b/app/routes/settings-event-types.tsx
index 5438b6f64..2991d71ad 100644
--- a/app/routes/settings-event-types.tsx
+++ b/app/routes/settings-event-types.tsx
@@ -18,6 +18,10 @@ interface EventType {
color: string | null;
sortOrder: number | null;
active: boolean;
+ /** Hours between a visit being completed and its follow-up going out. Zero is
+ * a real setting ("tell them when the visit ends"), so every read uses `??`
+ * and no control may treat 0 as "unset". */
+ followUpDelayHours: number | null;
}
export function meta() {
@@ -45,6 +49,11 @@ const EMPTY_FORM = {
priceDollars: 0,
color: "#4a72ff",
sortOrder: 0,
+ // A STRING, not a number. `Number("")` is 0, and 0 is a legitimate value here
+ // ("send the follow-up the moment the visit ends") — so a numeric field that
+ // coerces an emptied box would silently rewrite a tenant's 72 to "immediately"
+ // rather than leaving it alone. Empty means "don't change it"; see save().
+ followUpDelayHours: "",
};
export default function SettingsEventTypes() {
@@ -73,12 +82,17 @@ export default function SettingsEventTypes() {
priceDollars: (t.defaultPriceCents ?? 0) / 100,
color: t.color ?? "#4a72ff",
sortOrder: t.sortOrder ?? 0,
+ followUpDelayHours: t.followUpDelayHours == null ? "" : String(t.followUpDelayHours),
});
setModalOpen(true);
}
async function save() {
setSaving(true);
+ // An empty follow-up box is OMITTED, never sent as 0: the PUT schema is a
+ // `.partial()`, so an absent key leaves the stored delay alone, while a 0
+ // would mean "send it immediately" — the opposite of "I didn't touch that".
+ const followUp = form.followUpDelayHours.trim();
const body = {
name: form.name,
slug: form.slug,
@@ -86,6 +100,7 @@ export default function SettingsEventTypes() {
defaultPriceCents: Math.round(form.priceDollars * 100),
color: form.color,
sortOrder: form.sortOrder,
+ ...(followUp === "" ? {} : { followUpDelayHours: Number(followUp) }),
};
const method = editingId ? "PATCH" : "POST";
const url = editingId
@@ -175,6 +190,17 @@ export default function SettingsEventTypes() {
{ label: m.settings_event_types_col_duration(), cell: (t) => {m.settings_event_types_duration_value({ min: t.defaultDurationMin ?? 0 })} },
{ label: m.settings_event_types_col_price(), cell: (t) => ${((t.defaultPriceCents ?? 0) / 100).toFixed(2)} },
{ label: m.settings_event_types_col_color(), cell: (t) => {t.color} },
+ {
+ label: m.settings_event_types_col_followup(),
+ cell: (t) => (
+
+ {/* `??`, never `||`: 0 is "immediately", not "unset". */}
+ {(t.followUpDelayHours ?? 72) === 0
+ ? m.settings_event_types_followup_immediate()
+ : m.settings_event_types_followup_hours({ hours: t.followUpDelayHours ?? 72 })}
+
+ ),
+ },
{
label: m.settings_event_types_col_actions(),
align: "right",
@@ -331,6 +357,29 @@ export default function SettingsEventTypes() {
/>
+
+
+ {m.settings_event_types_followup_label()}
+
+
+ setForm((f) => ({ ...f, followUpDelayHours: e.target.value }))
+ }
+ min={0}
+ max={8760}
+ placeholder="72"
+ data-testid="event-type-followup-input"
+ className="w-full px-3 py-2 rounded-md border border-ih-border bg-ih-bg-card text-[13px] text-ih-fg-1 focus:border-ih-primary focus:shadow-ih-focus outline-none"
+ />
+ {/* This one earns always-on help: the field's whole subtlety is
+ that 0 is a setting rather than a blank, which a label and a
+ placeholder cannot say. */}
+
+ {m.settings_event_types_followup_hint()}
+
+
diff --git a/messages/en/settings-catalog.json b/messages/en/settings-catalog.json
index 9f727d375..1e8ca2e37 100644
--- a/messages/en/settings-catalog.json
+++ b/messages/en/settings-catalog.json
@@ -122,5 +122,10 @@
"settings_inspection_report_link_bulk_lift_confirm": "This removes the expiry from {count} report links that work today, so they stay open indefinitely. Links that have already expired or been revoked stay closed.",
"settings_inspection_report_link_bulk_done": "Updated {count} links.",
"settings_inspection_archive_revokes_label": "Archiving a contact also revokes their report links",
- "settings_inspection_archive_revokes_help": "Off by default. A report link works without an account, so archiving a contact normally leaves the reports they were given readable. Turn this on if archiving is how you offboard someone."
+ "settings_inspection_archive_revokes_help": "Off by default. A report link works without an account, so archiving a contact normally leaves the reports they were given readable. Turn this on if archiving is how you offboard someone.",
+ "settings_event_types_col_followup": "Follow-up",
+ "settings_event_types_followup_hours": "{hours} h",
+ "settings_event_types_followup_immediate": "Immediately",
+ "settings_event_types_followup_label": "Follow-up delay (hours)",
+ "settings_event_types_followup_hint": "Hours after a visit is completed before its follow-up goes out. 0 sends it immediately."
}
diff --git a/messages/es-419/settings-catalog.json b/messages/es-419/settings-catalog.json
index 3defcd2a1..6ce4c889e 100644
--- a/messages/es-419/settings-catalog.json
+++ b/messages/es-419/settings-catalog.json
@@ -122,5 +122,10 @@
"settings_inspection_report_link_bulk_lift_confirm": "Esto quita el vencimiento de {count} enlaces de informe que hoy funcionan, por lo que quedan abiertos indefinidamente. Los enlaces que ya vencieron o fueron revocados siguen cerrados.",
"settings_inspection_report_link_bulk_done": "Se actualizaron {count} enlaces.",
"settings_inspection_archive_revokes_label": "Archivar un contacto también revoca sus enlaces de informe",
- "settings_inspection_archive_revokes_help": "Desactivado de forma predeterminada. Un enlace de informe funciona sin cuenta, así que archivar un contacto normalmente deja legibles los informes que se le dieron. Actívelo si archivar es la forma en que usted da de baja a alguien."
+ "settings_inspection_archive_revokes_help": "Desactivado de forma predeterminada. Un enlace de informe funciona sin cuenta, así que archivar un contacto normalmente deja legibles los informes que se le dieron. Actívelo si archivar es la forma en que usted da de baja a alguien.",
+ "settings_event_types_col_followup": "Seguimiento",
+ "settings_event_types_followup_hours": "{hours} h",
+ "settings_event_types_followup_immediate": "De inmediato",
+ "settings_event_types_followup_label": "Retraso del seguimiento (horas)",
+ "settings_event_types_followup_hint": "Horas que pasan desde que se completa una visita hasta que sale su seguimiento. 0 lo envía de inmediato."
}
From 7b3ae64c3352a09eb3ff845ec1c41ac426835155 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 18:18:18 +0800
Subject: [PATCH 062/111] fix(calendar): make a calendar item land somewhere
real
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The calendar is the field's entry point, so the only thing that matters
about an item is whether tapping it goes anywhere. Two kinds did not.
The modal offered "Open Inspection" for ANY item carrying an id, and fell
back to the item's OWN id when it had no inspection id — so a company
holiday, whose id is `holiday:2026-08-04`, navigated to
`/inspections/holiday:2026-08-04`. Landing on a 404 in a crawlspace is
the failure this removes. Destination now comes from one pure function,
`calendarItemHref`, which is allowed to answer null; the modal renders no
action at all in that case rather than a confident button into nothing.
A visit resolves to its INSPECTION, never to its own event id, which is
the same 404 wearing a different hat. The action is a real now, so
an inspector in a driveway can long-press it open in another tab.
Second, an ALL-DAY item's date was converted as if it were an instant:
the same holiday, stored as `2026-08-27`, rendered "Aug 26, 2026,
8:00 PM EDT" — the wrong DAY and a time nobody set. All-day items are
formatted from their civil date, anchored and read back at UTC so the day
comes out as written. Timed items keep showing the wall clock the SERVER
already resolved in the viewer's zone (startTime/endTime), never one
re-derived here.
Visit status is translated instead of printed with its underscores
swapped for spaces, so a Spanish UI stops showing "results received".
Those labels follow the VIEWER'S LANGUAGE via the catalogue — deliberately
not `useDisplayLocale`, which resolves the tenant's locale SETTING and is
why the chrome above still says "August 2026" under a Spanish UI. Language
follows the viewer; only date SHAPE follows the tenant.
Two DST-boundary cases join the tz spec, for the interval that is COMPUTED
rather than entered: a radon pickup at dropOff + 48h across US
spring-forward keeps its instant but MOVES its wall clock (09:00 EST to
10:00 EDT), and a late-evening pickup at 02:00Z belongs to the previous
civil day in a UTC-negative zone. Both were confirmed to fail when
civilPartsInTz is replaced with an ISO slice.
Visits already appeared as calendar items — `listCalendarItems` has
emitted `kind: 'inspection_event'` all along. Three strings, both
catalogues, es-419 at full parity.
---
.../calendar/CalendarEventModal.tsx | 80 ++++++++---
app/components/calendar/calendar-helpers.ts | 26 ++++
.../calendar/calendar-visit-entry.test.tsx | 126 ++++++++++++++++++
messages/en/calendar.json | 5 +-
messages/es-419/calendar.json | 5 +-
tests/unit/calendar/calendar-items-tz.spec.ts | 90 +++++++++++++
6 files changed, 312 insertions(+), 20 deletions(-)
create mode 100644 app/components/calendar/calendar-visit-entry.test.tsx
diff --git a/app/components/calendar/CalendarEventModal.tsx b/app/components/calendar/CalendarEventModal.tsx
index 56e768060..cd52ba203 100644
--- a/app/components/calendar/CalendarEventModal.tsx
+++ b/app/components/calendar/CalendarEventModal.tsx
@@ -1,9 +1,30 @@
-import { useNavigate } from "react-router";
+import { Link } from "react-router";
import { Modal } from "@core/shared-ui";
-import type { CalendarEvent } from "~/components/calendar/calendar-helpers";
-import { formatDateTime } from "~/lib/format";
+import { calendarItemHref, type CalendarEvent } from "~/components/calendar/calendar-helpers";
+import { formatDate, formatDateTime } from "~/lib/format";
import { m } from "~/paraglide/messages";
+/**
+ * A status word the viewer can read.
+ *
+ * The modal used to print the raw column value with its underscores swapped for
+ * spaces, so a Spanish UI showed "results received". These come from the
+ * message catalogue, which follows the VIEWER'S LANGUAGE — deliberately not
+ * `useDisplayLocale`, which resolves the tenant's locale SETTING and is the
+ * reason the calendar chrome above still says "August 2026" under a Spanish UI.
+ * Language follows the viewer; only date SHAPE follows the tenant.
+ */
+function statusLabel(status: string): string {
+ if (status === "scheduled") return m.label_status_scheduled();
+ if (status === "completed") return m.label_status_completed();
+ if (status === "cancelled") return m.label_status_cancelled();
+ if (status === "results_received") return m.calendar_event_status_results_received();
+ // Inspection lifecycle values (draft/in_progress/delivered/…) already have
+ // their own labels elsewhere; until this modal is taught them, the legacy
+ // rendering is better than a blank.
+ return status.replace(/_/g, " ");
+}
+
interface CalendarEventModalProps {
event: CalendarEvent;
open: boolean;
@@ -13,7 +34,17 @@ interface CalendarEventModalProps {
}
export function CalendarEventModal({ event, open, displayTz, locale, onClose }: CalendarEventModalProps) {
- const navigate = useNavigate();
+ // ONE function decides the destination, and it is allowed to answer "nowhere"
+ // — a company holiday used to render an "Open inspection" button pointing at
+ // `/inspections/holiday:2026-08-04`. See `calendarItemHref`.
+ const href = calendarItemHref(event);
+ // An ALL-DAY item is a civil day, not an instant. Converting one through the
+ // viewer's zone is the calendar off-by-one in its purest form: a holiday
+ // stored as `2026-08-27` was rendered as "Aug 26, 2026, 8:00 PM EDT" — the
+ // wrong DAY, with a time nobody ever set. `timeZone: 'UTC'` here is
+ // deliberate and is not a display choice: `formatDate` anchors a civil string
+ // at UTC midnight, so formatting it back in UTC returns the day as written.
+ const allDay = event.extendedProps?.allDay === true;
return (
{m.common_close()}
- {event.id && (
- {
- const inspectionId = event.extendedProps?.inspectionId;
- navigate(`/inspections/${typeof inspectionId === "string" ? inspectionId : event.id}`);
- onClose();
- }}
- className="h-8 px-4 rounded-md bg-ih-primary text-ih-fg-inverse font-bold text-[13px] hover:bg-ih-primary-600"
+ {href && (
+ // A real link, not a navigate() handler: an inspector in a driveway
+ // long-presses to open the job in another tab, and a gives
+ // them nothing to press.
+
{m.calendar_event_open_inspection()}
-
+
)}
>
}
@@ -49,14 +79,28 @@ export function CalendarEventModal({ event, open, displayTz, locale, onClose }:
{m.calendar_event_date_label()} {" "}
- {event.start
- ? formatDateTime(event.start, { locale, timeZone: displayTz })
- : m.calendar_event_na()}
+ {!event.start
+ ? m.calendar_event_na()
+ : allDay
+ ? formatDate(event.civilDate || event.start, { locale, timeZone: "UTC" })
+ : formatDateTime(event.start, { locale, timeZone: displayTz })}
+ {/* The wall clock the SERVER already resolved in the viewer's effective
+ zone. Never re-derived from `start` here — that is the calendar
+ off-by-one, and a visit computed as "48 hours later" is exactly the
+ item whose hour moves across a DST boundary. */}
+ {event.startTime && (
+
+ {m.calendar_event_time_label()} {" "}
+ {event.endTime
+ ? m.calendar_event_time_range({ start: event.startTime, end: event.endTime })
+ : event.startTime}
+
+ )}
{event.status && (
{m.calendar_event_status_label()} {" "}
- {event.status.replace(/_/g, " ")}
+ {statusLabel(event.status)}
)}
diff --git a/app/components/calendar/calendar-helpers.ts b/app/components/calendar/calendar-helpers.ts
index 7ac26fe4a..d60f98ed2 100644
--- a/app/components/calendar/calendar-helpers.ts
+++ b/app/components/calendar/calendar-helpers.ts
@@ -92,10 +92,36 @@ export function calendarItemToEvent(item: CalendarItem): CalendarEvent {
...(item.inspectionId ? { inspectionId: item.inspectionId } : {}),
...(item.userId ? { userId: item.userId } : {}),
...(typeof item.meta?.notes === "string" ? { notes: item.meta.notes } : {}),
+ ...(typeof item.meta?.durationMin === "number" ? { durationMin: item.meta.durationMin } : {}),
},
};
}
+/**
+ * Where tapping this item should take someone — or `null` when the answer is
+ * "nowhere".
+ *
+ * An inspector opening the calendar on a phone in a driveway is choosing a JOB.
+ * The modal used to offer "Open inspection" for every item that had an id and
+ * fall back to `event.id` when no inspection id was carried, so a company
+ * holiday — whose id is `holiday:2026-08-04` — navigated to
+ * `/inspections/holiday:2026-08-04`. Landing on a 404 in a crawlspace is the
+ * failure this function exists to remove: an item with nowhere to go says so by
+ * returning null, and the caller renders no action at all rather than a
+ * confident button into nothing.
+ *
+ * A VISIT (`inspection_event`) resolves to its inspection, never to its own id:
+ * `/inspections/` is the same 404 wearing a different hat.
+ */
+export function calendarItemHref(event: CalendarEvent): string | null {
+ const kind = event.extendedProps?.kind;
+ const inspectionId = event.extendedProps?.inspectionId;
+ if (typeof inspectionId === "string" && inspectionId) return `/inspections/${inspectionId}`;
+ // An inspection item carries its own id as the inspection id.
+ if (kind === "inspection" && event.id) return `/inspections/${event.id}`;
+ return null;
+}
+
export function isEventDraggable(event: CalendarEvent): boolean {
const kind = event.extendedProps?.kind;
if (kind) return kind === "inspection";
diff --git a/app/components/calendar/calendar-visit-entry.test.tsx b/app/components/calendar/calendar-visit-entry.test.tsx
new file mode 100644
index 000000000..b624745fa
--- /dev/null
+++ b/app/components/calendar/calendar-visit-entry.test.tsx
@@ -0,0 +1,126 @@
+// @vitest-environment happy-dom
+/**
+ * The calendar is the field's entry point, so the only thing that matters about
+ * an item is whether tapping it lands somewhere real.
+ *
+ * It did not, for two kinds. The modal offered "Open Inspection" for ANY item
+ * carrying an id and fell back to the item's OWN id when it had no inspection
+ * id — so a company holiday, whose id is `holiday:2026-08-04`, navigated to
+ * `/inspections/holiday:2026-08-04`. Landing on a 404 in a crawlspace is the
+ * failure worth a gate; a visit resolving to `/inspections/` is the
+ * same 404 wearing a different hat.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { createRoutesStub } from "react-router";
+
+import { CalendarEventModal } from "./CalendarEventModal";
+import { calendarItemHref, calendarItemToEvent, type CalendarItem } from "./calendar-helpers";
+
+const VISIT: CalendarItem = {
+ id: "ev-radon-pickup",
+ kind: "inspection_event",
+ title: "Radon pickup",
+ start: "2027-03-15T14:00:00.000Z",
+ end: "2027-03-15T14:20:00.000Z",
+ civilDate: "2027-03-15",
+ startTime: "10:00",
+ endTime: "10:20",
+ allDay: false,
+ inspectionId: "insp-77",
+ meta: { status: "scheduled", durationMin: 20 },
+};
+
+const HOLIDAY: CalendarItem = {
+ id: "holiday:2026-08-04",
+ kind: "company_holiday",
+ title: "Civic Holiday",
+ start: "2026-08-04",
+ end: "2026-08-04",
+ civilDate: "2026-08-04",
+ allDay: true,
+ meta: { holidayName: "Civic Holiday" },
+};
+
+function renderModal(item: CalendarItem) {
+ const Stub = createRoutesStub([
+ {
+ path: "/calendar",
+ Component: () => (
+
+ ),
+ },
+ ]);
+ return render( );
+}
+
+describe("calendarItemHref", () => {
+ it("sends a visit to its inspection, not to its own id", () => {
+ expect(calendarItemHref(calendarItemToEvent(VISIT))).toBe("/inspections/insp-77");
+ });
+
+ it("sends an inspection to itself", () => {
+ const item: CalendarItem = {
+ id: "insp-9",
+ kind: "inspection",
+ title: "742 Evergreen Terrace",
+ start: "2026-08-04",
+ end: "2026-08-04",
+ civilDate: "2026-08-04",
+ allDay: true,
+ inspectionId: "insp-9",
+ };
+ expect(calendarItemHref(calendarItemToEvent(item))).toBe("/inspections/insp-9");
+ });
+
+ it("answers nowhere for a company holiday", () => {
+ // The whole point: `/inspections/holiday:2026-08-04` is a 404.
+ expect(calendarItemHref(calendarItemToEvent(HOLIDAY))).toBeNull();
+ });
+
+ it("answers nowhere for a visit that carries no inspection", () => {
+ const orphan = calendarItemToEvent({ ...VISIT, inspectionId: undefined });
+ expect(calendarItemHref(orphan)).toBeNull();
+ });
+});
+
+describe("CalendarEventModal", () => {
+ it("links a visit to the job it belongs to", () => {
+ renderModal(VISIT);
+ expect(screen.getByRole("link")).toHaveAttribute("href", "/inspections/insp-77");
+ });
+
+ it("offers no destination at all for a company holiday", () => {
+ renderModal(HOLIDAY);
+ expect(screen.queryByRole("link")).toBeNull();
+ });
+
+ it("shows the wall clock the server resolved, not a re-derived one", () => {
+ // 14:00Z is 10:00 in New York on this date (EDT). The modal must read the
+ // server's startTime; deriving it from `start` is the calendar
+ // off-by-one.
+ renderModal(VISIT);
+ expect(document.body.textContent).toContain("10:00 - 10:20");
+ });
+
+ it("shows an all-day item on the civil day it was stored, not one converted through a zone", () => {
+ // `2026-08-04` run through a UTC-negative viewer zone as an instant lands
+ // on 2026-08-03 at 8 PM — the wrong day, with a time nobody set.
+ renderModal(HOLIDAY);
+ expect(document.body.textContent).toContain("Aug 4, 2026");
+ expect(document.body.textContent).not.toContain("Aug 3");
+ expect(document.body.textContent).not.toMatch(/\d:\d\d\s?(AM|PM)/);
+ });
+
+ it("translates the visit status instead of printing the column value", () => {
+ renderModal({ ...VISIT, meta: { status: "results_received" } });
+ expect(document.body.textContent).toContain("Results received");
+ expect(document.body.textContent).not.toContain("results received");
+ });
+});
diff --git a/messages/en/calendar.json b/messages/en/calendar.json
index 56eeb8d61..123611d94 100644
--- a/messages/en/calendar.json
+++ b/messages/en/calendar.json
@@ -60,5 +60,8 @@
"calendar_sync_not_connected": "Calendar not connected",
"calendar_sync_stale_short": "Out of sync",
"calendar_sync_not_connected_short": "Not connected",
- "calendar_sync_never": "Never synced"
+ "calendar_sync_never": "Never synced",
+ "calendar_event_time_label": "Time:",
+ "calendar_event_time_range": "{start} - {end}",
+ "calendar_event_status_results_received": "Results received"
}
diff --git a/messages/es-419/calendar.json b/messages/es-419/calendar.json
index 2625dad33..78b10cb19 100644
--- a/messages/es-419/calendar.json
+++ b/messages/es-419/calendar.json
@@ -60,5 +60,8 @@
"calendar_sync_not_connected": "Calendario sin conectar",
"calendar_sync_stale_short": "Desincronizado",
"calendar_sync_not_connected_short": "Sin conectar",
- "calendar_sync_never": "Nunca se sincronizó"
+ "calendar_sync_never": "Nunca se sincronizó",
+ "calendar_event_time_label": "Hora:",
+ "calendar_event_time_range": "{start} - {end}",
+ "calendar_event_status_results_received": "Resultados recibidos"
}
diff --git a/tests/unit/calendar/calendar-items-tz.spec.ts b/tests/unit/calendar/calendar-items-tz.spec.ts
index 8b0c4a1b6..b1b2e602c 100644
--- a/tests/unit/calendar/calendar-items-tz.spec.ts
+++ b/tests/unit/calendar/calendar-items-tz.spec.ts
@@ -262,4 +262,94 @@ describe('calendar items — timezone-correct civil date bucketing', () => {
// Shanghai (UTC+8) → 2026-07-18 04:00, not New York (UTC-4) 2026-07-17 16:00.
expect(event).toMatchObject({ civilDate: '2026-07-18', startTime: '04:00' });
});
+
+ /**
+ * A radon pickup is not entered, it is COMPUTED: the sample sits for at
+ * least 48 hours, so the pickup instant is `dropOff + 48h`. That is plain
+ * millisecond arithmetic, which means the two things below are both true and
+ * neither is obvious — and the calendar is where a human finds out.
+ *
+ * Spring-forward 2027 in the US is 2027-03-14. A drop-off at 09:00 EST on
+ * 2027-03-13 is 14:00Z; exactly 48h later is 14:00Z on 2027-03-15, which in
+ * New York is 10:00 EDT. The wall clock MOVED. Anyone re-deriving the day or
+ * the hour from the UTC instant (`start.toISOString().slice(0, 10)`) gets
+ * this wrong in both directions, which is why the civil parts are resolved
+ * server-side in the viewer's zone and never recomputed downstream.
+ */
+ async function seedComputedPickup(scheduledAt: Date, id: string) {
+ const now = new Date();
+ await testDb.insert(schema.inspections).values({
+ id: `inspection-${id}`,
+ tenantId: TENANT,
+ inspectorId: INSPECTOR,
+ propertyAddress: '9 Radon Way',
+ date: '2027-03-13',
+ status: 'scheduled',
+ paymentStatus: 'unpaid',
+ price: 0,
+ paymentRequired: false,
+ agreementRequired: false,
+ createdAt: now,
+ });
+ await testDb.insert(schema.eventTypes).values({
+ id: `event-type-${id}`,
+ tenantId: TENANT,
+ name: 'Radon pickup',
+ slug: `radon-pickup-${id}`,
+ defaultDurationMin: 20,
+ defaultPriceCents: 0,
+ color: '#6366f1',
+ sortOrder: 0,
+ active: true,
+ createdAt: now,
+ });
+ await testDb.insert(schema.inspectionEvents).values({
+ id: `event-${id}`,
+ tenantId: TENANT,
+ inspectionId: `inspection-${id}`,
+ eventTypeId: `event-type-${id}`,
+ inspectorId: INSPECTOR,
+ scheduledAt,
+ durationMin: 20,
+ priceCents: 0,
+ status: 'scheduled',
+ createdAt: now,
+ });
+ }
+
+ async function pickupItem() {
+ const items = await listCalendarItems({} as D1Database, TENANT, {
+ start: '2027-03-12',
+ end: '2027-03-16',
+ userIds: [INSPECTOR],
+ effectiveTz: 'America/New_York',
+ });
+ return items.find((item) => item.kind === 'inspection_event');
+ }
+
+ it('reports the shifted wall clock when a 48h pickup crosses spring-forward', async () => {
+ // 2027-03-13 09:00 EST = 14:00Z. +48h = 2027-03-15T14:00Z = 10:00 EDT.
+ const dropOff = new Date('2027-03-13T14:00:00.000Z');
+ await seedComputedPickup(new Date(dropOff.getTime() + 48 * 3_600_000), 'dst');
+
+ expect(await pickupItem()).toMatchObject({
+ civilDate: '2027-03-15',
+ // NOT '09:00'. Adding 48 hours of elapsed time across a DST boundary
+ // does not preserve the hour, and the calendar must show the hour the
+ // inspector will actually be standing there.
+ startTime: '10:00',
+ });
+ });
+
+ it('keeps a late-evening pickup on its own civil day in a UTC-negative zone', async () => {
+ // 2027-03-15T02:00Z is 2027-03-14 22:00 in New York. Slicing the ISO
+ // instant would file this under the 15th — a whole day early, on the
+ // other side of the boundary from the UTC-positive case above.
+ await seedComputedPickup(new Date('2027-03-15T02:00:00.000Z'), 'eve');
+
+ expect(await pickupItem()).toMatchObject({
+ civilDate: '2027-03-14',
+ startTime: '22:00',
+ });
+ });
});
From e266dfe45fe590007ec1baa2500fdd9a929b7a14 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 18:35:27 +0800
Subject: [PATCH 063/111] feat(events): keep results_received in the office,
enforced at the API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Completing a visit is the field's own act — the inspector standing on site
knows it is over. Recording that lab RESULTS arrived is an office act about a
different event, and until now the API took it from anyone: `visitActions`
declined to offer the verb, but the UI is not a gate. One hand-rolled PUT to
`/api/events/:id` moved a radon visit to results_received and fired the
follow-up automation.
The distinction is body-dependent — one method, one path, two transitions —
so route-level `requireRole` cannot express it; the check sits in the handler
ahead of the write.
Asserted the way this repo has learned to: real requests through the routers
mounted as `server/index.ts` mounts them, reading status codes off responses.
A capability checked inside a service proves the service checks it, and
`createRoutesStub` runs no middleware at all. Each 403 was confirmed to go red
first — the results_received gate stubbed out returns 200, and widening either
delete guard to include 'inspector' turns those two red as well.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
server/api/events.ts | 14 ++
tests/unit/events/event-capabilities.spec.ts | 162 +++++++++++++++++++
2 files changed, 176 insertions(+)
create mode 100644 tests/unit/events/event-capabilities.spec.ts
diff --git a/server/api/events.ts b/server/api/events.ts
index ee2a019aa..a28d07431 100644
--- a/server/api/events.ts
+++ b/server/api/events.ts
@@ -2,6 +2,7 @@ import {} from '@hono/zod-openapi';
import { createApiRouter } from '../lib/openapi-router';
import { z } from '@hono/zod-openapi';
import { requireRole } from '../lib/middleware/rbac';
+import { isAdminRole } from '../lib/auth/roles';
import { Errors } from '../lib/errors';
const TypeBody = z.object({
@@ -83,6 +84,19 @@ const eventsRoutes = createApiRouter()
const id = c.req.param('id') as string;
const parsed = EventStatusBody.safeParse(await c.req.json());
if (!parsed.success) throw Errors.BadRequest('Invalid status', parsed.error.flatten().fieldErrors);
+ // Completing a visit is the FIELD's own act: the inspector standing on
+ // site is the person who knows it is over, so every role on this route
+ // may do it. Recording that lab RESULTS arrived is an office act about a
+ // different event entirely, so it is owner/manager.
+ //
+ // The distinction is body-dependent — one method, one path, two very
+ // different transitions — so route-level `requireRole` cannot express
+ // it. `visitActions` (app/components/inspector-portal/VisitsCard.tsx)
+ // declines to OFFER the verb; this is where it is ENFORCED, so a
+ // hand-rolled request cannot walk around the UI.
+ if (parsed.data.status === 'results_received' && !isAdminRole(c.get('userRole'))) {
+ throw Errors.Forbidden('Requires one of [owner, manager]');
+ }
await c.var.services.event.updateEventStatus(c.get('tenantId'), id, parsed.data.status);
return c.json({ success: true });
})
diff --git a/tests/unit/events/event-capabilities.spec.ts b/tests/unit/events/event-capabilities.spec.ts
new file mode 100644
index 000000000..6af8a85bb
--- /dev/null
+++ b/tests/unit/events/event-capabilities.spec.ts
@@ -0,0 +1,162 @@
+import { describe, it, expect, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import type { Context } from 'hono';
+import type { HonoConfig } from '../../../server/types/hono';
+import type { Role } from '../../../server/lib/auth/roles';
+import { AppError, ErrorCode } from '../../../server/lib/errors';
+
+/**
+ * Who may do what to a visit — asserted as HTTP status codes from real
+ * requests, not as a service call.
+ *
+ * A capability checked inside a service function proves the FUNCTION checks it;
+ * it says nothing about whether the route mounts the check. This repo has
+ * already shipped a capability (`viewCommunication`) that was declared,
+ * defaulted per role, returned by `/me`, documented, unit-asserted — and
+ * enforced nowhere. Likewise `createRoutesStub` does not run middleware, so an
+ * authorization test built on a rendered component is a false green: it proves
+ * the button is hidden, not that the API refuses the request somebody types by
+ * hand.
+ *
+ * So: build the router the way `server/index.ts` mounts it, send a real
+ * request, and read the status code off the response.
+ *
+ * | action | who |
+ * |-----------------------|---------------------|
+ * | mark a visit complete | inspector — required|
+ * | mark results_received | owner/manager |
+ * | delete a visit | owner/manager |
+ * | delete a report | owner/manager |
+ */
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn(() => ({})) }));
+const deleteReport = vi.fn(async () => undefined);
+vi.mock('../../../server/lib/inspection/reports', () => ({ deleteReport: (...a: unknown[]) => deleteReport(...(a as [])) }));
+
+// Imported AFTER the mocks above are registered.
+/* eslint-disable import/first */
+import eventsRoutes from '../../../server/api/events';
+import inspectionReportRoutes from '../../../server/api/inspections/reports';
+/* eslint-enable import/first */
+
+const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+const USER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
+const EVENT_ID = 'evt_1';
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+/**
+ * The API app as the worker assembles it: the same routers, the same mount
+ * paths (`server/index.ts` routes eventsRoutes at `/api`; inspectionReportRoutes
+ * is folded into `/api/inspections`), and the same AppError → status mapping the
+ * global handler performs. Only the auth middleware is replaced, by the one
+ * thing a test must be able to vary: which role is calling.
+ */
+function buildApp(role: Role, event: Record = {}) {
+ const app = new OpenAPIHono();
+ app.onError((err: unknown, c: Context) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status as 500);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT_ID);
+ c.set('user', { sub: USER_ID, role, tenantId: TENANT_ID });
+ c.set('userRole', role);
+ c.set('services', { event } as unknown as HonoConfig['Variables']['services']);
+ await next();
+ });
+ app.route('/api', eventsRoutes);
+ app.route('/api/inspections', inspectionReportRoutes);
+ return app;
+}
+
+const putStatus = (role: Role, status: string, event: Record = {}) =>
+ buildApp(role, event).request(`/api/events/${EVENT_ID}`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ status }),
+ }, FAKE_ENV);
+
+describe('PUT /api/events/:id — marking a visit complete', () => {
+ it('lets an inspector complete a visit (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('inspector', 'completed', { updateEventStatus });
+ expect(res.status).toBe(200);
+ expect(updateEventStatus).toHaveBeenCalledWith(TENANT_ID, EVENT_ID, 'completed');
+ });
+
+ it('lets an inspector cancel a visit they are standing at (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('inspector', 'cancelled', { updateEventStatus });
+ expect(res.status).toBe(200);
+ });
+
+ it('refuses an unknown status with 400 before any role question', async () => {
+ const updateEventStatus = vi.fn();
+ const res = await putStatus('inspector', 'results-received', { updateEventStatus });
+ expect(res.status).toBe(400);
+ expect(updateEventStatus).not.toHaveBeenCalled();
+ });
+});
+
+describe('PUT /api/events/:id — marking results_received', () => {
+ it('rejects results_received from an inspector with 403', async () => {
+ const updateEventStatus = vi.fn();
+ const res = await putStatus('inspector', 'results_received', { updateEventStatus });
+ expect(res.status).toBe(403);
+ const body = await res.json() as { error: { code: string } };
+ expect(body.error.code).toBe(ErrorCode.FORBIDDEN);
+ // The refusal must happen before the write, not after it.
+ expect(updateEventStatus).not.toHaveBeenCalled();
+ });
+
+ it('allows results_received from a manager (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('manager', 'results_received', { updateEventStatus });
+ expect(res.status).toBe(200);
+ expect(updateEventStatus).toHaveBeenCalledWith(TENANT_ID, EVENT_ID, 'results_received');
+ });
+
+ it('allows results_received from an owner (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('owner', 'results_received', { updateEventStatus });
+ expect(res.status).toBe(200);
+ });
+});
+
+describe('DELETE /api/events/:id — removing a visit', () => {
+ it('rejects an inspector with 403', async () => {
+ const deleteEvent = vi.fn();
+ const res = await buildApp('inspector', { deleteEvent })
+ .request(`/api/events/${EVENT_ID}`, { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(403);
+ expect(deleteEvent).not.toHaveBeenCalled();
+ });
+
+ it('allows a manager (200)', async () => {
+ const deleteEvent = vi.fn().mockResolvedValue(undefined);
+ const res = await buildApp('manager', { deleteEvent })
+ .request(`/api/events/${EVENT_ID}`, { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(200);
+ expect(deleteEvent).toHaveBeenCalledWith(TENANT_ID, EVENT_ID);
+ });
+});
+
+describe('DELETE /api/inspections/:id/reports/:reportId — destroying a deliverable', () => {
+ it('rejects an inspector with 403 and never reaches the delete', async () => {
+ deleteReport.mockClear();
+ const res = await buildApp('inspector')
+ .request('/api/inspections/insp_1/reports/rep_1', { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(403);
+ expect(deleteReport).not.toHaveBeenCalled();
+ });
+
+ it('lets a manager through to the delete (200)', async () => {
+ deleteReport.mockClear();
+ const res = await buildApp('manager')
+ .request('/api/inspections/insp_1/reports/rep_1', { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(200);
+ expect(deleteReport).toHaveBeenCalledTimes(1);
+ });
+});
From 368b7b81a34e84e639f642d5ab74d742f9d31b17 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 18:47:25 +0800
Subject: [PATCH 064/111] fix(sync): send a field write to the report it
belongs to
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`inspection_results` is one row per REPORT — `uq_results_report` is unique on
`report_id`, and the standard report and the sewer report each own their
findings. The two routes the field actually uses were never converted: the
inspector signature and the authoritative photo delete both selected
`WHERE inspection_id = ?` and took whatever the scan returned first. On an
order with two reports that is a coin toss. An inspector signs on site and the
signature lands on the sewer scope; a photo is deleted from a phone and it
comes out of the other document.
Worse in the insert branch: with no results row yet, the signature route
created one with a NULL `report_id`. The unique index permits any number of
NULLs, so nothing complained — the row simply became invisible to every
report-scoped read. That is the shape of the orphan rows already in the
database.
Both now resolve the order's primary report the way the collab route does (the
offline client holds no report id, so the primary is the answer), and fail
closed when there is not one rather than writing a row that belongs to no
deliverable. The editor's Durable Object already worked this way — "per REPORT
when we know which one"; this is the same rule reaching the field.
The specs seed the ANCILLARY row first, so an inspection-keyed select picks the
wrong document and they fail rather than passing by row order. Confirmed
against the unfixed routes: all four red, and each one red for its own reason —
signature on the sewer row, `sewer-a.jpg` deleted instead of `primary-a.jpg`,
`report_id` NULL, and a missing primary report answered 200.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
server/api/inspection-sync.ts | 47 ++++-
.../offline-sync-per-report.spec.ts | 160 ++++++++++++++++++
2 files changed, 202 insertions(+), 5 deletions(-)
create mode 100644 tests/unit/inspections/offline-sync-per-report.spec.ts
diff --git a/server/api/inspection-sync.ts b/server/api/inspection-sync.ts
index e9c268578..410db6ad4 100644
--- a/server/api/inspection-sync.ts
+++ b/server/api/inspection-sync.ts
@@ -10,7 +10,43 @@ import {
import { inspections, inspectionResults, templates } from '../lib/db/schema';
import { withMcpMetadata } from "../lib/route-metadata-standards";
import { getDrizzle } from '../lib/route-helpers';
+import { resolvePrimaryReportId } from '../lib/inspection/reports';
import { r2Delete } from '../lib/r2/objects';
+import type { DrizzleD1Database } from 'drizzle-orm/d1';
+
+/**
+ * The results row a FIELD write belongs to, and the report it is a row of.
+ *
+ * An order now carries one results row per REPORT — `uq_results_report` is
+ * unique on `report_id`, and the standard report and the sewer report each own
+ * their own findings. So "the inspection's results" stopped naming a row:
+ * selecting on `inspection_id` alone returns whichever the scan reaches first,
+ * which is a coin toss between two documents. The editor's Durable Object was
+ * already converted ("per REPORT when we know which one"); these routes were
+ * not, and they are the ones the field uses — a signature captured offline and
+ * a photo deleted on site.
+ *
+ * They are addressed by INSPECTION and the offline client holds no report id,
+ * so the primary report is the answer, resolved the way the collab route
+ * resolves it. Failing closed matters more here than anywhere: the insert path
+ * below used to write a row with a NULL `report_id`, and because the unique
+ * index is on a nullable column nothing complained — the row simply became
+ * invisible to every report-scoped read.
+ */
+async function primaryResults(
+ db: DrizzleD1Database,
+ tenantId: string,
+ inspectionId: string,
+): Promise<{ reportId: string; row: typeof inspectionResults.$inferSelect | undefined }> {
+ const reportId = await resolvePrimaryReportId(db, tenantId, inspectionId);
+ if (!reportId) throw Errors.NotFound('Inspection has no primary report');
+ const row = await db.select().from(inspectionResults)
+ .where(and(
+ eq(inspectionResults.tenantId, tenantId),
+ eq(inspectionResults.reportId, reportId),
+ )).get();
+ return { reportId, row };
+}
const syncRoutes = createApiRouter()
/* ── DELETE /api/inspections/:id/items/:itemId/photos/:photoIndex ─────────── */
@@ -51,8 +87,7 @@ const syncRoutes = createApiRouter()
.where(and(eq(inspections.id, id), eq(inspections.tenantId, tenantId))).get();
if (!insp) throw Errors.NotFound('Inspection not found');
- const row = await db.select().from(inspectionResults)
- .where(and(eq(inspectionResults.inspectionId, id), eq(inspectionResults.tenantId, tenantId))).get();
+ const { row } = await primaryResults(db, tenantId, id);
if (!row) throw Errors.NotFound('Results not found');
const data = row.data as Record }>;
@@ -108,20 +143,22 @@ const syncRoutes = createApiRouter()
.where(and(eq(inspections.id, id), eq(inspections.tenantId, tenantId))).get();
if (!insp) throw Errors.NotFound('Inspection not found');
- const row = await db.select().from(inspectionResults)
- .where(and(eq(inspectionResults.inspectionId, id), eq(inspectionResults.tenantId, tenantId))).get();
+ const { reportId, row } = await primaryResults(db, tenantId, id);
const data = (row?.data as Record) ?? {};
data['_inspector_signature'] = { signatureBase64, signedAt, updatedAt: signedAt };
if (row) {
await db.update(inspectionResults)
.set({ data: data as object, lastSyncedAt: new Date() })
- .where(eq(inspectionResults.id, row.id));
+ .where(and(eq(inspectionResults.tenantId, tenantId), eq(inspectionResults.id, row.id)));
} else {
await db.insert(inspectionResults).values({
id: crypto.randomUUID(),
tenantId,
inspectionId: id,
+ // Bound at creation. A results row with no report belongs to no
+ // deliverable and is read by nothing.
+ reportId,
data: data as object,
lastSyncedAt: new Date(),
});
diff --git a/tests/unit/inspections/offline-sync-per-report.spec.ts b/tests/unit/inspections/offline-sync-per-report.spec.ts
new file mode 100644
index 000000000..1762a541f
--- /dev/null
+++ b/tests/unit/inspections/offline-sync-per-report.spec.ts
@@ -0,0 +1,160 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import type { Context } from 'hono';
+import { and, eq } from 'drizzle-orm';
+import { createTestDb, setupSchema } from '../db';
+import { tenants, inspections, reports, inspectionResults } from '../../../server/lib/db/schema';
+import type { HonoConfig } from '../../../server/types/hono';
+import { AppError } from '../../../server/lib/errors';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import * as schema from '../../../server/lib/db/schema';
+
+/**
+ * Field sync is per REPORT.
+ *
+ * `uq_results_report` is unique on `report_id`: an order carries one results row
+ * per deliverable, and the standard report and the sewer report each own their
+ * own findings. "The inspection's results" therefore stopped naming a row —
+ * a select on `inspection_id` alone returns whichever the scan reaches first.
+ *
+ * These two routes are what the FIELD uses (a signature captured on site, a
+ * photo deleted from a phone), they are addressed by inspection, and the
+ * offline client holds no report id — so they must resolve the order's primary
+ * report themselves. Each case below is seeded with the ANCILLARY row inserted
+ * first, so a route that still matches on inspection id picks the wrong
+ * document and the test fails rather than passing by luck of row order.
+ */
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+// Imported AFTER the mock above.
+// eslint-disable-next-line import/order
+import syncRoutes from '../../../server/api/inspection-sync';
+
+const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+const INSP_ID = 'insp-offline-1';
+const PRIMARY_REPORT = 'rep-primary';
+const SEWER_REPORT = 'rep-sewer';
+/** The schema requires a real-sized data URL (min 100 chars), not a token blob. */
+const SIGNATURE = 'data:image/png;base64,' + 'A'.repeat(200);
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+function buildApp(db: BetterSQLite3Database) {
+ const app = new OpenAPIHono();
+ app.onError((err: unknown, c: Context) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status as 500);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT_ID);
+ c.set('userRole', 'inspector');
+ c.set('user', { sub: 'u-1', role: 'inspector', tenantId: TENANT_ID });
+ c.set('services', {} as HonoConfig['Variables']['services']);
+ await next();
+ });
+ app.route('/', syncRoutes);
+ (mockDrizzle as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue(db);
+ return app;
+}
+
+const photoRow = (label: string) => ({ 'item-1': { photos: [{ key: `${label}-a.jpg` }, { key: `${label}-b.jpg` }] } });
+
+describe('offline field sync writes to the primary report, not to whichever results row comes first', () => {
+ let db: BetterSQLite3Database;
+ let sqlite: { close: () => void };
+
+ beforeEach(async () => {
+ const setup = createTestDb();
+ db = setup.db as BetterSQLite3Database;
+ sqlite = setup.sqlite;
+ await setupSchema(sqlite);
+ (mockDrizzle as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue(db);
+
+ await db.insert(tenants).values({
+ id: TENANT_ID, name: 'Acme Inspections', slug: 'acme-test',
+ tier: 'free', status: 'active', maxUsers: 5,
+ deploymentMode: 'shared', createdAt: new Date(),
+ } as never);
+ await db.insert(inspections).values({
+ id: INSP_ID, tenantId: TENANT_ID, propertyAddress: '1 Main',
+ status: 'in_progress', date: '2026-08-04', createdAt: new Date(),
+ } as never);
+ // Ancillary FIRST, so an inspection-keyed scan reaches the wrong one.
+ await db.insert(reports).values([
+ { id: SEWER_REPORT, tenantId: TENANT_ID, inspectionId: INSP_ID, kind: 'ancillary', title: 'Sewer scope', status: 'in_progress', createdAt: new Date(1) },
+ { id: PRIMARY_REPORT, tenantId: TENANT_ID, inspectionId: INSP_ID, kind: 'primary', title: 'Home inspection', status: 'in_progress', createdAt: new Date(2) },
+ ] as never);
+ });
+
+ afterEach(() => sqlite.close());
+
+ const seedResults = async () => {
+ await db.insert(inspectionResults).values([
+ { id: 'res-sewer', tenantId: TENANT_ID, inspectionId: INSP_ID, reportId: SEWER_REPORT, data: photoRow('sewer'), lastSyncedAt: new Date(1) },
+ { id: 'res-primary', tenantId: TENANT_ID, inspectionId: INSP_ID, reportId: PRIMARY_REPORT, data: photoRow('primary'), lastSyncedAt: new Date(2) },
+ ] as never);
+ };
+
+ const readRow = async (id: string) =>
+ db.select().from(inspectionResults).where(eq(inspectionResults.id, id)).get();
+
+ it('records an inspector signature on the primary report only', async () => {
+ await seedResults();
+ const res = await buildApp(db).request(`/${INSP_ID}/inspector-signature`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ signatureBase64: SIGNATURE, signedAt: 1_754_000_000_000 }),
+ }, FAKE_ENV);
+ expect(res.status).toBe(200);
+
+ const primary = await readRow('res-primary');
+ const sewer = await readRow('res-sewer');
+ expect((primary?.data as Record)['_inspector_signature']).toBeTruthy();
+ expect((sewer?.data as Record)['_inspector_signature']).toBeUndefined();
+ });
+
+ it('deletes a photo from the primary report, leaving the sewer report whole', async () => {
+ await seedResults();
+ const res = await buildApp(db).request(`/${INSP_ID}/items/item-1/photos/0`, { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(200);
+ const body = await res.json() as { data: { deletedKey: string } };
+ expect(body.data.deletedKey).toBe('primary-a.jpg');
+
+ const primary = (await readRow('res-primary'))?.data as Record;
+ const sewer = (await readRow('res-sewer'))?.data as Record;
+ expect(primary['item-1'].photos.map((p) => p.key)).toEqual(['primary-b.jpg']);
+ expect(sewer['item-1'].photos.map((p) => p.key)).toEqual(['sewer-a.jpg', 'sewer-b.jpg']);
+ });
+
+ it('binds a results row it has to create to the primary report', async () => {
+ // No results rows yet — the first thing to reach the server is the
+ // signature. The row it creates used to carry a NULL report_id, which
+ // the unique index permits and every report-scoped read ignores.
+ const res = await buildApp(db).request(`/${INSP_ID}/inspector-signature`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ signatureBase64: SIGNATURE, signedAt: 1_754_000_000_000 }),
+ }, FAKE_ENV);
+ expect(res.status).toBe(200);
+
+ const rows = await db.select().from(inspectionResults)
+ .where(and(eq(inspectionResults.tenantId, TENANT_ID), eq(inspectionResults.inspectionId, INSP_ID))).all();
+ expect(rows).toHaveLength(1);
+ expect(rows[0].reportId).toBe(PRIMARY_REPORT);
+ });
+
+ it('refuses an order with no primary report rather than writing an unowned row', async () => {
+ await db.delete(reports).where(eq(reports.kind, 'primary'));
+ const res = await buildApp(db).request(`/${INSP_ID}/inspector-signature`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ signatureBase64: SIGNATURE, signedAt: 1_754_000_000_000 }),
+ }, FAKE_ENV);
+ expect(res.status).toBe(404);
+ const rows = await db.select().from(inspectionResults).all();
+ expect(rows).toHaveLength(0);
+ });
+});
From 35a880cb9b6060d72705fd70e25f7db53560dd2b Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 19:05:26 +0800
Subject: [PATCH 065/111] feat(payments): give a payment a row of its own
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An append-only `order_payments` table. It records what was RECEIVED, never
what is OWED — the total keeps coming from the money-authority chain, and
the invoice's paid_at / partial_paid_at / amount_paid_cents become a cache
that a later commit recomputes from these rows.
Keyed on the ORDER, not the invoice: a booking deposit is taken before any
invoice exists, so an invoice-keyed table would make `kind: 'deposit'`
unrepresentable and force a re-key of append-only financial rows later.
`invoice_id` is a nullable link, written once when the invoice is raised.
`inspection_id` is nullable too, against the original design: a STANDALONE
invoice (no inspection) is a real product state — `invoices.inspection_id`
is nullable and the New Invoice form submits null on a blank field — so a
NOT NULL order key would have made "mark this standalone invoice paid"
impossible to express once the writers move onto the ledger. The invariant
is "at least one of inspection_id / invoice_id", enforced in code.
Direction lives in `kind`, not in the sign of `amount_cents`, so nobody can
sum the column unfiltered and get a wrong total. The unique index on
(tenant_id, provider, provider_ref) is the webhook-redelivery guard, in the
database rather than in a handler — and it guards provider-backed rows only,
because SQLite treats NULLs as distinct and two offline cash rows must not
collide.
Catalogued for erasure in the same commit, and actually realized rather than
just declared: `note` is the one free-text column a human writes on a row
tied to an identified client, so the orchestrator clears it, reached through
both keys because neither alone covers every row. Amounts, the recording
user id and the processor reference are declared out of scope with reasons.
---
migrations/0036_mean_magik.sql | 20 +
migrations/meta/0036_snapshot.json | 10488 ++++++++++++++++
migrations/meta/_journal.json | 7 +
scripts/file-size-baseline.json | 2 +-
server/lib/compliance/erasure-manifest.ts | 18 +
server/lib/compliance/erasure-orchestrator.ts | 38 +
server/lib/db/schema/index.ts | 2 +
server/lib/db/schema/order-payment.ts | 65 +
8 files changed, 10639 insertions(+), 1 deletion(-)
create mode 100644 migrations/0036_mean_magik.sql
create mode 100644 migrations/meta/0036_snapshot.json
create mode 100644 server/lib/db/schema/order-payment.ts
diff --git a/migrations/0036_mean_magik.sql b/migrations/0036_mean_magik.sql
new file mode 100644
index 000000000..fc1d15c5d
--- /dev/null
+++ b/migrations/0036_mean_magik.sql
@@ -0,0 +1,20 @@
+CREATE TABLE `order_payments` (
+ `id` text PRIMARY KEY NOT NULL,
+ `tenant_id` text NOT NULL,
+ `inspection_id` text,
+ `invoice_id` text,
+ `kind` text NOT NULL,
+ `amount_cents` integer NOT NULL,
+ `method` text NOT NULL,
+ `provider` text,
+ `provider_ref` text,
+ `recorded_by` text,
+ `refunds_id` text,
+ `note` text,
+ `occurred_at` integer NOT NULL,
+ `created_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `idx_order_payments_inspection` ON `order_payments` (`tenant_id`,`inspection_id`);--> statement-breakpoint
+CREATE INDEX `idx_order_payments_invoice` ON `order_payments` (`tenant_id`,`invoice_id`);--> statement-breakpoint
+CREATE UNIQUE INDEX `uq_order_payments_provider_ref` ON `order_payments` (`tenant_id`,`provider`,`provider_ref`);
\ No newline at end of file
diff --git a/migrations/meta/0036_snapshot.json b/migrations/meta/0036_snapshot.json
new file mode 100644
index 000000000..a8bb41fd8
--- /dev/null
+++ b/migrations/meta/0036_snapshot.json
@@ -0,0 +1,10488 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "65b4799f-a21c-4323-bfa2-725b630b96cb",
+ "prevId": "bfb151b9-1234-4ea4-8047-f60f1b7d6b16",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "language_disclosure_version": {
+ "name": "language_disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "follow_up_delay_hours": {
+ "name": "follow_up_delay_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 72
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "order_payments": {
+ "name": "order_payments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "invoice_id": {
+ "name": "invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recorded_by": {
+ "name": "recorded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refunds_id": {
+ "name": "refunds_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_order_payments_inspection": {
+ "name": "idx_order_payments_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_order_payments_invoice": {
+ "name": "idx_order_payments_invoice",
+ "columns": [
+ "tenant_id",
+ "invoice_id"
+ ],
+ "isUnique": false
+ },
+ "uq_order_payments_provider_ref": {
+ "name": "uq_order_payments_provider_ref",
+ "columns": [
+ "tenant_id",
+ "provider",
+ "provider_ref"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'us'"
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'12h'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index 9354f69b6..d90b7aaf9 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -253,6 +253,13 @@
"when": 1785834126085,
"tag": "0035_awesome_ben_grimm",
"breakpoints": true
+ },
+ {
+ "idx": 36,
+ "version": "6",
+ "when": 1785841431766,
+ "tag": "0036_mean_magik",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index be0a81861..099a8399a 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -50,13 +50,13 @@
"app/components/collab/VersionHistoryPanel.tsx": 497,
"server/api/bookings.ts": 477,
"server/api/admin/admin-config.ts": 472,
+ "server/lib/compliance/erasure-orchestrator.ts": 472,
"server/portal/integration.routes.ts": 472,
"app/components/inspection/PeopleEditor.tsx": 457,
"app/components/editor/CostItemsPanel.tsx": 449,
"app/routes/settings-schedule.tsx": 437,
"app/lib/collab/results-doc-connection.ts": 435,
"server/api/inspections/media.ts": 435,
- "server/lib/compliance/erasure-orchestrator.ts": 434,
"app/components/media-studio/VideoCapture.tsx": 433,
"app/routes/public/portal-inspection.tsx": 430,
"server/api/inspections/results.ts": 430,
diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts
index ef740ba5c..f79f03bc9 100644
--- a/server/lib/compliance/erasure-manifest.ts
+++ b/server/lib/compliance/erasure-manifest.ts
@@ -107,6 +107,14 @@ export const ERASURE_MANIFEST: ErasureRule[] = [
{ table: 'invoices', column: 'client_name', category: 'user.name', action: 'null' },
{ table: 'invoices', column: 'client_email', category: 'user.contact.email', action: 'null' },
+ // ── order_payments ────────────────────────────────────────────────────────
+ // The payment ledger is append-only and financial: the ROWS are retained
+ // under the accounting/tax obligation (Art. 17(3)(b)) and the amounts are
+ // declared out of scope below. `note` is the one free-text column a human
+ // writes on a row linked to an identified client ("check from J. Smith,
+ // 123 Oak St"), so it is cleared in place rather than left standing.
+ { table: 'order_payments', column: 'note', category: 'user.freetext', action: 'anonymize', legalBasis: 'art_17_3_b' },
+
// ── concierge_confirm_tokens (#88) ────────────────────────────────────────
// Single-use magic-link tokens addressed to the subject: delete the ROWS
// (locator = client_email). Nothing references a token row.
@@ -225,6 +233,16 @@ export const ERASURE_OUT_OF_SCOPE: ErasureOutOfScopeEntry[] = [
// said at a date, which is the one thing it exists to answer. Listed rather
// than left silent because the PII heuristic does not flag any column here,
// and silence is not the same as a decision.
+ // The payment ledger. The `note` column has its own anonymize rule above;
+ // everything that carries a figure or an actor reference is declared here
+ // rather than left silent, because the heuristic flags none of it and
+ // silence is not the same as a decision.
+ { table: 'order_payments', column: 'amount_cents',
+ reason: 'financial record retained under accounting/tax obligation; carries no subject identifier on its own' },
+ { table: 'order_payments', column: 'recorded_by',
+ reason: 'staff user id (who keyed the payment) — not consumer-DSAR scope' },
+ { table: 'order_payments', column: 'provider_ref',
+ reason: 'payment-processor reference on the retained financial row, not personal data' },
{ table: 'tenant_legal_versions', column: 'body_snapshot', reason: 'company-authored policy text, not personal data of any data subject' },
{ table: 'tenant_legal_versions', column: 'published_by_user_id', reason: 'staff author reference — not consumer-DSAR scope' },
];
diff --git a/server/lib/compliance/erasure-orchestrator.ts b/server/lib/compliance/erasure-orchestrator.ts
index 36eeb775f..340808424 100644
--- a/server/lib/compliance/erasure-orchestrator.ts
+++ b/server/lib/compliance/erasure-orchestrator.ts
@@ -45,6 +45,7 @@ import {
agreementRequests,
agreementSigners,
invoices,
+ orderPayments,
conciergeConfirmTokens,
inspectionAccessTokens,
inspectionRequests,
@@ -378,6 +379,43 @@ export async function runErasure(
return c;
});
+ // The payment ledger is append-only and financial — the ROWS stay, retained
+ // under the accounting/tax obligation. `note` is the one column a human
+ // writes free-hand on a row tied to an identified client, so it is the one
+ // column cleared. Located BOTH ways, because neither key alone reaches every
+ // row: a deposit taken before the invoice exists has no invoice_id, and a
+ // payment against a standalone invoice has no inspection_id. The invoice
+ // locator matches on contact_id, which survives the invoices step above
+ // (that one nulls client_name/client_email only).
+ await step('order_payments', 'anonymize', { legalBasis: 'art_17_3_b' }, async () => {
+ if (subjectContactIds.length === 0) return 0;
+ const inspRows = await db.select({ id: inspectionPeople.inspectionId })
+ .from(inspectionPeople)
+ .where(and(
+ eq(inspectionPeople.tenantId, tenantId),
+ inArray(inspectionPeople.contactId, subjectContactIds),
+ ))
+ .all();
+ const inspIds = [...new Set((inspRows as Array<{ id: string }>).map((i) => i.id))];
+ const invRows = await db.select({ id: invoices.id }).from(invoices)
+ .where(and(eq(invoices.tenantId, tenantId), inArray(invoices.contactId, subjectContactIds)))
+ .all();
+ const invIds = [...new Set((invRows as Array<{ id: string }>).map((i) => i.id))];
+ if (inspIds.length === 0 && invIds.length === 0) return 0;
+
+ const reach = [
+ ...(inspIds.length > 0 ? [inArray(orderPayments.inspectionId, inspIds)] : []),
+ ...(invIds.length > 0 ? [inArray(orderPayments.invoiceId, invIds)] : []),
+ ];
+ const res = await db.update(orderPayments)
+ .set({ note: null })
+ .where(and(eq(orderPayments.tenantId, tenantId), reach.length === 1 ? reach[0] : or(...reach)))
+ .run();
+ const c = changeCount(res);
+ retainedCount += c; // financial rows retained with the free text cleared
+ return c;
+ });
+
// ── 4) Non-agreement client PII lives on `contacts` now (the
// `inspections.client_*` columns are a frozen, unread cache dropped in a
// later migration — the erasure orchestrator no longer writes them). ────
diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts
index aac233baa..7e7685e48 100644
--- a/server/lib/db/schema/index.ts
+++ b/server/lib/db/schema/index.ts
@@ -37,6 +37,8 @@ export { commercialSubtypes } from './commercial-subtypes';
export { contacts } from './contact';
export { contractorTypes } from './contractor-types';
export { invoices } from './invoice';
+export { orderPayments } from './order-payment';
+export type { OrderPayment, NewOrderPayment } from './order-payment';
export {
marketplaceTemplates,
tenantMarketplaceImports,
diff --git a/server/lib/db/schema/order-payment.ts b/server/lib/db/schema/order-payment.ts
new file mode 100644
index 000000000..d8ef0696e
--- /dev/null
+++ b/server/lib/db/schema/order-payment.ts
@@ -0,0 +1,65 @@
+import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
+
+/**
+ * The payment ledger — append-only, one row per movement of money against an
+ * ORDER. It records what was RECEIVED, never what is OWED: the total keeps
+ * coming from the money-authority chain (`getEffectivePriceCents()`), and the
+ * invoice's `paid_at` / `partial_paid_at` / `amount_paid_cents` become a
+ * denormalized cache recomputed from these rows by a single writer.
+ *
+ * Append-only means no UPDATE and no DELETE — a correction is a new row, which
+ * is what makes the ledger reconcilable. There is exactly ONE exception, and it
+ * is not a correction: `invoice_id` is written once onto rows that predate the
+ * invoice, at the moment the invoice is raised.
+ *
+ * See spec 2026-08-01 payment/deposit flow §3.
+ */
+export const orderPayments = sqliteTable('order_payments', {
+ id: text('id').primaryKey(),
+ tenantId: text('tenant_id').notNull(),
+ // The ORDER is the primary key of a payment. A booking deposit is taken
+ // before any invoice exists (`booking.service.ts` creates none), so keying
+ // this table on the invoice would make `kind: 'deposit'` unrepresentable —
+ // and this table is append-only and financial, the worst kind to re-key.
+ //
+ // Nullable only because a STANDALONE invoice is a real product state
+ // (`invoices.inspection_id` is nullable and the New Invoice form submits
+ // null when the field is blank); a payment against one has no order to
+ // point at. The invariant is "at least one of inspection_id / invoice_id",
+ // enforced in `recordPayment` — never both null.
+ inspectionId: text('inspection_id'),
+ // A LINK, not identity: null until an invoice exists, then set — and
+ // backfilled onto the deposit rows that predate it.
+ invoiceId: text('invoice_id'),
+ // Direction lives here, not in the sign of amountCents — an unfiltered
+ // SUM over a signed column is a wrong total nobody notices.
+ kind: text('kind', { enum: ['deposit', 'balance', 'adjustment', 'refund'] }).notNull(),
+ amountCents: integer('amount_cents').notNull(), // always positive
+ method: text('method', { enum: ['card', 'check', 'cash', 'offline', 'other'] }).notNull(),
+ provider: text('provider', { enum: ['stripe', 'qbo'] }), // null = offline
+ // Idempotency key. Stripe redelivers webhooks; the unique index below is the
+ // guard, in the database rather than in a handler someone can refactor.
+ providerRef: text('provider_ref'),
+ recordedBy: text('recorded_by'), // user id, null when automated
+ refundsId: text('refunds_id'), // kind='refund' -> the row it reverses
+ note: text('note'),
+ // When the money MOVED, not when the row was written — an inspector records
+ // Tuesday's cash on Thursday, and reporting periods key on the former.
+ occurredAt: integer('occurred_at', { mode: 'timestamp_ms' }).notNull(),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+}, (t) => [
+ // The order index is the one the deposit surfaces read — total / paid /
+ // remaining are answered per ORDER, whether or not an invoice exists yet.
+ index('idx_order_payments_inspection').on(t.tenantId, t.inspectionId),
+ index('idx_order_payments_invoice').on(t.tenantId, t.invoiceId),
+ // SQLite treats NULLs as DISTINCT in a unique index, so two offline rows
+ // (provider and provider_ref both NULL) never collide — which is correct
+ // here, a customer may hand over two identical $100 cash payments. The
+ // consequence to keep in mind: this index guards PROVIDER-BACKED rows only.
+ // An offline path that needs dedupe must bring its own key; do not assume
+ // this constraint is doing it.
+ uniqueIndex('uq_order_payments_provider_ref').on(t.tenantId, t.provider, t.providerRef),
+]);
+
+export type OrderPayment = typeof orderPayments.$inferSelect;
+export type NewOrderPayment = typeof orderPayments.$inferInsert;
From 2f6c49734274e5e496bdb94aaaa7ddd7b16d675a Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 19:11:56 +0800
Subject: [PATCH 066/111] feat(payments): one function that derives an
invoice's payment state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`recordPayment` appends to the ledger; `recomputeInvoicePaymentState` is the
only writer of `paid_at` / `partial_paid_at` / `amount_paid_cents`. Receipts
add, refunds subtract, and `amount_paid_cents` holds the CUMULATIVE amount
received — never a remaining balance, because `invoices.amount_cents` is the
authoritative total and the reader derives the remainder against it.
Three properties the tests pin, each proved to bite by running them against a
deliberately wrong implementation first:
- A refund moves an invoice back OUT of paid. The column model could not
express that at all; a naive sum reported 65000 where 25000 was received.
- The state comes from a SUM over the rows and the LATEST instant money
moved, not from whichever row the query returned last. The fixtures append
the later payment first, so a last-row implementation stamps the wrong
timestamp — it did, until it was fixed.
- An invoice with NO ledger rows is left alone. Recompute is not a bulldozer:
an invoice marked paid before the ledger existed would otherwise be zeroed,
erasing a real payment.
Redelivery is answered before the insert rather than by catching the unique
index, so the caller learns whether a row was actually appended; the index
still absorbs the race. Two identical offline cash rows stay two rows.
The order is resolved from the invoice when the caller does not supply one, and
a payment against a standalone invoice records with no order at all — the
invariant is "at least one of inspection_id / invoice_id", enforced here.
---
server/services/payment-ledger.service.ts | 186 +++++++++++++++++
tests/unit/invoices/payment-ledger.spec.ts | 229 +++++++++++++++++++++
2 files changed, 415 insertions(+)
create mode 100644 server/services/payment-ledger.service.ts
create mode 100644 tests/unit/invoices/payment-ledger.spec.ts
diff --git a/server/services/payment-ledger.service.ts b/server/services/payment-ledger.service.ts
new file mode 100644
index 000000000..b80fe1278
--- /dev/null
+++ b/server/services/payment-ledger.service.ts
@@ -0,0 +1,186 @@
+/**
+ * The payment ledger.
+ *
+ * A payment is a ROW here, never a column on the invoice. `order_payments` is
+ * append-only — a correction is a new row — and it records what was RECEIVED,
+ * never what is OWED: the total keeps coming from the money-authority chain
+ * (`getEffectivePriceCents()`), and these two functions are the ONLY writers of
+ * the invoice's derived payment state (`paid_at`, `partial_paid_at`,
+ * `amount_paid_cents`). A second writer does not fail any behavioural test; it
+ * just makes the cache disagree with the money weeks later.
+ *
+ * See spec 2026-08-01 payment/deposit flow §3.
+ */
+import { and, eq, isNotNull } from 'drizzle-orm';
+import type { DrizzleD1Database } from 'drizzle-orm/d1';
+import { orderPayments } from '../lib/db/schema/order-payment';
+import { invoices } from '../lib/db/schema/invoice';
+import { Errors } from '../lib/errors';
+
+/** Accepts the D1 drizzle instance in production and better-sqlite3 in tests. */
+type AnyDb = DrizzleD1Database> | { [k: string]: unknown };
+
+export type PaymentKind = 'deposit' | 'balance' | 'adjustment' | 'refund';
+export type PaymentMethodKind = 'card' | 'check' | 'cash' | 'offline' | 'other';
+export type PaymentProvider = 'stripe' | 'qbo';
+
+export interface PaymentEntry {
+ /** The order the money is against. Resolved from the invoice when omitted. */
+ inspectionId?: string | null;
+ /** Set once an invoice exists; a booking deposit predates one. */
+ invoiceId?: string | null;
+ kind: PaymentKind;
+ /** ALWAYS POSITIVE. Direction is carried by `kind`, never by the sign. */
+ amountCents: number;
+ method: PaymentMethodKind;
+ provider?: PaymentProvider | null;
+ /** Processor id — the idempotency key for a redelivered webhook. */
+ providerRef?: string | null;
+ recordedBy?: string | null;
+ refundsId?: string | null;
+ note?: string | null;
+ /** When the money MOVED, not when the row was written. Defaults to now. */
+ occurredAt?: Date;
+}
+
+/** Receipts add, refunds subtract. Nothing else is a direction. */
+const signOf = (kind: PaymentKind): 1 | -1 => (kind === 'refund' ? -1 : 1);
+
+/**
+ * Append one payment and refresh the invoice cache it affects.
+ *
+ * Returns `true` when a row was appended and `false` when the entry was a
+ * redelivery of one already recorded — the caller can log the difference, which
+ * is the whole reason this is not `void`.
+ */
+export async function recordPayment(
+ rawDb: AnyDb,
+ tenantId: string,
+ entry: PaymentEntry,
+): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const db = rawDb as any;
+
+ if (!Number.isInteger(entry.amountCents) || entry.amountCents <= 0) {
+ throw new Error('order_payments.amount_cents must be a positive integer; direction belongs in `kind`');
+ }
+
+ let inspectionId = entry.inspectionId ?? null;
+ const invoiceId = entry.invoiceId ?? null;
+
+ if (invoiceId) {
+ const inv = await db.select({ id: invoices.id, inspectionId: invoices.inspectionId })
+ .from(invoices)
+ .where(and(eq(invoices.tenantId, tenantId), eq(invoices.id, invoiceId)))
+ .get();
+ if (!inv) throw Errors.NotFound('Invoice not found');
+ // A standalone invoice has no order; that row is legitimately order-less.
+ inspectionId = inspectionId ?? (inv.inspectionId as string | null);
+ }
+
+ if (!inspectionId && !invoiceId) {
+ throw new Error('order_payments needs at least one of inspection_id / invoice_id');
+ }
+
+ // The unique index is the real guard, but it only covers provider-backed
+ // rows (SQLite treats NULLs as distinct), and hitting it would throw rather
+ // than answering "was this a redelivery?". Ask first, then let
+ // onConflictDoNothing absorb the race.
+ if (entry.provider && entry.providerRef) {
+ const dup = await db.select({ id: orderPayments.id }).from(orderPayments)
+ .where(and(
+ eq(orderPayments.tenantId, tenantId),
+ eq(orderPayments.provider, entry.provider),
+ eq(orderPayments.providerRef, entry.providerRef),
+ ))
+ .get();
+ if (dup) return false;
+ }
+
+ const now = new Date();
+ await db.insert(orderPayments).values({
+ id: crypto.randomUUID(),
+ tenantId,
+ inspectionId,
+ invoiceId,
+ kind: entry.kind,
+ amountCents: entry.amountCents,
+ method: entry.method,
+ provider: entry.provider ?? null,
+ providerRef: entry.providerRef ?? null,
+ recordedBy: entry.recordedBy ?? null,
+ refundsId: entry.refundsId ?? null,
+ note: entry.note ?? null,
+ occurredAt: entry.occurredAt ?? now,
+ createdAt: now,
+ }).onConflictDoNothing();
+
+ if (invoiceId) await recomputeInvoicePaymentState(db, tenantId, invoiceId);
+ return true;
+}
+
+/**
+ * Recompute an invoice's cached payment state from its ledger rows. THE ONLY
+ * writer of `paid_at` / `partial_paid_at` / `amount_paid_cents`.
+ *
+ * `amount_paid_cents` holds the CUMULATIVE amount received — receipts minus
+ * refunds — not a remaining balance: `invoices.amount_cents` is the
+ * authoritative total, so remaining is derived against it by the reader.
+ */
+export async function recomputeInvoicePaymentState(
+ rawDb: AnyDb,
+ tenantId: string,
+ invoiceId: string,
+): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const db = rawDb as any;
+
+ const inv = await db.select({ id: invoices.id, amountCents: invoices.amountCents })
+ .from(invoices)
+ .where(and(eq(invoices.tenantId, tenantId), eq(invoices.id, invoiceId)))
+ .get();
+ if (!inv) return;
+
+ // Explicit column projection, not select(): a wide invoice JOIN would run at
+ // D1's 100-column result cap, and we need three numbers.
+ const rows: Array<{ kind: PaymentKind; amountCents: number; occurredAt: Date | number | null }> =
+ await db.select({
+ kind: orderPayments.kind,
+ amountCents: orderPayments.amountCents,
+ occurredAt: orderPayments.occurredAt,
+ })
+ .from(orderPayments)
+ .where(and(
+ eq(orderPayments.tenantId, tenantId),
+ eq(orderPayments.invoiceId, invoiceId),
+ isNotNull(orderPayments.invoiceId),
+ ))
+ .all();
+
+ // No rows at all means the ledger has nothing to say about this invoice —
+ // NOT that nothing was paid. An invoice marked paid before the ledger
+ // existed is exactly that case, and zeroing it would erase a real payment.
+ if (rows.length === 0) return;
+
+ let netCents = 0;
+ let lastMovedAt = 0;
+ for (const r of rows) {
+ netCents += signOf(r.kind) * r.amountCents;
+ // The LATEST movement by when the money moved — not the last row the
+ // query happened to return. Rows arrive in insertion order, and an
+ // inspector records Tuesday's cash on Thursday.
+ const ms = r.occurredAt instanceof Date ? r.occurredAt.getTime() : Number(r.occurredAt ?? 0);
+ if (ms > lastMovedAt) lastMovedAt = ms;
+ }
+ const movedAt = new Date(lastMovedAt);
+
+ const total = inv.amountCents as number;
+ const paidInFull = total > 0 && netCents >= total;
+ const partiallyPaid = !paidInFull && netCents > 0;
+
+ await db.update(invoices).set({
+ paidAt: paidInFull ? movedAt : null,
+ partialPaidAt: partiallyPaid ? movedAt : null,
+ amountPaidCents: netCents,
+ }).where(and(eq(invoices.tenantId, tenantId), eq(invoices.id, invoiceId)));
+}
diff --git a/tests/unit/invoices/payment-ledger.spec.ts b/tests/unit/invoices/payment-ledger.spec.ts
new file mode 100644
index 000000000..442fee1d5
--- /dev/null
+++ b/tests/unit/invoices/payment-ledger.spec.ts
@@ -0,0 +1,229 @@
+/**
+ * The payment ledger — `order_payments` rows are the source of truth, and the
+ * invoice's paid_at / partial_paid_at / amount_paid_cents are a cache exactly
+ * one function writes.
+ *
+ * What these specs are actually guarding:
+ *
+ * 1. A REFUND can move an invoice back out of `paid`. That is the state the
+ * column model could not express at all — `markRefunded` had to null both
+ * timestamps and forget the money ever arrived — and it is why the ledger
+ * earns its keep.
+ * 2. The derived state comes from a SUM over the rows, not from whichever row
+ * was written last. The fixtures below are seeded in a deliberately adverse
+ * order (later money inserted first, earlier money after) so an
+ * implementation that reads "the last row" cannot pass by accident.
+ * 3. A redelivered webhook appends nothing. The unique index guards it in the
+ * database; the service must not double-count on the way there.
+ * 4. Two identical offline payments are two payments. SQLite treats NULLs as
+ * distinct in a unique index, so the same index that dedupes Stripe must
+ * not block a customer handing over $1 twice.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { and, eq } from 'drizzle-orm';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import { recordPayment, recomputeInvoicePaymentState } from '../../../server/services/payment-ledger.service';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const INSPECTION = 'insp-aaaaaaaa-0000-0000-0000-000000000001';
+const INV_ID = 'inv-aaaaaaaa-0000-0000-0000-000000000001';
+const STANDALONE_INV = 'inv-aaaaaaaa-0000-0000-0000-000000000002';
+
+/** Fixed instants so "which timestamp won" is assertable, not wall-clock luck. */
+const T1 = new Date('2026-03-01T10:00:00Z');
+const T2 = new Date('2026-03-05T10:00:00Z');
+const T3 = new Date('2026-03-09T10:00:00Z');
+
+let db: BetterSQLite3Database;
+
+beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.inspections).values({
+ id: INSPECTION, tenantId: TENANT, propertyAddress: '1 Oak St',
+ date: '2026-03-01', createdAt: T1,
+ });
+ await db.insert(schema.invoices).values([
+ {
+ id: INV_ID, tenantId: TENANT, inspectionId: INSPECTION, amountCents: 45000,
+ lineItems: [{ description: 'Inspection', amountCents: 45000 }],
+ sentAt: T1, createdAt: T1, currency: 'USD',
+ },
+ {
+ // No inspection: the New Invoice form submits null on a blank field,
+ // and a payment against one of these must still be recordable.
+ id: STANDALONE_INV, tenantId: TENANT, inspectionId: null, amountCents: 20000,
+ lineItems: [{ description: 'Consultation', amountCents: 20000 }],
+ sentAt: T1, createdAt: T1, currency: 'USD',
+ },
+ ]);
+});
+
+async function getInvoice(id = INV_ID) {
+ const row = await db.select().from(schema.invoices).where(eq(schema.invoices.id, id)).get();
+ if (!row) throw new Error('invoice not seeded');
+ return row;
+}
+
+async function ledgerRows(invoiceId = INV_ID) {
+ return db.select().from(schema.orderPayments)
+ .where(and(eq(schema.orderPayments.tenantId, TENANT), eq(schema.orderPayments.invoiceId, invoiceId)))
+ .all();
+}
+
+async function countLedgerRows(invoiceId = INV_ID) {
+ return (await ledgerRows(invoiceId)).length;
+}
+
+describe('payment ledger — recording', () => {
+ it('stamps the row with the order the invoice belongs to', async () => {
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 45000, method: 'card', occurredAt: T1 });
+ const [row] = await ledgerRows();
+ expect(row.inspectionId).toBe(INSPECTION);
+ expect(row.amountCents).toBe(45000); // always positive; direction is in `kind`
+ expect(row.kind).toBe('balance');
+ });
+
+ it('records a payment against a standalone invoice that has no order', async () => {
+ await recordPayment(db, TENANT, { invoiceId: STANDALONE_INV, kind: 'balance', amountCents: 20000, method: 'check', occurredAt: T1 });
+ const [row] = await ledgerRows(STANDALONE_INV);
+ expect(row.inspectionId).toBeNull();
+ expect((await getInvoice(STANDALONE_INV)).paidAt).not.toBeNull();
+ });
+
+ it('refuses a row that points at neither an order nor an invoice', async () => {
+ await expect(recordPayment(db, TENANT, { kind: 'deposit', amountCents: 100, method: 'cash' }))
+ .rejects.toThrow();
+ expect(await countLedgerRows()).toBe(0);
+ });
+
+ it('refuses a negative or zero amount — direction belongs in `kind`', async () => {
+ await expect(recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'refund', amountCents: -100, method: 'card' }))
+ .rejects.toThrow();
+ await expect(recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 0, method: 'card' }))
+ .rejects.toThrow();
+ expect(await countLedgerRows()).toBe(0);
+ });
+});
+
+describe('payment ledger — the derived invoice state', () => {
+ it('derives paid-in-full from the ledger', async () => {
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 45000, method: 'card', occurredAt: T1 });
+
+ const inv = await getInvoice();
+ expect(inv.paidAt).not.toBeNull();
+ expect(inv.partialPaidAt).toBeNull();
+ expect(inv.amountPaidCents).toBe(45000); // cumulative RECEIVED, not a remainder
+ });
+
+ it('derives partial from two rows that do not yet total the invoice', async () => {
+ // Adverse order: the LATER payment is appended FIRST, so a "read the last
+ // row" implementation would report 10000 and stamp the wrong instant.
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 15000, method: 'card', occurredAt: T3 });
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'deposit', amountCents: 10000, method: 'cash', occurredAt: T1 });
+
+ const inv = await getInvoice();
+ expect(inv.amountPaidCents).toBe(25000);
+ expect(inv.paidAt).toBeNull();
+ expect(inv.partialPaidAt?.getTime()).toBe(T3.getTime());
+ });
+
+ it('subtracts refunds and can move an invoice back out of paid', async () => {
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 45000, method: 'card', occurredAt: T1 });
+ expect((await getInvoice()).paidAt).not.toBeNull();
+
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'refund', amountCents: 20000, method: 'card', occurredAt: T2 });
+
+ const inv = await getInvoice();
+ expect(inv.amountPaidCents).toBe(25000);
+ expect(inv.paidAt).toBeNull(); // no longer paid in full
+ expect(inv.partialPaidAt).not.toBeNull();
+ });
+
+ it('lands on nothing received when the whole payment is refunded', async () => {
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 45000, method: 'card', occurredAt: T1 });
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'refund', amountCents: 45000, method: 'card', occurredAt: T2 });
+
+ const inv = await getInvoice();
+ expect(inv.amountPaidCents).toBe(0); // 0 received is a FACT; null would mean "unknown"
+ expect(inv.paidAt).toBeNull();
+ expect(inv.partialPaidAt).toBeNull();
+ });
+
+ it('counts an adjustment towards the total like any other receipt', async () => {
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'deposit', amountCents: 40000, method: 'cash', occurredAt: T1 });
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'adjustment', amountCents: 5000, method: 'other', occurredAt: T2 });
+
+ expect((await getInvoice()).paidAt).not.toBeNull();
+ });
+
+ it('leaves a legacy invoice alone when it has no ledger rows at all', async () => {
+ // Recompute is not a bulldozer: an invoice marked paid before the ledger
+ // existed has no rows, and zeroing it would erase a real payment.
+ await db.update(schema.invoices).set({ paidAt: T1, paymentMethod: 'check' })
+ .where(eq(schema.invoices.id, INV_ID));
+
+ await recomputeInvoicePaymentState(db, TENANT, INV_ID);
+
+ const inv = await getInvoice();
+ expect(inv.paidAt?.getTime()).toBe(T1.getTime());
+ expect(inv.amountPaidCents).toBeNull();
+ });
+
+ it('never reaches across tenants', async () => {
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 45000, method: 'card', occurredAt: T1 });
+ await recomputeInvoicePaymentState(db, 'some-other-tenant', INV_ID);
+
+ expect((await getInvoice()).amountPaidCents).toBe(45000); // untouched
+ });
+});
+
+describe('payment ledger — idempotency', () => {
+ it('is idempotent on a redelivered provider ref', async () => {
+ const entry = {
+ invoiceId: INV_ID, kind: 'balance' as const, amountCents: 45000, method: 'card' as const,
+ provider: 'stripe' as const, providerRef: 'pi_123', occurredAt: T1,
+ };
+ const first = await recordPayment(db, TENANT, entry);
+ const second = await recordPayment(db, TENANT, entry); // webhook redelivery
+
+ expect(first).toBe(true);
+ expect(second).toBe(false);
+ expect(await countLedgerRows()).toBe(1);
+ expect((await getInvoice()).amountPaidCents).toBe(45000);
+ });
+
+ it('never lets two offline rows collide', async () => {
+ // provider/providerRef are NULL for both; SQLite's NULL-distinct semantics
+ // must NOT be relied on to dedupe these, and must not block them either.
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'deposit', amountCents: 100, method: 'cash', occurredAt: T1 });
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'deposit', amountCents: 100, method: 'cash', occurredAt: T1 });
+
+ expect(await countLedgerRows()).toBe(2);
+ expect((await getInvoice()).amountPaidCents).toBe(200);
+ });
+
+ it('lets the same provider ref exist once per tenant', async () => {
+ await db.insert(schema.tenants).values({
+ id: 'tenant-two', name: 'Beta', slug: 'beta', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.invoices).values({
+ id: 'inv-other', tenantId: 'tenant-two', inspectionId: null, amountCents: 100,
+ lineItems: [], sentAt: T1, createdAt: T1, currency: 'USD',
+ });
+
+ await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 100, method: 'card', provider: 'stripe', providerRef: 'pi_shared', occurredAt: T1 });
+ const other = await recordPayment(db, 'tenant-two', { invoiceId: 'inv-other', kind: 'balance', amountCents: 100, method: 'card', provider: 'stripe', providerRef: 'pi_shared', occurredAt: T1 });
+
+ expect(other).toBe(true);
+ });
+});
From d7184c2c6825da7f5e7647b5d44f47f96c45e1c9 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 19:18:20 +0800
Subject: [PATCH 067/111] refactor(payments): route every payment write through
the ledger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Inventory of the writers of the derived payment columns before this change
(`git grep "paidAt:|partialPaidAt:|amountPaidCents:" -- server`, minus
projections, mappers and Zod fields):
- invoice.service.ts markPaid -> appends the outstanding remainder
- invoice.service.ts markPartial -> appends the delta to the reported total
- invoice.service.ts markRefunded -> appends a refund reversing what arrived
- invoice.service.ts createInvoice -> `.values({ paidAt: null })` on INSERT,
which is row creation, not a cache write, and stays
The Stripe webhook needed no change: it already goes through `markPaid`, so
rerouting the service rerouted it. Public signatures are unchanged so the QBO
webhook, the cron sweep and the routes are untouched.
A test now enforces it: nothing in server/ outside payment-ledger.service.ts
may `.set({...})` those columns. Proved to bite by putting a `paidAt` back into
markSent's UPDATE and watching it name invoice.service.ts.
Two decisions this owed:
- markPartial's amount is now REQUIRED. "Partial, amount unknown" existed
only because a column could not hold a history; a ledger always sums to a
known number. Making the parameter required makes that branch unreachable
rather than merely unused. Every caller already passed one.
- The public pay page keeps showing the FULL total. The Stripe intent is
minted server-side from the invoice total, so displaying a reduced balance
without reducing the charge would quote a price the payer is not charged.
Charge and display only move together, and moving them is payment-
COLLECTION behaviour — minimums, overpayment, what a second intent means —
which this plan enables but does not decide. Recorded at both surfaces.
Behaviour that did move, and the three tests that had to say so: a paid invoice
now reports the amount received rather than null, a refund lands on 0 received
rather than null, and a repeated partial sync appends nothing because what is
recorded is the delta. `markRefunded` seeds the ledger from the invoice's own
record first, so a refund still clears an invoice that was paid before the
ledger existed.
scripts/backfill-payment-ledger.mjs gives every legacy paid invoice that one
row, in a single idempotent statement, dry-run by default.
---
app/components/checkout/PayCard.tsx | 7 ++
.../portal/sections/InvoiceDisplay.tsx | 8 ++
scripts/backfill-payment-ledger.mjs | 76 ++++++++++++
server/services/invoice.service.ts | 107 ++++++++++++----
server/services/payment-ledger.service.ts | 116 +++++++++++++++---
tests/unit/invoices/partial-balance.spec.ts | 34 +++--
tests/unit/invoices/payment-ledger.spec.ts | 35 ++++++
7 files changed, 332 insertions(+), 51 deletions(-)
create mode 100644 scripts/backfill-payment-ledger.mjs
diff --git a/app/components/checkout/PayCard.tsx b/app/components/checkout/PayCard.tsx
index 483e9ca63..f7e7ecb87 100644
--- a/app/components/checkout/PayCard.tsx
+++ b/app/components/checkout/PayCard.tsx
@@ -61,6 +61,13 @@ export function PayCard({
{state === "todo" && invoice && !justPaid && (
0;
// IA-89 — Stripe has redirected back but the webhook has not settled the
diff --git a/scripts/backfill-payment-ledger.mjs b/scripts/backfill-payment-ledger.mjs
new file mode 100644
index 000000000..2a8b86fd6
--- /dev/null
+++ b/scripts/backfill-payment-ledger.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+/**
+ * Backfill the payment ledger from the invoice records that predate it.
+ *
+ * One `balance` row per PAID invoice, dated `paid_at`, method from
+ * `payment_method`, `provider` NULL, note 'backfilled from invoice record' —
+ * the same row `seedLedgerFromInvoiceRecord()` writes at runtime, so the script
+ * and the service cannot disagree about what a legacy invoice means.
+ *
+ * PARTIALLY-paid invoices get NO row. A legacy partial carries a timestamp and
+ * (before the amount column shipped) no figure; inventing one would fabricate a
+ * payment. They stay unrepresented and keep saying what they say today.
+ *
+ * Idempotent: an invoice that already has any ledger row is skipped, so a
+ * re-run appends nothing. Dry run by default — nothing is written without
+ * `--apply`.
+ *
+ * node scripts/backfill-payment-ledger.mjs # dry run, local D1
+ * node scripts/backfill-payment-ledger.mjs --apply # write, local D1
+ * node scripts/backfill-payment-ledger.mjs --apply --remote
+ *
+ * Production held a single paid invoice when this was written; it exists for
+ * self-hosted deploys, which have their own history.
+ */
+import { writeFileSync, unlinkSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { spawnSync } from 'node:child_process';
+
+const args = process.argv.slice(2);
+const apply = args.includes('--apply');
+const target = args.includes('--remote') ? '--remote' : '--local';
+
+/** Paid, not voided, has a positive total, and the ledger says nothing yet. */
+const CANDIDATES = `
+ FROM invoices i
+ WHERE i.paid_at IS NOT NULL
+ AND i.voided_at IS NULL
+ AND i.amount_cents > 0
+ AND NOT EXISTS (
+ SELECT 1 FROM order_payments p
+ WHERE p.tenant_id = i.tenant_id AND p.invoice_id = i.id
+ )`;
+
+const DRY_RUN_SQL = `SELECT count(*) AS invoices_to_backfill,
+ coalesce(sum(i.amount_cents), 0) AS cents_to_record${CANDIDATES};`;
+
+// randomblob(16) rather than a per-row round trip: the whole backfill is one
+// statement, so it cannot half-apply.
+const APPLY_SQL = `INSERT INTO order_payments (
+ id, tenant_id, inspection_id, invoice_id, kind, amount_cents, method,
+ provider, provider_ref, recorded_by, refunds_id, note, occurred_at, created_at
+)
+SELECT lower(hex(randomblob(16))), i.tenant_id, i.inspection_id, i.id, 'balance',
+ i.amount_cents, coalesce(i.payment_method, 'offline'),
+ NULL, NULL, NULL, NULL, 'backfilled from invoice record',
+ i.paid_at, ${Date.now()}${CANDIDATES};`;
+
+const sql = apply ? APPLY_SQL : DRY_RUN_SQL;
+const file = join(tmpdir(), `backfill-payment-ledger-${process.pid}.sql`);
+writeFileSync(file, sql, 'utf8');
+
+console.info(`[backfill-payment-ledger] ${apply ? 'APPLY' : 'DRY RUN'} against ${target} D1`);
+console.info(sql);
+
+try {
+ const r = spawnSync('node', [join(import.meta.dirname, 'wrangler.mjs'), 'd1', 'execute', 'DB', target, '--file', file], {
+ stdio: 'inherit',
+ shell: true,
+ });
+ process.exitCode = r.status ?? 0;
+} finally {
+ unlinkSync(file);
+}
+
+if (!apply) console.info('[backfill-payment-ledger] nothing written — re-run with --apply');
diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts
index 7b85af779..bc79cfca3 100644
--- a/server/services/invoice.service.ts
+++ b/server/services/invoice.service.ts
@@ -7,6 +7,12 @@ import { safeISODate } from '../lib/date';
import { AutomationService } from './automation.service';
import { logger } from '../lib/logger';
import type { PaymentMethod } from '../lib/payment-method';
+import {
+ recordPayment,
+ recomputeInvoicePaymentState,
+ getNetReceivedCents,
+ seedLedgerFromInvoiceRecord,
+} from './payment-ledger.service';
function getStatus(inv: { sentAt: Date | null; paidAt: Date | null; partialPaidAt?: Date | null; voidedAt?: Date | null }): 'draft' | 'sent' | 'paid' | 'partial' | 'void' {
if (inv.voidedAt) return 'void';
@@ -136,6 +142,11 @@ export class InvoiceService {
await db.update(invoices).set({ sentAt: new Date() }).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
}
+ /**
+ * Mark an invoice paid in full. Appends the outstanding remainder to the
+ * payment ledger; the invoice's paid/partial/amount columns are then
+ * recomputed from the ledger by its single writer, never set here.
+ */
async markPaid(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', method?: PaymentMethod): Promise {
const db = this.getDrizzle();
const existing = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))).get();
@@ -143,44 +154,94 @@ export class InvoiceService {
// Idempotency: webhooks redeliver. A paid invoice stays paid with its
// ORIGINAL timestamp — no double accounting, no date drift.
if (existing.paidAt) return;
- await db.update(invoices).set({
- paidAt: new Date(),
- partialPaidAt: null,
- // Paid in full leaves no residual partial amount; a stale value here
- // would let a paid invoice report an outstanding balance.
- amountPaidCents: null,
- // Record how it was paid; keep any existing value if the caller omits one.
- paymentMethod: method ?? existing.paymentMethod ?? null,
- }).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
+
+ // Record how it was paid; keep any existing value if the caller omits one.
+ const paymentMethod = method ?? existing.paymentMethod ?? null;
+ if (paymentMethod !== existing.paymentMethod) {
+ await db.update(invoices).set({ paymentMethod })
+ .where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
+ }
+
+ const outstanding = existing.amountCents - await getNetReceivedCents(db, tenantId, id);
+ if (outstanding > 0) {
+ await recordPayment(db, tenantId, {
+ invoiceId: id,
+ inspectionId: existing.inspectionId,
+ kind: 'balance',
+ amountCents: outstanding,
+ method: paymentMethod ?? 'offline',
+ });
+ } else {
+ // Nothing left to collect (a zero-total invoice, or the ledger
+ // already covers it) — the cache still has to catch up.
+ await recomputeInvoicePaymentState(db, tenantId, id);
+ }
void source; // consumed by route handler to decide QBO sync
}
/**
- * Record that an invoice is partially paid. `amountPaidCents` is what has
- * actually been RECEIVED, in integer cents; remaining is derived by the
+ * Record that an invoice is partially paid. `amountPaidCents` is the
+ * CUMULATIVE amount RECEIVED, in integer cents; remaining is derived by the
* caller as `amountCents - amountPaidCents` because the invoice total is
* the money authority, not any external system's view of it.
*
- * Omitting the amount means "partial, amount unknown" and clears any
- * previously captured figure — a number left over from an earlier sync is
- * not evidence of what is owed now.
+ * The amount is REQUIRED. It used to be optional, meaning "partial, amount
+ * unknown", which cleared any figure already captured. With a ledger there
+ * is no such state: every partial payment is one or more rows, and the sum
+ * of rows is always a known number. Making the parameter required is what
+ * makes that branch unreachable rather than merely unused — it cannot be
+ * called without one.
+ *
+ * The ledger row appended is the DELTA between the reported cumulative
+ * figure and what the ledger already holds, so a repeated sync of the same
+ * figure appends nothing and a figure that went DOWN records a refund.
*/
- async markPartial(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', amountPaidCents?: number): Promise {
+ async markPartial(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', amountPaidCents: number): Promise {
const db = this.getDrizzle();
- await db.update(invoices).set({
- partialPaidAt: new Date(),
- paidAt: null,
- amountPaidCents: amountPaidCents ?? null,
- }).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
- void source;
+ const existing = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))).get();
+ if (!existing) throw Errors.NotFound('Invoice not found');
+
+ const delta = amountPaidCents - await getNetReceivedCents(db, tenantId, id);
+ if (delta === 0) {
+ await recomputeInvoicePaymentState(db, tenantId, id);
+ return;
+ }
+ await recordPayment(db, tenantId, {
+ invoiceId: id,
+ inspectionId: existing.inspectionId,
+ kind: delta > 0 ? 'balance' : 'refund',
+ amountCents: Math.abs(delta),
+ method: existing.paymentMethod ?? 'other',
+ provider: source === 'qbo' ? 'qbo' : null,
+ });
}
+ /**
+ * Refund an invoice: appends a `refund` row reversing everything received,
+ * rather than nulling the columns. A fully refunded invoice therefore reads
+ * as "45000 received, 45000 refunded, 0 outstanding received" instead of a
+ * blank slate — more truthful, and the only version a reconciliation can
+ * check. An invoice paid before the ledger existed is seeded from its own
+ * record first, so there is something to reverse.
+ */
async markRefunded(id: string, tenantId: string): Promise {
const db = this.getDrizzle();
const existing = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))).get();
if (!existing) throw Errors.NotFound('Invoice not found');
- await db.update(invoices).set({ paidAt: null, partialPaidAt: null, amountPaidCents: null })
- .where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId)));
+
+ await seedLedgerFromInvoiceRecord(db, tenantId, id);
+ const received = await getNetReceivedCents(db, tenantId, id);
+ if (received > 0) {
+ await recordPayment(db, tenantId, {
+ invoiceId: id,
+ inspectionId: existing.inspectionId,
+ kind: 'refund',
+ amountCents: received,
+ method: existing.paymentMethod ?? 'other',
+ });
+ } else {
+ await recomputeInvoicePaymentState(db, tenantId, id);
+ }
await this.syncInspectionPaymentGate(existing.inspectionId, tenantId);
}
diff --git a/server/services/payment-ledger.service.ts b/server/services/payment-ledger.service.ts
index b80fe1278..7e27496d1 100644
--- a/server/services/payment-ledger.service.ts
+++ b/server/services/payment-ledger.service.ts
@@ -119,6 +119,93 @@ export async function recordPayment(
return true;
}
+/** Ledger rows for one invoice, projected to the three columns arithmetic needs. */
+async function ledgerRowsForInvoice(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ db: any,
+ tenantId: string,
+ invoiceId: string,
+): Promise> {
+ // Explicit column projection, not select(): a wide invoice JOIN would run at
+ // D1's 100-column result cap, and we need three numbers.
+ return db.select({
+ kind: orderPayments.kind,
+ amountCents: orderPayments.amountCents,
+ occurredAt: orderPayments.occurredAt,
+ })
+ .from(orderPayments)
+ .where(and(
+ eq(orderPayments.tenantId, tenantId),
+ eq(orderPayments.invoiceId, invoiceId),
+ isNotNull(orderPayments.invoiceId),
+ ))
+ .all();
+}
+
+/**
+ * Cumulative amount RECEIVED against an invoice — receipts minus refunds.
+ * What a caller needs to work out an outstanding remainder without guessing.
+ */
+export async function getNetReceivedCents(
+ rawDb: AnyDb,
+ tenantId: string,
+ invoiceId: string,
+): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const rows = await ledgerRowsForInvoice(rawDb as any, tenantId, invoiceId);
+ return rows.reduce((sum, r) => sum + signOf(r.kind) * r.amountCents, 0);
+}
+
+/**
+ * Give a PAID invoice that predates the ledger the one row its own record
+ * implies — the same row `scripts/backfill-payment-ledger.mjs` writes, so the
+ * runtime and the script cannot disagree about what a legacy invoice means.
+ *
+ * Does nothing when the ledger already has rows: the ledger, once it has an
+ * opinion, is the authority. No-op for unpaid, voided and zero-total invoices.
+ */
+export async function seedLedgerFromInvoiceRecord(
+ rawDb: AnyDb,
+ tenantId: string,
+ invoiceId: string,
+): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const db = rawDb as any;
+ const inv = await db.select({
+ id: invoices.id,
+ inspectionId: invoices.inspectionId,
+ amountCents: invoices.amountCents,
+ paidAt: invoices.paidAt,
+ voidedAt: invoices.voidedAt,
+ paymentMethod: invoices.paymentMethod,
+ })
+ .from(invoices)
+ .where(and(eq(invoices.tenantId, tenantId), eq(invoices.id, invoiceId)))
+ .get();
+ if (!inv || !inv.paidAt || inv.voidedAt || (inv.amountCents as number) <= 0) return;
+
+ const existing = await ledgerRowsForInvoice(db, tenantId, invoiceId);
+ if (existing.length > 0) return;
+
+ const paidAt = inv.paidAt instanceof Date ? inv.paidAt : new Date(Number(inv.paidAt));
+ await db.insert(orderPayments).values({
+ id: crypto.randomUUID(),
+ tenantId,
+ inspectionId: (inv.inspectionId as string | null) ?? null,
+ invoiceId,
+ kind: 'balance',
+ amountCents: inv.amountCents as number,
+ method: (inv.paymentMethod as PaymentMethodKind | null) ?? 'offline',
+ provider: null,
+ providerRef: null,
+ recordedBy: null,
+ refundsId: null,
+ note: 'backfilled from invoice record',
+ occurredAt: paidAt,
+ createdAt: new Date(),
+ }).onConflictDoNothing();
+}
+
/**
* Recompute an invoice's cached payment state from its ledger rows. THE ONLY
* writer of `paid_at` / `partial_paid_at` / `amount_paid_cents`.
@@ -141,26 +228,18 @@ export async function recomputeInvoicePaymentState(
.get();
if (!inv) return;
- // Explicit column projection, not select(): a wide invoice JOIN would run at
- // D1's 100-column result cap, and we need three numbers.
- const rows: Array<{ kind: PaymentKind; amountCents: number; occurredAt: Date | number | null }> =
- await db.select({
- kind: orderPayments.kind,
- amountCents: orderPayments.amountCents,
- occurredAt: orderPayments.occurredAt,
- })
- .from(orderPayments)
- .where(and(
- eq(orderPayments.tenantId, tenantId),
- eq(orderPayments.invoiceId, invoiceId),
- isNotNull(orderPayments.invoiceId),
- ))
- .all();
+ const total = inv.amountCents as number;
+ const rows = await ledgerRowsForInvoice(db, tenantId, invoiceId);
// No rows at all means the ledger has nothing to say about this invoice —
// NOT that nothing was paid. An invoice marked paid before the ledger
// existed is exactly that case, and zeroing it would erase a real payment.
- if (rows.length === 0) return;
+ //
+ // A ZERO-TOTAL invoice is the exception: there is no positive amount to
+ // append, so it can never acquire a row, and refusing to act would leave
+ // "mark this $0 invoice paid" permanently impossible — a regression against
+ // the column model this replaces.
+ if (rows.length === 0 && total > 0) return;
let netCents = 0;
let lastMovedAt = 0;
@@ -172,10 +251,9 @@ export async function recomputeInvoicePaymentState(
const ms = r.occurredAt instanceof Date ? r.occurredAt.getTime() : Number(r.occurredAt ?? 0);
if (ms > lastMovedAt) lastMovedAt = ms;
}
- const movedAt = new Date(lastMovedAt);
+ const movedAt = lastMovedAt > 0 ? new Date(lastMovedAt) : new Date();
- const total = inv.amountCents as number;
- const paidInFull = total > 0 && netCents >= total;
+ const paidInFull = netCents >= total;
const partiallyPaid = !paidInFull && netCents > 0;
await db.update(invoices).set({
diff --git a/tests/unit/invoices/partial-balance.spec.ts b/tests/unit/invoices/partial-balance.spec.ts
index af74c0bbd..936f0220c 100644
--- a/tests/unit/invoices/partial-balance.spec.ts
+++ b/tests/unit/invoices/partial-balance.spec.ts
@@ -139,31 +139,47 @@ describe('QBO partial payment — capturing the amount', () => {
expect(markPartial).toHaveBeenCalledWith(INV_ID, 25000, TENANT);
});
- it('clears the paid amount when the invoice is paid in full', async () => {
+ it('reads the whole amount received once the invoice is paid in full', async () => {
+ // The column now holds CUMULATIVE RECEIVED, summed from the payment
+ // ledger. It used to be nulled here, back when a figure could only ever
+ // describe a partial invoice; the ledger makes "paid, and here is how
+ // much arrived" expressible, and remaining is derived against
+ // amountCents, so a full figure states no outstanding balance.
await seedInvoice(45000);
await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
await invoiceSvc.markPaid(INV_ID, TENANT, 'qbo');
const inv = await getInvoice();
expect(inv.partialPaidAt).toBeNull();
- expect(inv.amountPaidCents).toBeNull(); // no stale residue on a paid invoice
+ expect(inv.amountPaidCents).toBe(45000);
+ expect(await remainingCents()).toBe(0);
});
- it('clears it on refund too', async () => {
+ it('drops back to nothing received on a refund', async () => {
+ // A refund is a ledger row reversing what arrived, not an erasure of the
+ // fact that it did: 0 RECEIVED, which is a figure — not null, which is
+ // "unknown".
await seedInvoice(45000);
await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
await invoiceSvc.markRefunded(INV_ID, TENANT);
- expect((await getInvoice()).amountPaidCents).toBeNull();
+ const inv = await getInvoice();
+ expect(inv.amountPaidCents).toBe(0);
+ expect(inv.paidAt).toBeNull();
+ expect(inv.partialPaidAt).toBeNull();
});
- it('leaves no stale amount when a later partial sync cannot say how much', async () => {
- // markPartial without an amount means "partial, amount unknown" — it must
- // not leave the previous figure standing as if it were current.
+ it('does not double-count a partial sync that repeats the same figure', async () => {
+ // QBO reports a CUMULATIVE amount and the sync runs on every webhook and
+ // every cron sweep. What is appended is the delta, so replaying the same
+ // figure appends nothing — and a figure that went down records a refund.
await seedInvoice(45000);
await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
- await invoiceSvc.markPartial(INV_ID, TENANT, 'oi');
+ await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
+
+ expect((await getInvoice()).amountPaidCents).toBe(25000);
- expect((await getInvoice()).amountPaidCents).toBeNull();
+ await syncFromQbo({ Id: QBO_ID, Balance: 350, TotalAmt: 450 });
+ expect((await getInvoice()).amountPaidCents).toBe(10000);
});
});
diff --git a/tests/unit/invoices/payment-ledger.spec.ts b/tests/unit/invoices/payment-ledger.spec.ts
index 442fee1d5..916d9b6c9 100644
--- a/tests/unit/invoices/payment-ledger.spec.ts
+++ b/tests/unit/invoices/payment-ledger.spec.ts
@@ -20,6 +20,8 @@
* not block a customer handing over $1 twice.
*/
import { describe, it, expect, beforeEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
import { and, eq } from 'drizzle-orm';
import * as schema from '../../../server/lib/db/schema';
import { createTestDb, setupSchema } from '../db';
@@ -186,6 +188,39 @@ describe('payment ledger — the derived invoice state', () => {
});
});
+describe('payment ledger — one writer', () => {
+ it('is the only thing in server/ that writes the derived payment columns', () => {
+ // The regression this whole task exists to avoid. A second writer fails
+ // no behavioural test — it just makes the cache disagree with the money
+ // weeks later, which is the expensive kind of wrong.
+ //
+ // Scoped to `.set({...})`, i.e. UPDATEs of an existing invoice. Creating
+ // a row with `paidAt: null` in its `.values()` is not a cache write, and
+ // neither is a SELECT projection or a Zod field of the same name — which
+ // is why a bare identifier grep would be all noise.
+ const serverDir = path.resolve(__dirname, '../../../server');
+ const allowed = ['services/payment-ledger.service.ts'];
+ const offenders: string[] = [];
+
+ const walk = (dir: string) => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) { walk(full); continue; }
+ if (!e.name.endsWith('.ts')) continue;
+ const rel = path.relative(serverDir, full).split(path.sep).join('/');
+ if (allowed.includes(rel)) continue;
+ const src = fs.readFileSync(full, 'utf8');
+ for (const block of src.match(/\.set\(\{[\s\S]*?\}\)/g) ?? []) {
+ if (/\b(paidAt|partialPaidAt|amountPaidCents)\s*:/.test(block)) offenders.push(rel);
+ }
+ }
+ };
+ walk(serverDir);
+
+ expect([...new Set(offenders)]).toEqual([]);
+ });
+});
+
describe('payment ledger — idempotency', () => {
it('is idempotent on a redelivered provider ref', async () => {
const entry = {
From 61c3813de4f0e950bd2a7a693c15ac614e22f76f Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 19:42:19 +0800
Subject: [PATCH 068/111] fix(qbo): push the payment that happened, not the
invoice total
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both push sites sent `invoice.amountCents` keyed by the invoice id. That is
correct only while payment is all-or-nothing. On a $450 inspection with a $90
deposit it is wrong twice: the amount is $450 when $360 arrived, and the key
makes the deposit and the balance one fact to QuickBooks, so the second push
returns the first one's response instead of booking anything.
The ledger already knows both numbers, so `recordPayment` now returns the row it
appended instead of a boolean, `markPaid`/`markPartial` pass it up, and the push
takes its amount and its idempotency key from that row. Only the ledger knows
whether a row came into existence, which is also the point: an append that did
not happen — a redelivery, an already-paid invoice, rows the backfill wrote
straight to D1 — now pushes nothing at all rather than pushing again and
trusting `requestid` to absorb it. Re-pushing a backfilled row would double a
tenant's recorded revenue, and they would find it at tax time.
The three CDC/webhook adapters drop the returned row explicitly: QuickBooks is
where those figures came from, so pushing them back would book them twice.
Moving the key off `pay-${invoiceId}` is safe because an invoice could be pushed
at most once under it: `markPaid` appends nothing once `paid_at` is set, so
nothing settled under the old key can produce a row to re-push under a new one.
---
server/api/invoices.ts | 24 ++-
server/api/qbo-webhook.ts | 7 +-
server/api/qbo.ts | 6 +-
server/api/stripe-webhook.ts | 20 +-
server/lib/qbo-payment-key.ts | 31 ++-
server/scheduled.ts | 6 +-
server/services/invoice.service.ts | 36 ++--
server/services/payment-ledger.service.ts | 34 ++-
tests/unit/invoices/payment-ledger.spec.ts | 10 +-
tests/unit/qbo/payment-push-amount.spec.ts | 238 +++++++++++++++++++++
tests/unit/qbo/payment-push.spec.ts | 24 ++-
11 files changed, 371 insertions(+), 65 deletions(-)
create mode 100644 tests/unit/qbo/payment-push-amount.spec.ts
diff --git a/server/api/invoices.ts b/server/api/invoices.ts
index 25a347b15..2a9b58c1b 100644
--- a/server/api/invoices.ts
+++ b/server/api/invoices.ts
@@ -181,23 +181,27 @@ const invoiceRoutes = createApiRouter()
const { method } = c.req.valid('json');
const tenantId = c.get('tenantId');
const paymentMethod = normalizePaymentMethod(method);
- await c.var.services.invoice.markPaid(id, tenantId, 'oi', paymentMethod);
+ const appended = await c.var.services.invoice.markPaid(id, tenantId, 'oi', paymentMethod);
- const inv = (await c.var.services.invoice.listInvoices(tenantId)).find(
- (i: Awaited>[number]) => i.id === id,
- );
+ const inv = await c.var.services.invoice.findById(tenantId, id);
// Manual payment must also close the report's payment gate (markPaid only
// touches the invoice row; the gate reads inspections.paymentStatus).
if (inv?.inspectionId) {
await c.var.services.inspection.markPaymentReceived(tenantId, inv.inspectionId);
}
- if (c.env.QBO_CLIENT_ID && inv) {
+ // What goes to QuickBooks is the ROW that was appended — the remainder
+ // collected on this occasion — not the invoice total. Once a $90 deposit
+ // exists on a $450 invoice, pushing the total books $450 against an
+ // invoice that only just received $360, and the deposit push already
+ // there makes $540 of recorded revenue out of $450 of money.
+ //
+ // No row appended (already paid, or the ledger already covers it) means
+ // nothing happened, so there is nothing to tell them about.
+ if (c.env.QBO_CLIENT_ID && appended) {
c.executionCtx.waitUntil(
- // Same key the Stripe webhook uses, deliberately: an invoice
- // settled online and then also marked paid by hand is ONE
- // payment, and QBO collapses the second push on the repeated
- // requestid instead of booking it twice.
- c.var.services.qbo.recordPayment(tenantId, id, inv.amountCents / 100, qboPaymentKey(id)),
+ c.var.services.qbo.recordPayment(
+ tenantId, id, appended.amountCents / 100, qboPaymentKey(appended.id),
+ ),
);
}
return c.json({ success: true }, 200);
diff --git a/server/api/qbo-webhook.ts b/server/api/qbo-webhook.ts
index 374abaffc..febdbe64e 100644
--- a/server/api/qbo-webhook.ts
+++ b/server/api/qbo-webhook.ts
@@ -23,8 +23,11 @@ api.post('/', async (c) => {
svc.handleWebhook(
rawBody,
headerSig,
- (invoiceId, tenantId) => invoiceSvc.markPaid(invoiceId, tenantId, 'qbo'),
- (invoiceId, amountPaidCents, tenantId) => invoiceSvc.markPartial(invoiceId, tenantId, 'qbo', amountPaidCents),
+ // The appended row is deliberately dropped on the INBOUND path:
+ // QuickBooks told us about this money, so pushing it back to them
+ // would book it a second time.
+ async (invoiceId, tenantId) => { await invoiceSvc.markPaid(invoiceId, tenantId, 'qbo'); },
+ async (invoiceId, amountPaidCents, tenantId) => { await invoiceSvc.markPartial(invoiceId, tenantId, 'qbo', amountPaidCents); },
).then(({ valid }) => {
if (!valid) logger.info('QBO webhook: signature mismatch — discarded');
}).catch(e => {
diff --git a/server/api/qbo.ts b/server/api/qbo.ts
index 61516b68d..c82823a65 100644
--- a/server/api/qbo.ts
+++ b/server/api/qbo.ts
@@ -128,8 +128,10 @@ api.post('/sync', async (c) => {
c.executionCtx.waitUntil(
svc.runCDCSync(
tenantId,
- (invoiceId, tid) => invoiceSvc.markPaid(invoiceId, tid, 'qbo'),
- (invoiceId, amountPaidCents, tid) => invoiceSvc.markPartial(invoiceId, tid, 'qbo', amountPaidCents),
+ // Inbound: the row appended is dropped on purpose — QuickBooks is
+ // where this figure came from, so it must not be pushed back.
+ async (invoiceId, tid) => { await invoiceSvc.markPaid(invoiceId, tid, 'qbo'); },
+ async (invoiceId, amountPaidCents, tid) => { await invoiceSvc.markPartial(invoiceId, tid, 'qbo', amountPaidCents); },
),
);
return c.json({ success: true, data: { message: 'Sync started' } });
diff --git a/server/api/stripe-webhook.ts b/server/api/stripe-webhook.ts
index f61de5b32..dabb4bf19 100644
--- a/server/api/stripe-webhook.ts
+++ b/server/api/stripe-webhook.ts
@@ -82,8 +82,9 @@ api.post('/', async (c) => {
return c.json({ success: true }); // ACK: a retry can never succeed
}
+ let appended: Awaited> = null;
try {
- await c.var.services.invoice.markPaid(settled.invoiceId, tenantId, 'oi', 'card');
+ appended = await c.var.services.invoice.markPaid(settled.invoiceId, tenantId, 'oi', 'card');
if (settled.inspectionId) {
await c.var.services.inspection.markPaymentReceived(tenantId, settled.inspectionId);
}
@@ -103,16 +104,19 @@ api.post('/', async (c) => {
//
// In waitUntil, and deliberately after the ACK path is settled: the customer
// has already paid, and a QuickBooks outage must not turn a successful
- // payment into a 500 that Stripe redelivers forever. `markPaid` above is
- // idempotent on our side; the requestid is what keeps their side clean when
- // Stripe redelivers anyway.
- if (c.env.QBO_CLIENT_ID) {
+ // payment into a 500 that Stripe redelivers forever.
+ //
+ // The amount and the key both come from the ledger row `markPaid` appended,
+ // never from the invoice: the card settled the REMAINDER, which is the whole
+ // total only when no deposit was taken. A redelivery appends nothing and
+ // therefore pushes nothing — the requestid stays as the second line of
+ // defence rather than the only one.
+ if (c.env.QBO_CLIENT_ID && appended) {
+ const push = appended;
c.executionCtx.waitUntil((async () => {
try {
- const inv = await c.var.services.invoice.findById(tenantId, settled.invoiceId);
- if (!inv) return;
await c.var.services.qbo.recordPayment(
- tenantId, settled.invoiceId, inv.amountCents / 100, qboPaymentKey(settled.invoiceId),
+ tenantId, settled.invoiceId, push.amountCents / 100, qboPaymentKey(push.id),
);
} catch (e) {
logger.error('Stripe webhook: QBO payment push failed',
diff --git a/server/lib/qbo-payment-key.ts b/server/lib/qbo-payment-key.ts
index a81e0daf8..c351fb273 100644
--- a/server/lib/qbo-payment-key.ts
+++ b/server/lib/qbo-payment-key.ts
@@ -4,17 +4,26 @@
* QBO's `requestid` returns the ORIGINAL response for a repeated key rather
* than performing the operation again, and keys are unique per company
* FOREVER. So the key has to identify the fact — the thing that happened once —
- * and never the attempt. Both push sites (the manual "mark as paid" route and
- * the Stripe webhook) derive it from here so they agree: one invoice settled
- * online and then also marked paid by hand is one payment, not two.
+ * and never the attempt.
*
- * Today the fact is the invoice, because payment is all-or-nothing. When a
- * payment ledger exists the fact becomes the ledger ROW — a $90 deposit and a
- * $360 balance are two payments against one invoice — and this function changes
- * to take the row id. Every caller must move in that same change: an invoice
- * pushed under the old key and re-pushed under a new one is a duplicate in
- * someone's books, and they would find it at tax time rather than in a test.
+ * The fact is the ledger ROW, not the invoice. A $90 deposit and a $360 balance
+ * are two payments against one invoice: keyed by the invoice they would collapse
+ * into one in the tenant's books, losing $90 of real revenue, and the second
+ * would carry the wrong amount besides. Both push sites (the manual "mark as
+ * paid" route and the Stripe webhook) derive the key from here so they agree —
+ * and both take it from the row `recordPayment` RETURNED, never from their own
+ * arguments, so an append that did not happen cannot push.
+ *
+ * A row id is a UUID, so the key is 40 characters — well inside QBO's limit, and
+ * deliberately not a concatenation of everything available.
+ *
+ * ⚠️ Moving off the older `pay-${invoiceId}` derivation is safe only because an
+ * invoice could be pushed at most once under it: `markPaid` returns without
+ * appending when `paid_at` is already set, so an invoice settled under the old
+ * key never produces a row to re-push under a new one. Any future change to this
+ * derivation has to re-establish that, or it duplicates payments in someone's
+ * books and they find it at tax time rather than in a test.
*/
-export function qboPaymentKey(invoiceId: string): string {
- return `pay-${invoiceId}`;
+export function qboPaymentKey(paymentRowId: string): string {
+ return `pay-${paymentRowId}`;
}
diff --git a/server/scheduled.ts b/server/scheduled.ts
index f238b1ccc..0eade93a6 100644
--- a/server/scheduled.ts
+++ b/server/scheduled.ts
@@ -81,8 +81,10 @@ async function runQBOCDC(env: ScheduledEnv): Promise {
try {
const { processed } = await svc.runCDCSync(
conn.tenantId,
- (invoiceId, tid) => invoiceSvc.markPaid(invoiceId, tid, 'qbo'),
- (invoiceId, amountPaidCents, tid) => invoiceSvc.markPartial(invoiceId, tid, 'qbo', amountPaidCents),
+ // Inbound: the row appended is dropped on purpose — QuickBooks
+ // is where this figure came from, so it must not be pushed back.
+ async (invoiceId, tid) => { await invoiceSvc.markPaid(invoiceId, tid, 'qbo'); },
+ async (invoiceId, amountPaidCents, tid) => { await invoiceSvc.markPartial(invoiceId, tid, 'qbo', amountPaidCents); },
);
if (processed > 0) logger.info('[cron:qbo] CDC processed invoices', { tenantId: conn.tenantId, processed });
} catch (e) {
diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts
index bc79cfca3..4fd427dcf 100644
--- a/server/services/invoice.service.ts
+++ b/server/services/invoice.service.ts
@@ -13,6 +13,7 @@ import {
getNetReceivedCents,
seedLedgerFromInvoiceRecord,
} from './payment-ledger.service';
+import type { AppendedPayment } from './payment-ledger.service';
function getStatus(inv: { sentAt: Date | null; paidAt: Date | null; partialPaidAt?: Date | null; voidedAt?: Date | null }): 'draft' | 'sent' | 'paid' | 'partial' | 'void' {
if (inv.voidedAt) return 'void';
@@ -146,14 +147,22 @@ export class InvoiceService {
* Mark an invoice paid in full. Appends the outstanding remainder to the
* payment ledger; the invoice's paid/partial/amount columns are then
* recomputed from the ledger by its single writer, never set here.
+ *
+ * Returns the ledger row appended, or `null` when nothing was — an already
+ * paid invoice, or one the ledger already covers. A caller pushing to an
+ * external book of record must use that row's amount and id: the amount is
+ * the REMAINDER collected on this occasion, which stops being the invoice
+ * total the moment a deposit exists.
*/
- async markPaid(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', method?: PaymentMethod): Promise {
+ async markPaid(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', method?: PaymentMethod): Promise {
const db = this.getDrizzle();
const existing = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))).get();
if (!existing) throw Errors.NotFound('Invoice not found');
// Idempotency: webhooks redeliver. A paid invoice stays paid with its
- // ORIGINAL timestamp — no double accounting, no date drift.
- if (existing.paidAt) return;
+ // ORIGINAL timestamp — no double accounting, no date drift. Returning
+ // null here is also what keeps a redelivery out of QuickBooks entirely,
+ // rather than relying on their side to collapse a repeated requestid.
+ if (existing.paidAt) return null;
// Record how it was paid; keep any existing value if the caller omits one.
const paymentMethod = method ?? existing.paymentMethod ?? null;
@@ -163,20 +172,20 @@ export class InvoiceService {
}
const outstanding = existing.amountCents - await getNetReceivedCents(db, tenantId, id);
+ void source; // consumed by route handler to decide QBO sync
if (outstanding > 0) {
- await recordPayment(db, tenantId, {
+ return recordPayment(db, tenantId, {
invoiceId: id,
inspectionId: existing.inspectionId,
kind: 'balance',
amountCents: outstanding,
method: paymentMethod ?? 'offline',
});
- } else {
- // Nothing left to collect (a zero-total invoice, or the ledger
- // already covers it) — the cache still has to catch up.
- await recomputeInvoicePaymentState(db, tenantId, id);
}
- void source; // consumed by route handler to decide QBO sync
+ // Nothing left to collect (a zero-total invoice, or the ledger
+ // already covers it) — the cache still has to catch up.
+ await recomputeInvoicePaymentState(db, tenantId, id);
+ return null;
}
/**
@@ -195,8 +204,11 @@ export class InvoiceService {
* The ledger row appended is the DELTA between the reported cumulative
* figure and what the ledger already holds, so a repeated sync of the same
* figure appends nothing and a figure that went DOWN records a refund.
+ *
+ * Returns the appended row (or `null` when the figure had not moved) on the
+ * same contract as `markPaid`.
*/
- async markPartial(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', amountPaidCents: number): Promise {
+ async markPartial(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', amountPaidCents: number): Promise {
const db = this.getDrizzle();
const existing = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))).get();
if (!existing) throw Errors.NotFound('Invoice not found');
@@ -204,9 +216,9 @@ export class InvoiceService {
const delta = amountPaidCents - await getNetReceivedCents(db, tenantId, id);
if (delta === 0) {
await recomputeInvoicePaymentState(db, tenantId, id);
- return;
+ return null;
}
- await recordPayment(db, tenantId, {
+ return recordPayment(db, tenantId, {
invoiceId: id,
inspectionId: existing.inspectionId,
kind: delta > 0 ? 'balance' : 'refund',
diff --git a/server/services/payment-ledger.service.ts b/server/services/payment-ledger.service.ts
index 7e27496d1..67ff05ded 100644
--- a/server/services/payment-ledger.service.ts
+++ b/server/services/payment-ledger.service.ts
@@ -43,21 +43,37 @@ export interface PaymentEntry {
occurredAt?: Date;
}
+/**
+ * The row that was actually appended. This is the unit an external book of
+ * record is told about: its `amountCents` is what moved on this occasion — not
+ * the invoice total — and its `id` names the fact, so a retry of the same
+ * append pushes under the same key instead of booking a second payment.
+ */
+export interface AppendedPayment {
+ id: string;
+ kind: PaymentKind;
+ /** ALWAYS POSITIVE, like `PaymentEntry.amountCents`. */
+ amountCents: number;
+ occurredAt: Date;
+}
+
/** Receipts add, refunds subtract. Nothing else is a direction. */
const signOf = (kind: PaymentKind): 1 | -1 => (kind === 'refund' ? -1 : 1);
/**
* Append one payment and refresh the invoice cache it affects.
*
- * Returns `true` when a row was appended and `false` when the entry was a
- * redelivery of one already recorded — the caller can log the difference, which
- * is the whole reason this is not `void`.
+ * Returns the appended row, or `null` when the entry was a redelivery of one
+ * already recorded — which is the whole reason this is not `void`. A caller
+ * that pushes to an external ledger must key that push off the RETURN VALUE and
+ * never off its own arguments: only this function knows whether a row came into
+ * existence, and only the row it returns carries the amount that moved.
*/
export async function recordPayment(
rawDb: AnyDb,
tenantId: string,
entry: PaymentEntry,
-): Promise {
+): Promise {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = rawDb as any;
@@ -94,12 +110,14 @@ export async function recordPayment(
eq(orderPayments.providerRef, entry.providerRef),
))
.get();
- if (dup) return false;
+ if (dup) return null;
}
const now = new Date();
+ const id = crypto.randomUUID();
+ const occurredAt = entry.occurredAt ?? now;
await db.insert(orderPayments).values({
- id: crypto.randomUUID(),
+ id,
tenantId,
inspectionId,
invoiceId,
@@ -111,12 +129,12 @@ export async function recordPayment(
recordedBy: entry.recordedBy ?? null,
refundsId: entry.refundsId ?? null,
note: entry.note ?? null,
- occurredAt: entry.occurredAt ?? now,
+ occurredAt,
createdAt: now,
}).onConflictDoNothing();
if (invoiceId) await recomputeInvoicePaymentState(db, tenantId, invoiceId);
- return true;
+ return { id, kind: entry.kind, amountCents: entry.amountCents, occurredAt };
}
/** Ledger rows for one invoice, projected to the three columns arithmetic needs. */
diff --git a/tests/unit/invoices/payment-ledger.spec.ts b/tests/unit/invoices/payment-ledger.spec.ts
index 916d9b6c9..3bbd0f587 100644
--- a/tests/unit/invoices/payment-ledger.spec.ts
+++ b/tests/unit/invoices/payment-ledger.spec.ts
@@ -230,8 +230,12 @@ describe('payment ledger — idempotency', () => {
const first = await recordPayment(db, TENANT, entry);
const second = await recordPayment(db, TENANT, entry); // webhook redelivery
- expect(first).toBe(true);
- expect(second).toBe(false);
+ // The return value is the appended ROW, because an external book of
+ // record has to be told the amount that moved and keyed on the fact —
+ // and told nothing at all when the fact turns out to be a redelivery.
+ expect(first).toMatchObject({ kind: 'balance', amountCents: 45000, occurredAt: T1 });
+ expect(first?.id).toEqual(expect.any(String));
+ expect(second).toBeNull();
expect(await countLedgerRows()).toBe(1);
expect((await getInvoice()).amountPaidCents).toBe(45000);
});
@@ -259,6 +263,6 @@ describe('payment ledger — idempotency', () => {
await recordPayment(db, TENANT, { invoiceId: INV_ID, kind: 'balance', amountCents: 100, method: 'card', provider: 'stripe', providerRef: 'pi_shared', occurredAt: T1 });
const other = await recordPayment(db, 'tenant-two', { invoiceId: 'inv-other', kind: 'balance', amountCents: 100, method: 'card', provider: 'stripe', providerRef: 'pi_shared', occurredAt: T1 });
- expect(other).toBe(true);
+ expect(other).toMatchObject({ amountCents: 100 });
});
});
diff --git a/tests/unit/qbo/payment-push-amount.spec.ts b/tests/unit/qbo/payment-push-amount.spec.ts
new file mode 100644
index 000000000..86831241c
--- /dev/null
+++ b/tests/unit/qbo/payment-push-amount.spec.ts
@@ -0,0 +1,238 @@
+/**
+ * What goes to QuickBooks is the payment, not the invoice.
+ *
+ * Both push sites used to send `invoice.amountCents` — the TOTAL — keyed by the
+ * invoice id. That is correct only while payment is all-or-nothing. With a
+ * ledger it is wrong twice over on a $450 inspection with a $90 deposit:
+ *
+ * - the amount is $450 when $360 arrived, and the deposit push already there
+ * makes $540 of recorded revenue out of $450 of money;
+ * - the key is the invoice, so the deposit and the balance are the same "fact"
+ * to QuickBooks, and the second one silently returns the first one's
+ * response instead of booking anything.
+ *
+ * The fix is that both sites push the row `markPaid` RETURNED: its amount is
+ * what moved on this occasion, and its id names the fact. Which also means an
+ * append that did not happen — a redelivery, an already-paid invoice, rows the
+ * backfill wrote straight to D1 — pushes nothing at all, rather than pushing
+ * again and trusting QBO's requestid to absorb it.
+ *
+ * These specs drive the REAL mounted routes against in-memory SQLite with a
+ * real InvoiceService, so the amount and the key are the ones production
+ * computes. `services.qbo` is the spy — the lowest-level seam that still fails
+ * when the wrong number goes on the wire.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { and, eq } from 'drizzle-orm';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+
+const verifyWebhook = vi.fn();
+vi.mock('../../../server/services/stripe.service', () => ({
+ StripeService: class { constructor(_k: string) { void _k; } verifyWebhook = verifyWebhook; },
+}));
+
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import { Hono } from 'hono';
+import invoiceRoutes from '../../../server/api/invoices';
+import stripeWebhookApi from '../../../server/api/stripe-webhook';
+import { InvoiceService } from '../../../server/services/invoice.service';
+import { AppError } from '../../../server/lib/errors';
+import type { HonoConfig } from '../../../server/types/hono';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const INSPECTION = 'insp-aaaaaaaa-0000-0000-0000-000000000001';
+const INV_ID = 'inv-aaaaaaaa-0000-0000-0000-000000000001';
+const TOTAL_CENTS = 45000;
+const DEPOSIT_CENTS = 9000;
+
+/** Fixed instants so nothing here depends on wall-clock ordering. */
+const T1 = new Date('2026-03-01T10:00:00Z');
+const T2 = new Date('2026-03-05T10:00:00Z');
+
+let db: BetterSQLite3Database;
+let recordPaymentSpy: ReturnType;
+
+/** `[amountInDollars, requestKey]` for every payment push that was attempted. */
+const pushes = (): Array<[number, string]> =>
+ recordPaymentSpy.mock.calls.map((c) => [c[2] as number, c[3] as string]);
+
+beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+ recordPaymentSpy = vi.fn().mockResolvedValue(undefined);
+ verifyWebhook.mockReset();
+
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: T1,
+ });
+ await db.insert(schema.inspections).values({
+ id: INSPECTION, tenantId: TENANT, propertyAddress: '1 Oak St',
+ date: '2026-03-01', createdAt: T1,
+ });
+ await db.insert(schema.invoices).values({
+ id: INV_ID, tenantId: TENANT, inspectionId: INSPECTION, amountCents: TOTAL_CENTS,
+ lineItems: [{ description: 'Inspection', amountCents: TOTAL_CENTS }],
+ sentAt: T1, createdAt: T1, currency: 'USD',
+ });
+});
+
+/** A deposit already collected — the state that makes the total the wrong number. */
+async function seedDeposit() {
+ await db.insert(schema.orderPayments).values({
+ id: 'pay-deposit-0000-0000-0000-000000000001',
+ tenantId: TENANT, inspectionId: INSPECTION, invoiceId: INV_ID,
+ kind: 'deposit', amountCents: DEPOSIT_CENTS, method: 'cash',
+ occurredAt: T1, createdAt: T1,
+ });
+ await db.update(schema.invoices).set({ partialPaidAt: T1, amountPaidCents: DEPOSIT_CENTS })
+ .where(eq(schema.invoices.id, INV_ID));
+}
+
+/** The ledger row the invoice acquired during this test, whatever its id is. */
+async function appendedBalanceRow() {
+ const rows = await db.select().from(schema.orderPayments)
+ .where(and(eq(schema.orderPayments.invoiceId, INV_ID), eq(schema.orderPayments.kind, 'balance')))
+ .all();
+ return rows[0] ?? null;
+}
+
+// --- the manual "mark as paid" route --------------------------------------
+
+const ENV = { DB: {}, QBO_CLIENT_ID: 'qbo-client', JWT_SECRET: 'test-jwt-secret' } as never;
+
+function markPaid(method = 'check') {
+ const settled: Promise[] = [];
+ const app = new OpenAPIHono();
+ app.use('*', async (c, next) => {
+ c.set('userRole', 'manager' as never);
+ c.set('tenantId', TENANT);
+ c.set('services', {
+ invoice: new InvoiceService({} as D1Database),
+ inspection: { markPaymentReceived: vi.fn().mockResolvedValue(undefined) },
+ qbo: { recordPayment: recordPaymentSpy },
+ } as never);
+ await next();
+ });
+ app.route('/api/invoices', invoiceRoutes);
+ app.onError((err, c) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status as never);
+ }
+ throw err;
+ });
+ const req = new Request(`https://acme.example.com/api/invoices/${INV_ID}/mark-paid`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ method }),
+ });
+ return app.fetch(req, ENV, {
+ waitUntil: (p: Promise) => { settled.push(p); }, passThroughOnException: () => {},
+ } as never).then(async (res) => { await Promise.allSettled(settled); return res; });
+}
+
+describe('mark-paid → QuickBooks', () => {
+ it('pushes the remainder collected, not the invoice total', async () => {
+ await seedDeposit();
+
+ const res = await markPaid();
+ expect(res.status).toBe(200);
+
+ // $360 — the balance. NOT $450, which is what the invoice says it costs.
+ expect(pushes()).toEqual([[360, expect.stringMatching(/^pay-/) as unknown as string]]);
+ });
+
+ it('keys the push on the ledger row, so a deposit and a balance are two facts', async () => {
+ await seedDeposit();
+ await markPaid();
+
+ const row = await appendedBalanceRow();
+ expect(row).not.toBeNull();
+ expect(pushes()[0][1]).toBe(`pay-${row?.id}`);
+ // The invoice id is what it used to be keyed on, and keying two payments
+ // on it makes QBO return the deposit's response for the balance.
+ expect(pushes()[0][1]).not.toBe(`pay-${INV_ID}`);
+ });
+
+ it('pushes the whole total when that really is what was collected', async () => {
+ await markPaid();
+ expect(pushes().map(([amount]) => amount)).toEqual([450]);
+ });
+
+ it('pushes nothing for an invoice already paid before the ledger existed', async () => {
+ // The backfill writes rows straight to D1 for invoices QuickBooks was
+ // already told about by the original manual flow. Nothing appends here,
+ // so nothing can push — re-pushing would double their recorded revenue.
+ await db.insert(schema.orderPayments).values({
+ id: 'pay-backfill-000-0000-0000-000000000001',
+ tenantId: TENANT, inspectionId: INSPECTION, invoiceId: INV_ID,
+ kind: 'balance', amountCents: TOTAL_CENTS, method: 'offline',
+ note: 'backfilled from invoice record', occurredAt: T1, createdAt: T2,
+ });
+ await db.update(schema.invoices).set({ paidAt: T1, amountPaidCents: TOTAL_CENTS })
+ .where(eq(schema.invoices.id, INV_ID));
+
+ const res = await markPaid();
+ expect(res.status).toBe(200);
+ expect(pushes()).toEqual([]);
+ });
+});
+
+// --- the Stripe webhook ---------------------------------------------------
+
+const SIG = { 'stripe-signature': 't=1,v1=x' };
+const SETTLED = {
+ type: 'payment_intent.succeeded',
+ data: { object: { metadata: { invoiceId: INV_ID, tenantId: TENANT, inspectionId: INSPECTION } } },
+};
+
+function deliverWebhook() {
+ verifyWebhook.mockResolvedValue(SETTLED);
+ const settled: Promise[] = [];
+ const kv = { get: vi.fn().mockResolvedValue(null), put: vi.fn() };
+ const app = new Hono();
+ app.use('*', async (c, next) => {
+ c.set('tenantId' as never, TENANT as never);
+ (c as { env: Record }).env = {
+ TENANT_CACHE: kv, STRIPE_SECRET_KEY: 'sk_test_1',
+ STRIPE_WEBHOOK_SECRET: 'whsec_1', QBO_CLIENT_ID: 'qbo-client',
+ };
+ c.set('services' as never, {
+ invoice: new InvoiceService({} as D1Database),
+ inspection: { markPaymentReceived: vi.fn().mockResolvedValue(undefined) },
+ qbo: { recordPayment: recordPaymentSpy },
+ } as never);
+ Object.defineProperty(c, 'executionCtx', {
+ value: { waitUntil: (p: Promise) => { settled.push(p); } }, configurable: true,
+ });
+ await next();
+ });
+ app.route('/', stripeWebhookApi);
+ return app.request('/', { method: 'POST', headers: SIG, body: '{}' })
+ .then(async (res) => { await Promise.allSettled(settled); return res; });
+}
+
+describe('a settled card payment → QuickBooks', () => {
+ it('pushes what the card actually settled, not the invoice total', async () => {
+ await seedDeposit();
+
+ const res = await deliverWebhook();
+ expect(res.status).toBe(200);
+
+ const row = await appendedBalanceRow();
+ expect(pushes()).toEqual([[360, `pay-${row?.id}`]]);
+ });
+
+ it('pushes nothing on redelivery, rather than pushing again under one key', async () => {
+ await seedDeposit();
+ await deliverWebhook();
+ await deliverWebhook(); // Stripe redelivers; the invoice is paid already.
+
+ expect(pushes()).toHaveLength(1);
+ });
+});
diff --git a/tests/unit/qbo/payment-push.spec.ts b/tests/unit/qbo/payment-push.spec.ts
index adf390cff..69b344e09 100644
--- a/tests/unit/qbo/payment-push.spec.ts
+++ b/tests/unit/qbo/payment-push.spec.ts
@@ -112,10 +112,18 @@ const SETTLED = {
data: { object: { metadata: { invoiceId: 'inv-1', tenantId: 'tA', inspectionId: 'insp1' } } },
};
+/**
+ * `markPaid` returns the ledger row it appended, and the push is keyed and
+ * priced off THAT — see `tests/unit/qbo/payment-push-amount.spec.ts` for the
+ * real-database version. Here it is a stub, so the row is a stub too; `null`
+ * stands for "nothing was appended", which must push nothing.
+ */
+const APPENDED = { id: 'row-9', kind: 'balance' as const, amountCents: 45000, occurredAt: new Date() };
+
function makeApp(opts: {
env?: Record;
recordPayment?: ReturnType;
- findById?: ReturnType;
+ markPaid?: ReturnType;
} = {}) {
const kv = { get: vi.fn().mockResolvedValue(null), put: vi.fn() };
const settled: Promise[] = [];
@@ -125,8 +133,7 @@ function makeApp(opts: {
(c as { env: Record }).env = { TENANT_CACHE: kv, ...KEYS, ...(opts.env ?? {}) };
c.set('services' as never, {
invoice: {
- markPaid: vi.fn().mockResolvedValue(undefined),
- findById: opts.findById ?? vi.fn().mockResolvedValue({ id: 'inv-1', amountCents: 45000 }),
+ markPaid: opts.markPaid ?? vi.fn().mockResolvedValue(APPENDED),
},
inspection: { markPaymentReceived: vi.fn().mockResolvedValue(undefined) },
qbo: { recordPayment: opts.recordPayment ?? vi.fn().mockResolvedValue(undefined) },
@@ -153,7 +160,7 @@ describe('a card payment reaches QuickBooks', () => {
await Promise.all(settled);
expect(res.status).toBe(200);
- expect(recordPayment).toHaveBeenCalledWith('tA', 'inv-1', 450, 'pay-inv-1');
+ expect(recordPayment).toHaveBeenCalledWith('tA', 'inv-1', 450, 'pay-row-9');
});
it('does not push when QuickBooks is not connected', async () => {
@@ -180,11 +187,14 @@ describe('a card payment reaches QuickBooks', () => {
expect(res.status).toBe(200);
});
- it('pushes nothing when the invoice cannot be read', async () => {
+ it('pushes nothing when no payment was appended', async () => {
+ // A redelivery, or an invoice already paid. Nothing happened, so there
+ // is nothing to tell QuickBooks — the requestid stays a second line of
+ // defence rather than the only one.
verifyWebhook.mockResolvedValue(SETTLED);
const recordPayment = vi.fn();
- const findById = vi.fn().mockResolvedValue(null);
- const { app, settled } = makeApp({ env: { QBO_CLIENT_ID: 'qbo-client' }, recordPayment, findById });
+ const markPaid = vi.fn().mockResolvedValue(null);
+ const { app, settled } = makeApp({ env: { QBO_CLIENT_ID: 'qbo-client' }, recordPayment, markPaid });
const res = await app.request('/', { method: 'POST', headers: SIG, body: '{}' });
await Promise.all(settled);
From 22e8da4409a713649719bb31012c5be2243ad443 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 20:04:32 +0800
Subject: [PATCH 069/111] feat(qbo): surface a payment disagreement instead of
resolving it silently
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spec §6: our ledger is authoritative for what WE collected; QuickBooks reports a
balance and cannot reconstruct our rows. The CDC sweep was applying their
implied paid amount straight onto the invoice, which after the payment ledger
means APPENDING A ROW — an adjusting entry recording money movement nobody
performed, and one that is indistinguishable a month later from money that
really moved. A figure that went down manufactured a refund nobody issued.
The sweep now compares. Where the ledger has an opinion and the two disagree, it
writes a discrepancy carrying BOTH figures and leaves the ledger alone;
agreement resolves an open one, so whoever reconciles in QuickBooks does not
also have to tick it off here. The line is drawn by whether the ledger has rows
at all, not by whether its number is zero: no rows means "we have nothing to say
about this invoice", which is the pre-ledger invoice QuickBooks legitimately
still gets to inform, and flagging those would make every legacy invoice a
discrepancy nobody can act on.
Discrepancies ride the existing `qbo_sync_errors` table under their own error
code — it already has the tenant scope, the resolved flag and the settings
surface — so `error_code` joins the open-row identity: a failed push and a
discrepancy on one invoice are two different things to look at. They are counted
apart from sync errors on the status card, because nothing went wrong on the
wire and burying them there hides the one item that needs a human.
Also discloses what deliberately never reaches QuickBooks: deposits taken before
an invoice existed. An unapplied deposit needs a liability account in the
tenant's own chart of accounts, which is their accountant's call — so the count
is stated where they would look for it rather than the cash quietly
under-reported. A count and not an amount: those rows predate the invoice that
carries the currency, and inventing one would be the worse lie.
---
app/components/settings/QboBooksHealth.tsx | 92 ++++++++
app/routes/settings-integrations-qbo.tsx | 36 +--
messages/en/settings-integrations.json | 5 +
messages/es-419/settings-integrations.json | 5 +
server/lib/qbo-discrepancy.ts | 43 ++++
server/services/payment-ledger.service.ts | 32 ++-
server/services/qbo/api-base.ts | 69 +++++-
server/services/qbo/connection.ts | 52 ++++-
server/services/qbo/invoice-sync.ts | 39 +++-
tests/unit/invoices/partial-balance.spec.ts | 21 +-
tests/unit/qbo/payment-discrepancy.spec.ts | 240 ++++++++++++++++++++
11 files changed, 590 insertions(+), 44 deletions(-)
create mode 100644 app/components/settings/QboBooksHealth.tsx
create mode 100644 server/lib/qbo-discrepancy.ts
create mode 100644 tests/unit/qbo/payment-discrepancy.spec.ts
diff --git a/app/components/settings/QboBooksHealth.tsx b/app/components/settings/QboBooksHealth.tsx
new file mode 100644
index 000000000..c3c728b23
--- /dev/null
+++ b/app/components/settings/QboBooksHealth.tsx
@@ -0,0 +1,92 @@
+import { m } from "~/paraglide/messages";
+import { formatCurrency } from "~/lib/format";
+import { useDisplayLocale } from "~/hooks/useSessionContext";
+
+export interface QboDiscrepancy {
+ id: string;
+ invoiceId: string;
+ currency: string;
+ /** What our payment ledger records as received. */
+ ledgerCents: number;
+ /** QuickBooks' implied paid amount (TotalAmt − Balance). */
+ qboCents: number;
+}
+
+/**
+ * The three things on the QuickBooks page that are about the tenant's BOOKS
+ * rather than about the connection: pushes that failed, figures the two sides
+ * disagree on, and money we deliberately never send.
+ *
+ * A discrepancy is shown with BOTH figures and never as one reconciled number.
+ * Spec 2026-08-01 payment/deposit flow §6 — our ledger is authoritative for what
+ * we collected, QuickBooks reports a balance and cannot reconstruct our rows, so
+ * a human reconciles. Auto-adjusting either side would record money movement
+ * nobody performed, and showing a single "corrected" figure would hide that the
+ * question was ever open.
+ */
+export function QboBooksHealth({
+ openErrors,
+ discrepancies,
+ heldDepositCount,
+}: {
+ openErrors: number;
+ discrepancies: QboDiscrepancy[];
+ heldDepositCount: number;
+}) {
+ const locale = useDisplayLocale();
+
+ return (
+ <>
+ {openErrors > 0 && (
+
+
+
+
+
+ {m.settings_qbo_sync_errors({ count: openErrors })}
+
+
{m.settings_qbo_sync_errors_desc()}
+
+ )}
+
+ {/* Surfaced, never auto-corrected. */}
+ {discrepancies.length > 0 && (
+
+
+ {m.settings_qbo_discrepancy_heading({ count: discrepancies.length })}
+
+
{m.settings_qbo_discrepancy_desc()}
+
+ {discrepancies.map((d) => (
+
+ {m.settings_qbo_discrepancy_row({
+ invoice: d.invoiceId.slice(0, 8),
+ ours: formatCurrency(d.ledgerCents, { locale, currency: d.currency }),
+ theirs: formatCurrency(d.qboCents, { locale, currency: d.currency }),
+ })}
+
+ ))}
+
+
+ )}
+
+ {/* What deliberately does not reach QuickBooks, said where they would look
+ for it — silence here reads as "everything synced". */}
+ {heldDepositCount > 0 && (
+
+
+ {m.settings_qbo_not_synced_heading()}
+
+
+ {m.settings_qbo_not_synced_deposits({ count: heldDepositCount })}
+
+
+ )}
+ >
+ );
+}
diff --git a/app/routes/settings-integrations-qbo.tsx b/app/routes/settings-integrations-qbo.tsx
index 64939baba..319889b53 100644
--- a/app/routes/settings-integrations-qbo.tsx
+++ b/app/routes/settings-integrations-qbo.tsx
@@ -8,6 +8,7 @@ import { getApiUrl } from "~/lib/api.server";
import { SecretField } from "~/components/SecretField";
import { m } from "~/paraglide/messages";
import { getCloudflareEnv } from "~/lib/load-context";
+import { QboBooksHealth, type QboDiscrepancy } from "~/components/settings/QboBooksHealth";
interface QboStatus {
connected: boolean;
@@ -15,6 +16,9 @@ interface QboStatus {
syncEnabled?: boolean;
lastSyncAt?: number | null;
openErrors?: number;
+ /** Both figures, never one: the point is that a human compares them. */
+ paymentDiscrepancies?: QboDiscrepancy[];
+ heldDepositCount?: number;
refreshTokenExpiresAt?: number;
}
@@ -145,6 +149,7 @@ export default function SettingsIntegrationsQbo() {
const qboFetcher = useFetcher<{ success: boolean; intent?: string | null; error: string | null; syncEnabled?: boolean }>();
const connected = status?.connected;
+ const discrepancies = status?.paymentDiscrepancies ?? [];
const syncing = qboFetcher.state !== "idle" && qboFetcher.formData?.get("intent") === "qbo-sync";
const expiryWarning =
status?.refreshTokenExpiresAt &&
@@ -345,30 +350,13 @@ export default function SettingsIntegrationsQbo() {
- {/* Sync errors */}
- {(status.openErrors ?? 0) > 0 && (
-
-
-
-
-
- {m.settings_qbo_sync_errors({ count: status.openErrors ?? 0 })}
-
-
- {m.settings_qbo_sync_errors_desc()}
-
-
- )}
+ {/* Failed pushes, disagreements and what is never sent — see
+ QboBooksHealth for why a discrepancy shows both figures. */}
+
)}
diff --git a/messages/en/settings-integrations.json b/messages/en/settings-integrations.json
index 0cdb579f9..12a6921d9 100644
--- a/messages/en/settings-integrations.json
+++ b/messages/en/settings-integrations.json
@@ -157,6 +157,11 @@
"settings_qbo_disconnect": "Disconnect",
"settings_qbo_sync_errors": "Sync Errors ({count})",
"settings_qbo_sync_errors_desc": "Check the sync error log for details. Errors will retry automatically on the next sync.",
+ "settings_qbo_discrepancy_heading": "Payment discrepancies ({count})",
+ "settings_qbo_discrepancy_desc": "QuickBooks and your payment records disagree about what was collected. Nothing has been adjusted automatically: recording an adjustment would invent money movement nobody performed. Reconcile these in QuickBooks.",
+ "settings_qbo_discrepancy_row": "Invoice {invoice}: you recorded {ours}, QuickBooks reports {theirs}.",
+ "settings_qbo_not_synced_heading": "Not sent to QuickBooks",
+ "settings_qbo_not_synced_deposits": "{count} payments were collected before an invoice existed, so they are not in QuickBooks. An unapplied deposit needs a liability account in your chart of accounts, and that is your accountant's decision.",
"settings_apps_meta_title": "Connected applications - Settings - OpenInspection",
"settings_apps_crumb": "Connected applications",
"settings_apps_all_modules": "All modules",
diff --git a/messages/es-419/settings-integrations.json b/messages/es-419/settings-integrations.json
index 7d6b91415..081a58acf 100644
--- a/messages/es-419/settings-integrations.json
+++ b/messages/es-419/settings-integrations.json
@@ -157,6 +157,11 @@
"settings_qbo_disconnect": "Desconectar",
"settings_qbo_sync_errors": "Errores de sincronización ({count})",
"settings_qbo_sync_errors_desc": "Consulte el registro de errores de sincronización para ver los detalles. Los errores se reintentan automáticamente en la próxima sincronización.",
+ "settings_qbo_discrepancy_heading": "Discrepancias de pago ({count})",
+ "settings_qbo_discrepancy_desc": "QuickBooks y sus registros de pago no coinciden en cuánto se cobró. No se ajustó nada de forma automática: registrar un ajuste inventaría un movimiento de dinero que nadie realizó. Concilie estos casos en QuickBooks.",
+ "settings_qbo_discrepancy_row": "Factura {invoice}: usted registró {ours}; QuickBooks informa {theirs}.",
+ "settings_qbo_not_synced_heading": "No se envía a QuickBooks",
+ "settings_qbo_not_synced_deposits": "{count} pagos se cobraron antes de que existiera una factura, por lo que no están en QuickBooks. Un depósito no aplicado exige una cuenta de pasivo en su catálogo de cuentas, y esa es una decisión de su contador.",
"settings_apps_meta_title": "Aplicaciones conectadas - Configuración - OpenInspection",
"settings_apps_crumb": "Aplicaciones conectadas",
"settings_apps_all_modules": "Todos los módulos",
diff --git a/server/lib/qbo-discrepancy.ts b/server/lib/qbo-discrepancy.ts
new file mode 100644
index 000000000..e17d7bc9c
--- /dev/null
+++ b/server/lib/qbo-discrepancy.ts
@@ -0,0 +1,43 @@
+/**
+ * A payment discrepancy: QuickBooks and our payment ledger disagree about what
+ * was collected against an invoice.
+ *
+ * Spec 2026-08-01 payment/deposit flow §6 — our ledger is authoritative for what
+ * WE collected; QuickBooks reports a balance and cannot reconstruct our rows.
+ * When they disagree the rule is to flag it and let a human reconcile. Writing
+ * an adjusting entry to make the numbers agree would record money movement
+ * nobody performed, and it would be indistinguishable afterwards from money that
+ * really moved.
+ *
+ * Discrepancies ride the existing `qbo_sync_errors` table under their own
+ * `error_code`: it already has a tenant scope, a resolved flag, a settings
+ * surface and a resolve action, and a discrepancy is exactly what that table is
+ * for — something a human has to look at. What it does not have is a column per
+ * figure, so both figures live in `error_msg` under the codec below. It is
+ * written and read only here, which is what makes that safe.
+ */
+export const QBO_PAYMENT_DISCREPANCY = 'PAYMENT_DISCREPANCY';
+
+export interface PaymentDiscrepancy {
+ /** What our ledger says we received, in integer cents. */
+ ledgerCents: number;
+ /** QuickBooks' implied paid amount (TotalAmt − Balance), in integer cents. */
+ qboCents: number;
+}
+
+export function encodePaymentDiscrepancy(d: PaymentDiscrepancy): string {
+ return JSON.stringify({ ledgerCents: d.ledgerCents, qboCents: d.qboCents });
+}
+
+/** `null` for anything this module did not write — never guess at both figures. */
+export function decodePaymentDiscrepancy(errorMsg: string): PaymentDiscrepancy | null {
+ try {
+ const parsed: unknown = JSON.parse(errorMsg);
+ if (typeof parsed !== 'object' || parsed === null) return null;
+ const { ledgerCents, qboCents } = parsed as Record;
+ if (!Number.isInteger(ledgerCents) || !Number.isInteger(qboCents)) return null;
+ return { ledgerCents: ledgerCents as number, qboCents: qboCents as number };
+ } catch {
+ return null;
+ }
+}
diff --git a/server/services/payment-ledger.service.ts b/server/services/payment-ledger.service.ts
index 67ff05ded..b9dc7acd5 100644
--- a/server/services/payment-ledger.service.ts
+++ b/server/services/payment-ledger.service.ts
@@ -160,6 +160,34 @@ async function ledgerRowsForInvoice(
.all();
}
+/**
+ * What the ledger has to say about an invoice — and crucially, WHETHER it has
+ * anything to say. `netCents` alone cannot tell those apart: zero is both "no
+ * rows at all" and "every receipt was refunded", and those two lead to opposite
+ * decisions when an external system reports a different figure.
+ *
+ * No rows means the ledger has no opinion, NOT that nothing was paid — the same
+ * rule `recomputeInvoicePaymentState` applies to the cache.
+ */
+export interface LedgerOpinion {
+ rowCount: number;
+ /** Receipts minus refunds, in integer cents. Meaningless when rowCount is 0. */
+ netCents: number;
+}
+
+export async function getLedgerOpinion(
+ rawDb: AnyDb,
+ tenantId: string,
+ invoiceId: string,
+): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const rows = await ledgerRowsForInvoice(rawDb as any, tenantId, invoiceId);
+ return {
+ rowCount: rows.length,
+ netCents: rows.reduce((sum, r) => sum + signOf(r.kind) * r.amountCents, 0),
+ };
+}
+
/**
* Cumulative amount RECEIVED against an invoice — receipts minus refunds.
* What a caller needs to work out an outstanding remainder without guessing.
@@ -169,9 +197,7 @@ export async function getNetReceivedCents(
tenantId: string,
invoiceId: string,
): Promise {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const rows = await ledgerRowsForInvoice(rawDb as any, tenantId, invoiceId);
- return rows.reduce((sum, r) => sum + signOf(r.kind) * r.amountCents, 0);
+ return (await getLedgerOpinion(rawDb, tenantId, invoiceId)).netCents;
}
/**
diff --git a/server/services/qbo/api-base.ts b/server/services/qbo/api-base.ts
index 28a1b81fb..194f33862 100644
--- a/server/services/qbo/api-base.ts
+++ b/server/services/qbo/api-base.ts
@@ -3,6 +3,7 @@ import { eq, and } from 'drizzle-orm';
import { qboConnections, qboSyncErrors } from '../../lib/db/schema/qbo';
import { encryptToken, decryptToken } from '../../lib/qbo-crypto';
import { QBOTokenResponseSchema } from '../../lib/validations/qbo.schema';
+import { QBO_PAYMENT_DISCREPANCY, encodePaymentDiscrepancy } from '../../lib/qbo-discrepancy';
const QBO_API_BASE = 'https://quickbooks.api.intuit.com/v3/company';
const QBO_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
@@ -11,12 +12,31 @@ const MINOR_VERSION = '75';
export const ACCESS_TOKEN_TTL_SEC = 3600;
export const CDC_PAGE_SIZE = 1000;
+/** One invoice where QuickBooks and our ledger disagree, with BOTH figures. */
+export interface QBOPaymentDiscrepancy {
+ id: string;
+ invoiceId: string;
+ currency: string;
+ ledgerCents: number;
+ qboCents: number;
+}
+
export interface QBOConnectionStatus {
realmId: string;
companyName: string | null;
lastSyncAt: number | null;
syncEnabled: boolean;
+ /** Failed pushes only. Discrepancies are not errors and are counted below. */
openErrors: number;
+ paymentDiscrepancies: QBOPaymentDiscrepancy[];
+ /**
+ * Payments taken before an invoice existed. They are deliberately NOT sent
+ * to QuickBooks — see the settings copy — so the count is disclosed rather
+ * than the cash quietly under-reported. A count and not an amount: these
+ * rows predate the invoice that carries the currency, and inventing one
+ * would be a worse lie than saying less.
+ */
+ heldDepositCount: number;
refreshTokenExpiresAt: number;
}
@@ -152,21 +172,62 @@ export class QBOServiceBase {
}
protected async logSyncError(tenantId: string, oiType: string, oiId: string, error: unknown): Promise {
+ const msg = error instanceof Error ? error.message : String(error);
+ await this.upsertSyncFlag(tenantId, oiType, oiId, 'SYNC_ERROR', msg);
+ }
+
+ /**
+ * QuickBooks and our ledger disagree about what was collected. Recorded, not
+ * corrected: an adjusting entry would manufacture money movement nobody
+ * performed, and a human reconciles money. Re-detecting the same
+ * disagreement refreshes the figures instead of stacking rows.
+ */
+ protected async recordPaymentDiscrepancy(
+ tenantId: string, invoiceId: string, ledgerCents: number, qboCents: number,
+ ): Promise {
+ await this.upsertSyncFlag(
+ tenantId, 'invoice', invoiceId, QBO_PAYMENT_DISCREPANCY,
+ encodePaymentDiscrepancy({ ledgerCents, qboCents }),
+ );
+ }
+
+ /** The two sides agree again — whoever reconciled it does not have to also tick it off. */
+ protected async clearPaymentDiscrepancy(tenantId: string, invoiceId: string): Promise {
+ const db = this.getDrizzle();
+ await db.update(qboSyncErrors).set({ resolved: true, updatedAt: new Date() })
+ .where(and(
+ eq(qboSyncErrors.tenantId, tenantId),
+ eq(qboSyncErrors.oiType, 'invoice'),
+ eq(qboSyncErrors.oiId, invoiceId),
+ eq(qboSyncErrors.errorCode, QBO_PAYMENT_DISCREPANCY),
+ eq(qboSyncErrors.resolved, false),
+ ));
+ }
+
+ /**
+ * One open row per (entity, kind). `errorCode` is part of the identity: a
+ * failed push and a payment discrepancy on the same invoice are two
+ * different things to look at, and collapsing them would overwrite one
+ * with the other.
+ */
+ private async upsertSyncFlag(
+ tenantId: string, oiType: string, oiId: string, errorCode: string, errorMsg: string,
+ ): Promise {
const db = this.getDrizzle();
const now = new Date();
- const msg = error instanceof Error ? error.message : String(error);
const existing = await db.select().from(qboSyncErrors)
.where(and(
eq(qboSyncErrors.tenantId, tenantId),
eq(qboSyncErrors.oiType, oiType),
eq(qboSyncErrors.oiId, oiId),
+ eq(qboSyncErrors.errorCode, errorCode),
eq(qboSyncErrors.resolved, false),
)).get();
if (existing) {
await db.update(qboSyncErrors).set({
retries: existing.retries + 1,
- errorMsg: msg,
+ errorMsg,
updatedAt: now,
}).where(eq(qboSyncErrors.id, existing.id));
} else {
@@ -175,8 +236,8 @@ export class QBOServiceBase {
tenantId,
oiType,
oiId,
- errorCode: 'SYNC_ERROR',
- errorMsg: msg,
+ errorCode,
+ errorMsg,
retries: 0,
resolved: false,
createdAt: now,
diff --git a/server/services/qbo/connection.ts b/server/services/qbo/connection.ts
index a33f86aba..729064668 100644
--- a/server/services/qbo/connection.ts
+++ b/server/services/qbo/connection.ts
@@ -1,10 +1,14 @@
-import { eq, and } from 'drizzle-orm';
+import { eq, and, inArray, isNull } from 'drizzle-orm';
import { qboConnections, qboEntityMap, qboSyncErrors } from '../../lib/db/schema/qbo';
+import { invoices } from '../../lib/db/schema/invoice';
+import { orderPayments } from '../../lib/db/schema/order-payment';
import { encryptToken } from '../../lib/qbo-crypto';
+import { QBO_PAYMENT_DISCREPANCY, decodePaymentDiscrepancy } from '../../lib/qbo-discrepancy';
import {
ACCESS_TOKEN_TTL_SEC,
type Constructor,
type QBOConnectionStatus,
+ type QBOPaymentDiscrepancy,
type QBOServiceBase,
} from './api-base';
import { withToken } from './token';
@@ -68,6 +72,45 @@ export function withConnection>(Base:
if (!row) return null;
const errorRows = await db.select().from(qboSyncErrors)
.where(and(eq(qboSyncErrors.tenantId, tenantId), eq(qboSyncErrors.resolved, false))).all();
+
+ // Explicit projection, not select(): the invoice join is only here
+ // for the currency each pair of figures should be read in, and a
+ // wide invoice row would run at D1's 100-column result cap.
+ const discrepancyRows = errorRows.filter(r => r.errorCode === QBO_PAYMENT_DISCREPANCY);
+ const currencies = discrepancyRows.length === 0 ? [] : await db
+ .select({ id: invoices.id, currency: invoices.currency })
+ .from(invoices)
+ .where(and(
+ eq(invoices.tenantId, tenantId),
+ inArray(invoices.id, discrepancyRows.map(r => r.oiId)),
+ )).all();
+ const currencyOf = new Map(currencies.map(c => [c.id, c.currency]));
+
+ const paymentDiscrepancies: QBOPaymentDiscrepancy[] = [];
+ for (const row of discrepancyRows) {
+ const figures = decodePaymentDiscrepancy(row.errorMsg);
+ // A row this codec did not write has no two figures to show, and
+ // a half-rendered discrepancy is worse than none.
+ if (!figures) continue;
+ paymentDiscrepancies.push({
+ id: row.id,
+ invoiceId: row.oiId,
+ currency: currencyOf.get(row.oiId) ?? 'USD',
+ ledgerCents: figures.ledgerCents,
+ qboCents: figures.qboCents,
+ });
+ }
+
+ // Money we hold that predates any invoice. Never pushed: an
+ // unapplied deposit needs a liability account in the tenant's own
+ // chart of accounts, which is their accountant's call, not ours.
+ const heldDeposits = await db.select({ id: orderPayments.id })
+ .from(orderPayments)
+ .where(and(
+ eq(orderPayments.tenantId, tenantId),
+ isNull(orderPayments.invoiceId),
+ )).all();
+
return {
realmId: row.realmId,
companyName: row.companyName,
@@ -77,7 +120,12 @@ export function withConnection>(Base:
// the column's own Date storage type.
lastSyncAt: row.lastSyncAt ? Math.floor(row.lastSyncAt.getTime() / 1000) : null,
syncEnabled: row.syncEnabled,
- openErrors: errorRows.length,
+ // Failed pushes only. A discrepancy is not a failure — nothing
+ // went wrong on the wire — and filing it under "sync errors"
+ // would bury the one thing on this page that needs a human.
+ openErrors: errorRows.length - discrepancyRows.length,
+ paymentDiscrepancies,
+ heldDepositCount: heldDeposits.length,
refreshTokenExpiresAt: Math.floor(row.refreshTokenExpiresAt.getTime() / 1000),
};
}
diff --git a/server/services/qbo/invoice-sync.ts b/server/services/qbo/invoice-sync.ts
index fba49b64f..ccc982612 100644
--- a/server/services/qbo/invoice-sync.ts
+++ b/server/services/qbo/invoice-sync.ts
@@ -2,6 +2,7 @@ import { eq, and } from 'drizzle-orm';
import { qboConnections, qboEntityMap } from '../../lib/db/schema/qbo';
import { invoices } from '../../lib/db/schema/invoice';
import { logger } from '../../lib/logger';
+import { getLedgerOpinion } from '../payment-ledger.service';
import type {
Constructor,
InvoiceSummary,
@@ -57,17 +58,39 @@ export function withInvoiceSync>(Base:
syncedAt: new Date(),
}).where(eq(qboEntityMap.id, mapped.id));
+ // QuickBooks amounts are dollars (see the reverse mapping in
+ // upsertInvoice, `Amount: amountCents / 100`). Round each side to
+ // its own exact cent value before subtracting: a bare float
+ // multiply on the difference produces off-by-one-cent amounts
+ // that are impossible to explain to a customer. This is the ONLY
+ // place the conversion happens — adapters receive cents.
+ const qboPaidCents = Math.round(inv.TotalAmt * 100) - Math.round(inv.Balance * 100);
+
+ // Spec §6. Our ledger is authoritative for what WE collected;
+ // QuickBooks reports a balance and cannot reconstruct our rows. So
+ // once the ledger has an opinion, this sweep may only COMPARE:
+ // applying QuickBooks' figure here would append an adjusting row,
+ // and an adjusting row is money movement nobody performed that is
+ // indistinguishable afterwards from money that really moved.
+ const opinion = await getLedgerOpinion(db, tenantId, mapped.oiId);
+ if (opinion.rowCount > 0) {
+ if (opinion.netCents === qboPaidCents) {
+ await this.clearPaymentDiscrepancy(tenantId, mapped.oiId);
+ } else {
+ await this.recordPaymentDiscrepancy(tenantId, mapped.oiId, opinion.netCents, qboPaidCents);
+ }
+ return true;
+ }
+
+ // No rows means the ledger has NO OPINION — not that nothing was
+ // paid. (Same rule `recomputeInvoicePaymentState` applies to the
+ // cache.) There is nothing for QuickBooks to contradict, so it is
+ // the only account of this invoice there is and applying it is the
+ // first record rather than an adjustment.
if (inv.Balance === 0) {
await markPaid(mapped.oiId, tenantId);
} else if (inv.Balance < inv.TotalAmt) {
- // QuickBooks amounts are dollars (see the reverse mapping in
- // upsertInvoice, `Amount: amountCents / 100`). Round each side to
- // its own exact cent value before subtracting: a bare float
- // multiply on the difference produces off-by-one-cent amounts
- // that are impossible to explain to a customer. This is the ONLY
- // place the conversion happens — adapters receive cents.
- const amountPaidCents = Math.round(inv.TotalAmt * 100) - Math.round(inv.Balance * 100);
- await markPartial(mapped.oiId, amountPaidCents, tenantId);
+ await markPartial(mapped.oiId, qboPaidCents, tenantId);
}
return true;
}
diff --git a/tests/unit/invoices/partial-balance.spec.ts b/tests/unit/invoices/partial-balance.spec.ts
index 936f0220c..19494626d 100644
--- a/tests/unit/invoices/partial-balance.spec.ts
+++ b/tests/unit/invoices/partial-balance.spec.ts
@@ -171,15 +171,30 @@ describe('QBO partial payment — capturing the amount', () => {
it('does not double-count a partial sync that repeats the same figure', async () => {
// QBO reports a CUMULATIVE amount and the sync runs on every webhook and
- // every cron sweep. What is appended is the delta, so replaying the same
- // figure appends nothing — and a figure that went down records a refund.
+ // every cron sweep. The first sweep lands because the ledger has nothing
+ // to say yet; a replay of the same figure is agreement, and agreement
+ // writes nothing.
await seedInvoice(45000);
await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
expect((await getInvoice()).amountPaidCents).toBe(25000);
+ });
+
+ it('flags a figure that went DOWN instead of inventing a refund', async () => {
+ // Once the ledger holds $250, QuickBooks reporting $100 is a
+ // disagreement about money, not an instruction. Writing the $150 refund
+ // that would reconcile them records a refund nobody issued — see
+ // tests/unit/qbo/payment-discrepancy.spec.ts for the flag itself.
+ await seedInvoice(45000);
+ await syncFromQbo({ Id: QBO_ID, Balance: 200, TotalAmt: 450 });
+ expect((await getInvoice()).amountPaidCents).toBe(25000);
await syncFromQbo({ Id: QBO_ID, Balance: 350, TotalAmt: 450 });
- expect((await getInvoice()).amountPaidCents).toBe(10000);
+
+ expect((await getInvoice()).amountPaidCents).toBe(25000); // untouched
+ const flags = await db.select().from(schema.qboSyncErrors)
+ .where(eq(schema.qboSyncErrors.oiId, INV_ID)).all();
+ expect(flags.map((f) => f.errorCode)).toEqual(['PAYMENT_DISCREPANCY']);
});
});
diff --git a/tests/unit/qbo/payment-discrepancy.spec.ts b/tests/unit/qbo/payment-discrepancy.spec.ts
new file mode 100644
index 000000000..5743b69de
--- /dev/null
+++ b/tests/unit/qbo/payment-discrepancy.spec.ts
@@ -0,0 +1,240 @@
+/**
+ * When QuickBooks and our ledger disagree, say so — do not quietly make them
+ * agree.
+ *
+ * Spec 2026-08-01 payment/deposit flow §6. The CDC sweep used to apply
+ * QuickBooks' implied paid amount straight onto the invoice, which after the
+ * payment ledger means APPENDING A ROW: an adjusting entry recording money
+ * movement nobody performed, and one that is indistinguishable a month later
+ * from money that really moved. Our ledger is authoritative for what we
+ * collected; QuickBooks reports a balance and cannot reconstruct our rows.
+ *
+ * The line the sweep must not cross is drawn by whether the ledger has an
+ * OPINION, not by whether its number happens to be zero — no rows means "we
+ * have nothing to say about this invoice", which is exactly the pre-ledger
+ * invoice QuickBooks legitimately still gets to inform.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { and, eq } from 'drizzle-orm';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('../../../server/lib/qbo-crypto', () => ({
+ encryptToken: vi.fn(async (t: string) => `enc:${t}`),
+ decryptToken: vi.fn(async (t: string) => t.replace('enc:', '')),
+}));
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { QBOServiceBase } from '../../../server/services/qbo/api-base';
+import type { InvoiceSummary } from '../../../server/services/qbo/api-base';
+import { withInvoiceSync } from '../../../server/services/qbo/invoice-sync';
+import { withConnection } from '../../../server/services/qbo/connection';
+import { InvoiceService } from '../../../server/services/invoice.service';
+import { QBO_PAYMENT_DISCREPANCY } from '../../../server/lib/qbo-discrepancy';
+
+/** Exposes the protected sweep step so the test drives the real decision. */
+class TestQbo extends withInvoiceSync(withConnection(QBOServiceBase)) {
+ apply(tenantId: string, inv: InvoiceSummary, markPaid: never, markPartial: never) {
+ return this.applyInvoiceStatusFromQBO(tenantId, inv, markPaid, markPartial);
+ }
+}
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const INSPECTION = 'insp-aaaaaaaa-0000-0000-0000-000000000001';
+const INV_ID = 'inv-aaaaaaaa-0000-0000-0000-000000000001';
+const QBO_ID = '147';
+const TOTAL_CENTS = 45000;
+
+const T1 = new Date('2026-03-01T10:00:00Z');
+const T2 = new Date('2026-03-05T10:00:00Z');
+
+/** QuickBooks says $450 total with $450 collected — TotalAmt/Balance in dollars. */
+const QBO_SAYS_PAID_IN_FULL: InvoiceSummary = {
+ Id: QBO_ID, SyncToken: '4', Balance: 0, TotalAmt: 450,
+} as InvoiceSummary;
+/** $360 collected of $450. */
+const QBO_SAYS_360: InvoiceSummary = {
+ Id: QBO_ID, SyncToken: '4', Balance: 90, TotalAmt: 450,
+} as InvoiceSummary;
+
+let db: BetterSQLite3Database;
+let qbo: TestQbo;
+let invoiceSvc: InvoiceService;
+let markPaid: ReturnType;
+let markPartial: ReturnType;
+
+beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+
+ qbo = new TestQbo({} as D1Database, 'cid', 'csec', 'whsec', 'secret32chars_aaaaaaaaaaaaaaaa');
+ invoiceSvc = new InvoiceService({} as D1Database);
+ markPaid = vi.fn((id: string, tid: string) => invoiceSvc.markPaid(id, tid, 'qbo'));
+ markPartial = vi.fn((id: string, cents: number, tid: string) => invoiceSvc.markPartial(id, tid, 'qbo', cents));
+
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: T1,
+ });
+ await db.insert(schema.inspections).values({
+ id: INSPECTION, tenantId: TENANT, propertyAddress: '1 Oak St', date: '2026-03-01', createdAt: T1,
+ });
+ await db.insert(schema.invoices).values({
+ id: INV_ID, tenantId: TENANT, inspectionId: INSPECTION, amountCents: TOTAL_CENTS,
+ lineItems: [{ description: 'Inspection', amountCents: TOTAL_CENTS }],
+ sentAt: T1, createdAt: T1, currency: 'CAD',
+ });
+ await db.insert(schema.qboEntityMap).values({
+ id: 'map-1', tenantId: TENANT, oiType: 'invoice', oiId: INV_ID,
+ qboType: 'Invoice', qboId: QBO_ID, qboSyncToken: '1', syncedAt: T1,
+ });
+});
+
+/**
+ * A ledger holding $360 across two rows, inserted LATER money first so an
+ * implementation that reads "the last row" cannot pass by accident.
+ */
+async function seedLedger360() {
+ await db.insert(schema.orderPayments).values([
+ {
+ id: 'pay-balance-000-0000-0000-000000000002', tenantId: TENANT,
+ inspectionId: INSPECTION, invoiceId: INV_ID, kind: 'balance',
+ amountCents: 27000, method: 'card', occurredAt: T2, createdAt: T2,
+ },
+ {
+ id: 'pay-deposit-000-0000-0000-000000000001', tenantId: TENANT,
+ inspectionId: INSPECTION, invoiceId: INV_ID, kind: 'deposit',
+ amountCents: 9000, method: 'cash', occurredAt: T1, createdAt: T1,
+ },
+ ]);
+ await db.update(schema.invoices).set({ partialPaidAt: T2, amountPaidCents: 36000 })
+ .where(eq(schema.invoices.id, INV_ID));
+}
+
+const ledgerRows = () => db.select().from(schema.orderPayments)
+ .where(eq(schema.orderPayments.invoiceId, INV_ID)).all();
+
+const openFlags = () => db.select().from(schema.qboSyncErrors)
+ .where(and(eq(schema.qboSyncErrors.tenantId, TENANT), eq(schema.qboSyncErrors.resolved, false))).all();
+
+describe('the CDC sweep records disagreement instead of adjusting', () => {
+ it('flags both figures when QuickBooks reports more collected than the ledger holds', async () => {
+ await seedLedger360();
+
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+
+ const flags = await openFlags();
+ expect(flags).toHaveLength(1);
+ expect(flags[0].errorCode).toBe(QBO_PAYMENT_DISCREPANCY);
+ expect(flags[0].oiId).toBe(INV_ID);
+ // BOTH figures, because a human is the one who reconciles them.
+ expect(JSON.parse(flags[0].errorMsg)).toEqual({ ledgerCents: 36000, qboCents: 45000 });
+ });
+
+ it('appends no adjusting row and leaves the cached figure alone', async () => {
+ await seedLedger360();
+
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+
+ // The row that would have appeared is the whole defect: $90 of receipt
+ // that nobody collected, indistinguishable next month from real money.
+ expect(await ledgerRows()).toHaveLength(2);
+ const inv = await db.select().from(schema.invoices).where(eq(schema.invoices.id, INV_ID)).get();
+ expect(inv?.amountPaidCents).toBe(36000);
+ expect(inv?.paidAt).toBeNull();
+ expect(markPaid).not.toHaveBeenCalled();
+ expect(markPartial).not.toHaveBeenCalled();
+ });
+
+ it('resolves the flag once the two sides agree again', async () => {
+ await seedLedger360();
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+ expect(await openFlags()).toHaveLength(1);
+
+ // Someone reconciled it in QuickBooks: 360 there, 360 here.
+ await qbo.apply(TENANT, QBO_SAYS_360, markPaid as never, markPartial as never);
+
+ expect(await openFlags()).toHaveLength(0);
+ expect(await ledgerRows()).toHaveLength(2);
+ });
+
+ it('re-detecting the same disagreement refreshes it rather than stacking rows', async () => {
+ await seedLedger360();
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+
+ const flags = await openFlags();
+ expect(flags).toHaveLength(1);
+ expect(flags[0].retries).toBe(1);
+ });
+
+ it('still applies QuickBooks when the ledger has NO opinion', async () => {
+ // No rows at all: a pre-ledger invoice, or one an accountant settled in
+ // QuickBooks. There is nothing to contradict, so this is the first
+ // record rather than an adjustment — and flagging it instead would make
+ // every legacy invoice a discrepancy nobody can act on.
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+
+ expect(markPaid).toHaveBeenCalledWith(INV_ID, TENANT);
+ expect(await openFlags()).toHaveLength(0);
+ const inv = await db.select().from(schema.invoices).where(eq(schema.invoices.id, INV_ID)).get();
+ expect(inv?.paidAt).not.toBeNull();
+ });
+
+ it('keeps a discrepancy and a failed push on one invoice apart', async () => {
+ // They are two different things to look at. The open-row identity has to
+ // include the code, or one silently overwrites the other.
+ await seedLedger360();
+ await db.insert(schema.qboSyncErrors).values({
+ id: 'err-1', tenantId: TENANT, oiType: 'invoice', oiId: INV_ID,
+ errorCode: 'SYNC_ERROR', errorMsg: 'QBO 503', retries: 0, resolved: false,
+ createdAt: T1, updatedAt: T1,
+ });
+
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+
+ const codes = (await openFlags()).map((f) => f.errorCode).sort();
+ expect(codes).toEqual([QBO_PAYMENT_DISCREPANCY, 'SYNC_ERROR']);
+ });
+});
+
+describe('settings shows both figures and what is not synced at all', () => {
+ beforeEach(async () => {
+ await db.insert(schema.qboConnections).values({
+ tenantId: TENANT, realmId: 'r1', companyName: 'Acme Books',
+ accessToken: 'enc:a', refreshToken: 'enc:r',
+ tokenExpiresAt: T2, refreshTokenExpiresAt: new Date('2027-01-01T00:00:00Z'),
+ syncEnabled: true, defaultItemId: '1', createdAt: T1,
+ });
+ });
+
+ it('reports each discrepancy with both figures and the invoice currency', async () => {
+ await seedLedger360();
+ await qbo.apply(TENANT, QBO_SAYS_PAID_IN_FULL, markPaid as never, markPartial as never);
+
+ const status = await qbo.getConnectionStatus(TENANT);
+ expect(status?.paymentDiscrepancies).toEqual([
+ expect.objectContaining({ invoiceId: INV_ID, ledgerCents: 36000, qboCents: 45000, currency: 'CAD' }),
+ ]);
+ // A discrepancy is not a failed push; counting it as one buries it.
+ expect(status?.openErrors).toBe(0);
+ });
+
+ it('discloses deposits held before any invoice, which never reach QuickBooks', async () => {
+ // No invoice id: an unapplied deposit needs a liability account in the
+ // tenant's chart of accounts, so it is deliberately never pushed. Saying
+ // nothing here would read as "everything is synced".
+ await db.insert(schema.orderPayments).values({
+ id: 'pay-held-0000-0000-0000-000000000001', tenantId: TENANT,
+ inspectionId: INSPECTION, invoiceId: null, kind: 'deposit',
+ amountCents: 15000, method: 'card', occurredAt: T1, createdAt: T1,
+ });
+
+ const status = await qbo.getConnectionStatus(TENANT);
+ expect(status?.heldDepositCount).toBe(1);
+ });
+});
From 5690747e7f92c49094adeac94506529bf2707a34 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 20:24:59 +0800
Subject: [PATCH 070/111] feat(i18n): give message templates a locale, and a
fallback chain that cannot go silent
A template row is now one LANGUAGE VARIANT: variants share (tenant_id, name,
channel) and differ only by locale. resolveForLocale walks requested locale ->
tenant default -> 'en' -> the referenced row itself. That last rung is the point
of the function: a tenant who has authored no Spanish variant keeps sending
English, because silence is the one unacceptable outcome for a notification.
No unique index. The plan called widening (tenant_id, name, channel) the risky
part; there was nothing to widen. This table has carried exactly one index, the
non-unique (tenant_id, channel) one, and nothing has ever enforced a name.
create() accepts any name and update() renames freely, so duplicate (name,
channel) rows are reachable today and some tenant is probably holding a pair.
CREATE UNIQUE INDEX fails outright on those rows, which would turn a language
feature into a failed migration on data we cannot see. Duplicates stay legal and
the resolver is made deterministic instead: oldest row wins, tie broken by id.
tenant_configs.default_locale is a full BCP-47 tag while this column holds
catalogue locales, so it is reduced through normalizeLocale -- comparing them raw
would mean that rung never fires and every fallback landed on English by
accident, which looks identical to it working.
---
migrations/0037_many_luke_cage.sql | 2 +
migrations/meta/0037_snapshot.json | 10506 ++++++++++++++++
migrations/meta/_journal.json | 7 +
.../db/schema/inspection/message-template.ts | 22 +
server/services/message-template.service.ts | 88 +-
.../message-template-resolve.spec.ts | 152 +
6 files changed, 10775 insertions(+), 2 deletions(-)
create mode 100644 migrations/0037_many_luke_cage.sql
create mode 100644 migrations/meta/0037_snapshot.json
create mode 100644 tests/unit/automations/message-template-resolve.spec.ts
diff --git a/migrations/0037_many_luke_cage.sql b/migrations/0037_many_luke_cage.sql
new file mode 100644
index 000000000..27a51c42a
--- /dev/null
+++ b/migrations/0037_many_luke_cage.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `message_templates` ADD `locale` text DEFAULT 'en' NOT NULL;--> statement-breakpoint
+CREATE INDEX `idx_message_templates_variant` ON `message_templates` (`tenant_id`,`name`,`channel`,`locale`);
\ No newline at end of file
diff --git a/migrations/meta/0037_snapshot.json b/migrations/meta/0037_snapshot.json
new file mode 100644
index 000000000..52d88bb4d
--- /dev/null
+++ b/migrations/meta/0037_snapshot.json
@@ -0,0 +1,10506 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "cd2c347a-92b2-477a-9127-3b745b7326d6",
+ "prevId": "65b4799f-a21c-4323-bfa2-725b630b96cb",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "language_disclosure_version": {
+ "name": "language_disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "follow_up_delay_hours": {
+ "name": "follow_up_delay_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 72
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ },
+ "idx_message_templates_variant": {
+ "name": "idx_message_templates_variant",
+ "columns": [
+ "tenant_id",
+ "name",
+ "channel",
+ "locale"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "order_payments": {
+ "name": "order_payments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "invoice_id": {
+ "name": "invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recorded_by": {
+ "name": "recorded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refunds_id": {
+ "name": "refunds_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_order_payments_inspection": {
+ "name": "idx_order_payments_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_order_payments_invoice": {
+ "name": "idx_order_payments_invoice",
+ "columns": [
+ "tenant_id",
+ "invoice_id"
+ ],
+ "isUnique": false
+ },
+ "uq_order_payments_provider_ref": {
+ "name": "uq_order_payments_provider_ref",
+ "columns": [
+ "tenant_id",
+ "provider",
+ "provider_ref"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'us'"
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'12h'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index d90b7aaf9..7ce9f08f0 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -260,6 +260,13 @@
"when": 1785841431766,
"tag": "0036_mean_magik",
"breakpoints": true
+ },
+ {
+ "idx": 37,
+ "version": "6",
+ "when": 1785846007839,
+ "tag": "0037_many_luke_cage",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/lib/db/schema/inspection/message-template.ts b/server/lib/db/schema/inspection/message-template.ts
index f0723a0a4..0f5828ab7 100644
--- a/server/lib/db/schema/inspection/message-template.ts
+++ b/server/lib/db/schema/inspection/message-template.ts
@@ -28,6 +28,28 @@ export const messageTemplates = sqliteTable('message_templates', {
isSeeded: integer('is_seeded', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
+ /**
+ * Multilingual delivery — one row per LANGUAGE VARIANT of a template.
+ * Variants of the same template share `(tenant_id, name, channel)` and
+ * differ only here; the send path picks one by resolving the RECIPIENT's
+ * locale (`server/services/message-template.service.ts#resolveForLocale`),
+ * never the request's.
+ *
+ * NOT NULL with a default so every row that already exists is a valid `en`
+ * variant and no backfill is needed.
+ *
+ * Deliberately NOT unique on `(tenant_id, name, channel, locale)`. Nothing
+ * has ever enforced uniqueness on this table: `create()` accepts any name
+ * and `update()` renames freely, so duplicate `(name, channel)` pairs are
+ * reachable today and a tenant may well be holding some. A unique index
+ * fails OUTRIGHT on those rows, which would turn a language feature into a
+ * failed migration on someone else's data. Resolution is made deterministic
+ * in the service instead — oldest row wins, tie broken by id.
+ */
+ locale: text('locale').notNull().default('en'),
}, (t) => [
index('idx_message_templates_tenant_channel').on(t.tenantId, t.channel),
+ // Variant lookup: the resolver filters tenant + name + channel and then
+ // walks locales. Non-unique on purpose — see the `locale` comment.
+ index('idx_message_templates_variant').on(t.tenantId, t.name, t.channel, t.locale),
]);
diff --git a/server/services/message-template.service.ts b/server/services/message-template.service.ts
index da479f7f4..611e66e9e 100644
--- a/server/services/message-template.service.ts
+++ b/server/services/message-template.service.ts
@@ -1,8 +1,9 @@
import { drizzle } from 'drizzle-orm/d1';
import { eq, and, or } from 'drizzle-orm';
import { nanoid } from 'nanoid';
-import { messageTemplates, automations } from '../lib/db/schema';
+import { messageTemplates, automations, tenantConfigs } from '../lib/db/schema';
import { Errors } from '../lib/errors';
+import { normalizeLocale, type ContactLocale } from '../lib/i18n/contact-locale';
/** Derived from the column's enum so widening the schema propagates here. */
export type TemplateChannel = typeof messageTemplates.$inferSelect['channel'];
@@ -10,6 +11,8 @@ export type TemplateChannel = typeof messageTemplates.$inferSelect['channel'];
export interface MessageTemplateRow {
id: string; tenantId: string; name: string; channel: TemplateChannel;
subject: string | null; body: string; variables: string[];
+ /** Which language variant this row IS. See the schema comment. */
+ locale: string;
isSeeded: boolean; createdAt: number; updatedAt: number;
}
@@ -23,6 +26,10 @@ function serialize(r: typeof messageTemplates.$inferSelect): MessageTemplateRow
return {
id: r.id, tenantId: r.tenantId, name: r.name, channel: r.channel,
subject: r.subject, body: r.body, variables: parseVars(r.variables),
+ // Rows written before the column existed read back as the default; a
+ // NULL here would still mean "the English one", so say so rather than
+ // letting `undefined` reach the resolver's comparisons.
+ locale: r.locale ?? 'en',
isSeeded: r.isSeeded,
createdAt: r.createdAt instanceof Date ? r.createdAt.getTime() : Number(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.getTime() : Number(r.updatedAt),
@@ -54,11 +61,87 @@ export class MessageTemplateService {
return row ? serialize(row) : null;
}
- async create(tenantId: string, data: { name: string; channel: TemplateChannel; subject?: string | null; body: string; variables?: string[] }): Promise {
+ /**
+ * Every variant of one template — same `(tenant, name, channel)`, one row
+ * per language. Ordered oldest-first so the authoring surface and the
+ * resolver agree on which row is "the original" when a tenant holds
+ * duplicates (nothing stops them; see the schema comment).
+ */
+ async variantsOf(tenantId: string, id: string): Promise {
+ const base = await this.get(tenantId, id);
+ if (!base) return [];
+ return this.siblings(tenantId, base.name, base.channel);
+ }
+
+ /** Tenant filter FIRST and unconditional — the locale chain walks locales,
+ * never tenants. */
+ private async siblings(tenantId: string, name: string, channel: TemplateChannel): Promise {
+ const rows = await this.drizzle.select().from(messageTemplates)
+ .where(and(
+ eq(messageTemplates.tenantId, tenantId),
+ eq(messageTemplates.name, name),
+ eq(messageTemplates.channel, channel),
+ ));
+ return rows.map(serialize).sort((a, b) =>
+ (a.createdAt - b.createdAt) || a.id.localeCompare(b.id));
+ }
+
+ /**
+ * The variant of `id` to send to a recipient who reads `locale`.
+ *
+ * Chain: the requested locale → the tenant's configured default → `'en'` →
+ * the referenced row itself. The last step is the point of the function: a
+ * tenant who has authored no Spanish variant keeps sending English, because
+ * silence is the one unacceptable outcome for a notification. This NEVER
+ * returns null for a template that exists.
+ *
+ * `tenant_configs.default_locale` is a full BCP-47 tag (`en-US`) while this
+ * column holds catalogue locales (`en`, `es-419`), so it is reduced through
+ * `normalizeLocale` — comparing the two raw would never match.
+ */
+ async resolveForLocale(tenantId: string, id: string, locale: ContactLocale | string | null | undefined): Promise {
+ const base = await this.get(tenantId, id);
+ if (!base) return null;
+ const wanted = normalizeLocale(locale);
+ // The overwhelmingly common case — an English recipient on an English
+ // template — must not pay for a second query.
+ if (wanted && base.locale === wanted) return base;
+
+ const rows = await this.siblings(tenantId, base.name, base.channel);
+ const tenantDefault = normalizeLocale(await this.tenantDefaultLocale(tenantId));
+ const chain: Array = [];
+ for (const candidate of [wanted, tenantDefault, 'en' as const]) {
+ if (candidate && !chain.includes(candidate)) chain.push(candidate);
+ }
+ for (const want of chain) {
+ const hit = rows.find((r) => r.locale === want);
+ if (hit) return hit;
+ }
+ return base;
+ }
+
+ private async tenantDefaultLocale(tenantId: string): Promise {
+ try {
+ const cfg = await this.drizzle.select({ defaultLocale: tenantConfigs.defaultLocale })
+ .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get();
+ return cfg?.defaultLocale ?? null;
+ } catch {
+ // A config read that fails must not stop a send; the chain simply
+ // loses one rung and lands on 'en' or the referenced row.
+ return null;
+ }
+ }
+
+ async create(tenantId: string, data: { name: string; channel: TemplateChannel; subject?: string | null; body: string; variables?: string[]; locale?: string }): Promise {
const id = nanoid();
const now = new Date();
await this.drizzle.insert(messageTemplates).values({
id, tenantId, name: data.name, channel: data.channel,
+ // A variant's language is what the row IS, like its channel — set
+ // at create and never patched, so an edit can never silently
+ // reassign copy to a language it was not written in. An unsupported
+ // tag lands on 'en' rather than becoming a variant nothing resolves.
+ locale: normalizeLocale(data.locale) ?? 'en',
// `subject` is the email subject AND the in-app notice title
// (see the schema comment); only SMS has nowhere to put one.
subject: data.channel === 'sms' ? null : (data.subject ?? null),
@@ -88,6 +171,7 @@ export class MessageTemplateService {
return this.create(tenantId, {
name: `${src.name} (Copy)`, channel: src.channel,
subject: src.subject, body: src.body, variables: src.variables,
+ locale: src.locale,
});
}
diff --git a/tests/unit/automations/message-template-resolve.spec.ts b/tests/unit/automations/message-template-resolve.spec.ts
new file mode 100644
index 000000000..365c859b1
--- /dev/null
+++ b/tests/unit/automations/message-template-resolve.spec.ts
@@ -0,0 +1,152 @@
+/**
+ * Multilingual delivery — picking the right LANGUAGE VARIANT of a template.
+ *
+ * The interesting behaviour is not "the Spanish row comes back when it exists".
+ * It is what happens when it does NOT: a tenant who has authored no Spanish
+ * variant must keep sending English, because silence is the one unacceptable
+ * outcome for a notification. Every fallback rung below is therefore seeded so
+ * that a WRONG answer is a different, observable string — a resolver that
+ * hardcoded English, or one that quietly widened its tenant filter, fails here
+ * rather than passing on a happy path.
+ *
+ * Rows are inserted in an ADVERSE order (the variant that must NOT win first)
+ * so no assertion can be satisfied by a row-order accident.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { MessageTemplateService } from '../../../server/services/message-template.service';
+import { tenantConfigs, tenants } from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+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 T = 'tenant-1';
+const OTHER = 'tenant-2';
+
+describe('template resolution by locale', () => {
+ let testDb: BetterSQLite3Database;
+ let svc: MessageTemplateService;
+
+ beforeEach(async () => {
+ const fx = createTestDb(); testDb = fx.db; await setupSchema(fx.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb);
+ svc = new MessageTemplateService({} as D1Database);
+ });
+
+ const setTenantLocale = async (tenantId: string, locale: string) => {
+ await testDb.insert(tenants).values({
+ id: tenantId, name: tenantId, slug: tenantId, status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await testDb.insert(tenantConfigs)
+ .values({ tenantId, defaultLocale: locale, updatedAt: new Date() });
+ };
+
+ it('returns the exact locale variant when it exists', async () => {
+ // Spanish first: if the resolver walked rows in insertion order and
+ // stopped at the first match, this ordering would hide the bug.
+ const es = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'es-body', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ expect((await svc.resolveForLocale(T, en.id, 'es-419'))!.id).toBe(es.id);
+ expect((await svc.resolveForLocale(T, es.id, 'en'))!.id).toBe(en.id);
+ });
+
+ it('reduces a regional tag to its catalogue variant', async () => {
+ const es = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'es-body', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'en-body', locale: 'en' });
+ // A contact who stored es-MX is a Spanish speaker, not an English one.
+ expect((await svc.resolveForLocale(T, en.id, 'es-MX'))!.id).toBe(es.id);
+ });
+
+ it('falls back to the tenant default locale before English', async () => {
+ // The tenant's configured locale is a full BCP-47 tag; the column holds
+ // catalogue locales. If the resolver compared them raw, this rung would
+ // never fire and the English row below would win.
+ await setTenantLocale(T, 'es-MX');
+ const es = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'es-body', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ // Nothing is known about this recipient's language (a null
+ // `contacts.locale` is the common case — the booking form deliberately
+ // does not ask on the agent-on-behalf branch).
+ const picked = await svc.resolveForLocale(T, en.id, null);
+ expect(picked!.id).toBe(es.id);
+ expect(picked!.body).toBe('es-body');
+ });
+
+ it('keeps sending English when the tenant authored no Spanish variant', async () => {
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+ const picked = await svc.resolveForLocale(T, en.id, 'es-419');
+ // Degrade, never block: a null here is a notification that never goes out.
+ expect(picked).not.toBeNull();
+ expect(picked!.body).toBe('en-body');
+ });
+
+ it('returns the referenced row when neither the tenant default nor English exists', async () => {
+ // The last rung. A tenant whose only variant is Spanish, addressing an
+ // English reader, still sends something.
+ const es = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'es-body', locale: 'es-419' });
+ const picked = await svc.resolveForLocale(T, es.id, 'en');
+ expect(picked!.id).toBe(es.id);
+ });
+
+ it('never returns a template from another tenant', async () => {
+ // The other tenant's Spanish copy is created FIRST and is the only
+ // es-419 row in the table. A resolver that walked locales without
+ // pinning the tenant would leak this company's outbound copy.
+ await setTenantLocale(T, 'es-MX');
+ const leak = await svc.create(OTHER, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'LEAKED', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ const picked = await svc.resolveForLocale(T, en.id, 'es-419');
+ expect(picked!.tenantId).toBe(T);
+ expect(picked!.id).not.toBe(leak.id);
+ expect(picked!.body).toBe('en-body');
+ });
+
+ it('never crosses channels', async () => {
+ // Same tenant, same name, different channel: an SMS variant is not a
+ // translation of an email template, and rendering one as the other
+ // would send a plain-text stub as an HTML body.
+ const smsEs = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'sms-es', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ const picked = await svc.resolveForLocale(T, en.id, 'es-419');
+ expect(picked!.id).not.toBe(smsEs.id);
+ expect(picked!.channel).toBe('email');
+ });
+
+ it('is deterministic when a tenant holds duplicate variants', async () => {
+ // Nothing enforces uniqueness on (tenant, name, channel, locale) —
+ // `create` accepts any name and `update` renames freely — so duplicates
+ // are reachable and the resolver must not pick at random.
+ const first = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'A', body: 'first', locale: 'es-419' });
+ const second = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'B', body: 'second', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'C', body: 'en-body', locale: 'en' });
+
+ const a = await svc.resolveForLocale(T, en.id, 'es-419');
+ const b = await svc.resolveForLocale(T, en.id, 'es-419');
+ expect(a!.id).toBe(b!.id);
+ expect([first.id, second.id]).toContain(a!.id);
+ });
+
+ it('defaults an unknown id to null and an unsupported authored tag to en', async () => {
+ expect(await svc.resolveForLocale(T, 'nope', 'en')).toBeNull();
+ // A variant stored under a language we have no messages for would be
+ // unreachable by the chain; it lands on 'en' at create time instead.
+ const t = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'b', locale: 'fr-FR' });
+ expect(t.locale).toBe('en');
+ });
+
+ it('lists every variant of one template, oldest first', async () => {
+ const es = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'es', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en', locale: 'en' });
+ await svc.create(T, { name: 'Other', channel: 'email', subject: 'X', body: 'x', locale: 'en' });
+
+ const variants = await svc.variantsOf(T, en.id);
+ expect(variants.map((v) => v.id)).toEqual([es.id, en.id]);
+ });
+});
From f7845a85be1d0c9348122d61595a40cfa66cabc7 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 20:47:15 +0800
Subject: [PATCH 071/111] feat(i18n): render notifications in the recipient's
language
Recipient locale is not request locale. A trigger fires from another user's
request, from cron, or from a queue -- where getLocale() is baseLocale and any
ambient read is silently wrong. The locale is now threaded explicitly from the
recipient down to the message function, and there is a test that sets the ambient
locale to Spanish, notifies an English reader, and fails if anyone reads ambient
state again.
ResolvedRecipient carries a locale; notice wording is cached per (rule, locale)
rather than per rule, because one firing reaching an English agent and a Spanish
client would otherwise hand the first recipient's wording to both -- the same
bug, relocated into a memo. noticeTitleFor takes the locale as a REQUIRED
parameter so that omitting it is a type error instead of a mistranslation nobody
sees. Wording moved to its own module: the trigger decides which rows to write,
which is a different job from deciding what they say.
The email and SMS paths re-resolve at send time from the log's own recipient
rather than reading a locale stamped at enqueue: a delayed rule can sit for days,
and the language someone has told us they read is a current fact about them, not
a property of the firing.
Missing translations degrade to the tenant default and then to English: a tenant
with no Spanish template keeps sending English, because silence is the one
unacceptable outcome for a notification. A null contacts.locale is an ABSENCE and
falls through -- the booking form deliberately does not ask on the
agent-on-behalf branch, so null is the common case, not an anomaly.
---
server/lib/i18n/messages.ts | 15 +-
server/lib/i18n/recipient-locale.ts | 111 +++++++++
server/services/automation/deliver-email.ts | 18 +-
server/services/automation/notice-headers.ts | 20 +-
server/services/automation/notice-wording.ts | 100 +++++++++
server/services/automation/recipients.ts | 32 ++-
server/services/automation/sms.ts | 13 +-
server/services/automation/template-store.ts | 16 +-
server/services/automation/trigger.ts | 76 ++-----
.../unit/automations/recipient-locale.spec.ts | 211 ++++++++++++++++++
.../automations/resolve-recipients.spec.ts | 8 +-
.../unit/automations/staff-recipients.spec.ts | 5 +-
12 files changed, 548 insertions(+), 77 deletions(-)
create mode 100644 server/lib/i18n/recipient-locale.ts
create mode 100644 server/services/automation/notice-wording.ts
create mode 100644 tests/unit/automations/recipient-locale.spec.ts
diff --git a/server/lib/i18n/messages.ts b/server/lib/i18n/messages.ts
index 2a2761b64..3816c2096 100644
--- a/server/lib/i18n/messages.ts
+++ b/server/lib/i18n/messages.ts
@@ -4,10 +4,17 @@
* Server-side i18n has a constraint the client does not: a notification is
* rendered FOR a recipient, whose locale is not the request's locale, and in a
* cron or queue context there is no request at all — `getLocale()` returns
- * `baseLocale`. Recipient-locale resolution is a separate piece of work. Until
- * it lands, every message read through here resolves in the ambient locale,
- * which for notifications means English. Routing this through one module means
- * that change touches one file rather than every call site.
+ * `baseLocale`.
+ *
+ * **Every recipient-facing read through here MUST pass an explicit locale** —
+ * `m.some_message({ ...inputs }, { locale })` — resolved from the recipient via
+ * `recipient-locale.ts`. Paraglide's message functions fall back to `getLocale()`
+ * when the option is omitted, and on this side of the app that default is not a
+ * sensible one: it is a silent mistranslation in exactly the firings nobody
+ * tests, because a cron sweep and a queue consumer both answer `baseLocale` no
+ * matter who is reading. The one place that renders such a string today
+ * (`automation/trigger.ts#titleFor`) takes the locale as a REQUIRED parameter so
+ * that omitting it is a type error rather than an invisible one.
*
* Why the disable below, and why it does not weaken the BFF boundary: the rule
* stops `server/` depending on `app/` because `app/` is loaders, components and
diff --git a/server/lib/i18n/recipient-locale.ts b/server/lib/i18n/recipient-locale.ts
new file mode 100644
index 000000000..10c8ce48e
--- /dev/null
+++ b/server/lib/i18n/recipient-locale.ts
@@ -0,0 +1,111 @@
+/**
+ * Reading a RECIPIENT's locale out of the database.
+ *
+ * `contact-locale.ts` owns the PRECEDENCE and is deliberately pure — it is in
+ * the browser bundle. This module is the database half: it fetches the inputs
+ * that precedence needs and hands them over. Keeping the two apart is what lets
+ * the booking form and the cron sweeper agree on what a person's language is
+ * without the form pulling D1 into the client bundle.
+ *
+ * WHY THIS EXISTS AT ALL: a notification is rendered FOR someone, and a trigger
+ * fires from another user's request, from cron, or from a queue consumer, where
+ * there is no request and `getLocale()` answers `baseLocale`. Every ambient read
+ * on a recipient-facing string is therefore wrong in exactly the cases nobody
+ * tests. Rendering takes the locale from here, explicitly, or it is a bug.
+ *
+ * FAIL-SOFT throughout: every lookup that throws degrades one rung down the
+ * chain and the resolver still answers. A locale lookup must never be the reason
+ * a notification did not go out.
+ */
+import { eq, and } from 'drizzle-orm';
+import { contacts, users, tenantConfigs } from '../db/schema';
+import { resolveContactLocale, type ContactLocale } from './contact-locale';
+
+/**
+ * Who is being written to. Automation recipients are a XOR: `contacts.id` for
+ * clients/agents, `users.id` for staff and the assigned inspector (the trigger
+ * path carries the user id in the same field — `isStaffRecipient(roleKey)` is
+ * what says which). Passing the wrong `kind` reads the wrong table and quietly
+ * lands on the tenant default, so the caller must decide it from the role key,
+ * not from the shape of the id.
+ */
+export interface RecipientRef {
+ kind: 'user' | 'contact';
+ id: string;
+}
+
+/** Resolves a recipient to the language to address them in. */
+export type RecipientLocaleResolver = (ref: RecipientRef | null) => Promise;
+
+// The two drivers (async D1, synchronous better-sqlite3) share this builder
+// surface; the automation path already types its db handles this way.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyDb = any;
+
+/**
+ * A resolver bound to one tenant, memoized for the life of one trigger firing
+ * or one flush batch. The memo matters: a rule fanning out to eight staff would
+ * otherwise re-read the same tenant config eight times, and the whole point of
+ * doing this per-recipient is that it is cheap enough to.
+ */
+export function createRecipientLocaleResolver(db: AnyDb, tenantId: string): RecipientLocaleResolver {
+ const perRecipient = new Map>();
+ let tenantDefault: Promise | undefined;
+
+ const loadTenantDefault = (): Promise => {
+ tenantDefault ??= (async () => {
+ try {
+ const cfg = await db.select({ defaultLocale: tenantConfigs.defaultLocale })
+ .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get();
+ return (cfg?.defaultLocale as string | null) ?? null;
+ } catch { return null; }
+ })();
+ return tenantDefault;
+ };
+
+ const load = async (ref: RecipientRef): Promise => {
+ const fallbackDefault = await loadTenantDefault();
+ if (ref.kind === 'user') {
+ let locale: string | null = null;
+ try {
+ const row = await db.select({ locale: users.locale }).from(users)
+ .where(and(eq(users.id, ref.id), eq(users.tenantId, tenantId))).get();
+ locale = (row?.locale as string | null) ?? null;
+ } catch { locale = null; }
+ // A staff member's own UI locale IS a stated preference, so it goes
+ // in the top slot rather than the `linkedUserLocale` one.
+ return resolveContactLocale({ contactLocale: locale, tenantDefault: fallbackDefault });
+ }
+ let contactLocale: string | null = null;
+ let linkedUserLocale: string | null = null;
+ try {
+ // One query, left-joined: a contact bound to an account
+ // (`contacts.agent_user_id`) has made an explicit language choice
+ // too, just in a different place. Scoped to the tenant on BOTH
+ // sides — a resolver is a read path like any other.
+ const row = await db.select({ locale: contacts.locale, linked: users.locale })
+ .from(contacts)
+ .leftJoin(users, and(eq(users.id, contacts.agentUserId), eq(users.tenantId, tenantId)))
+ .where(and(eq(contacts.id, ref.id), eq(contacts.tenantId, tenantId))).get();
+ contactLocale = (row?.locale as string | null) ?? null;
+ linkedUserLocale = (row?.linked as string | null) ?? null;
+ } catch { /* both stay null; the tenant default carries it */ }
+ // NULL is an ABSENCE, never English — `resolveContactLocale` owns that
+ // distinction and this function must not pre-empt it. The booking form
+ // deliberately does not ask for a language on the agent-on-behalf
+ // branch, so a null here is the common case, not an anomaly.
+ return resolveContactLocale({ contactLocale, linkedUserLocale, tenantDefault: fallbackDefault });
+ };
+
+ return async (ref) => {
+ if (!ref?.id) {
+ // Nothing identifies this recipient. Answer with the tenant's own
+ // language rather than refusing: the alternative is not sending.
+ return resolveContactLocale({ tenantDefault: await loadTenantDefault() });
+ }
+ const key = `${ref.kind}:${ref.id}`;
+ let p = perRecipient.get(key);
+ if (!p) { p = load(ref); perRecipient.set(key, p); }
+ return p;
+ };
+}
diff --git a/server/services/automation/deliver-email.ts b/server/services/automation/deliver-email.ts
index 4f928f72e..464f4edec 100644
--- a/server/services/automation/deliver-email.ts
+++ b/server/services/automation/deliver-email.ts
@@ -8,7 +8,8 @@ import { deliverAction } from '../../lib/automation-core';
import { buildBaseTemplateVars } from './template-vars';
import { createOiTemplateStore } from './template-store';
import { automationClassId } from '../../lib/notifications/automation-classes';
-import { oiClock } from './shared';
+import { oiClock, isStaffRecipient } from './shared';
+import { createRecipientLocaleResolver } from '../../lib/i18n/recipient-locale';
import type { FlushInspection } from './shared';
import type { EmailService } from '../email.service';
import type { automations, automationLogs as automationLogsTable, tenants } from '../../lib/db/schema';
@@ -52,11 +53,24 @@ export async function deliverTemplatedEmail(
// embedded subject_template / body_template, now frozen DEAD).
// Skip fail-closed when the rule has no resolvable email template.
const store = createOiTemplateStore(deps.rawDb);
+ // The RECIPIENT's language, resolved from the log's own recipient rather
+ // than from anything ambient — flush() runs on cron, where there is no
+ // request and `getLocale()` would answer `baseLocale` for everyone.
+ //
+ // Resolved at SEND time rather than stamped on the log at enqueue: a
+ // delayed rule can sit for days, and the language a person has told us they
+ // read is a current fact about them, not a property of the firing. The cost
+ // is two small indexed reads per log.
+ const locale = await createRecipientLocaleResolver(db, inspection.tenantId)(
+ log.recipientContactId
+ ? { kind: isStaffRecipient(log.recipientRoleKey) ? 'user' : 'contact', id: log.recipientContactId }
+ : null,
+ );
// No rule means no referenced template, which lands on the
// same fail-closed skip a rule with no template gets. One
// outcome, one reason string, whichever way it got here.
const tpl = automation?.emailTemplateId
- ? await store.resolve(inspection.tenantId, automation.emailTemplateId)
+ ? await store.resolve(inspection.tenantId, automation.emailTemplateId, locale)
: null;
if (!tpl || tpl.channel !== 'email') {
await db.update(automationLogs).set({ status: 'skipped', error: 'no email template' })
diff --git a/server/services/automation/notice-headers.ts b/server/services/automation/notice-headers.ts
index 643cd84ea..9e4ae2ab4 100644
--- a/server/services/automation/notice-headers.ts
+++ b/server/services/automation/notice-headers.ts
@@ -19,6 +19,8 @@ import { insertNotificationRow } from '../notification.service';
import { nanoid } from 'nanoid';
import { isStaffRecipient } from './shared';
import { isPreferenceMuted, type PreferenceSubject } from '../../lib/notifications/preference-port';
+import type { RecipientLocaleResolver } from '../../lib/i18n/recipient-locale';
+import type { ContactLocale } from '../../lib/i18n/contact-locale';
export interface NoticeHeaderInput {
tenantId: string;
@@ -122,14 +124,22 @@ export async function createHeadersForInsertedLogs(
* The wording for one rule's notice (B3/IA-115). Per-RULE, not per-batch:
* two rules on the same event can carry different in-app templates, and a
* single title for the whole firing would silently pick one of them.
+ *
+ * Now also per-LOCALE, and for a related reason: one firing can reach an
+ * English agent and a Spanish client, so a single wording for the whole
+ * rule would silently pick one of THEM. The locale comes from the header's
+ * own recipient (`localeFor` below) — never from the ambient locale, which
+ * in a cron firing describes nobody.
*/
- wordingFor: (automationId: string | null) => NoticeWording,
+ wordingFor: (automationId: string | null, locale: ContactLocale) => Promise,
/**
* The notification class for one rule's notice — per-RULE for the same
* reason `wordingFor` is: two rules on one event are two different things
* to have a preference about.
*/
classFor: (automationId: string | null) => string | undefined,
+ /** The language each header's own recipient reads in. */
+ localeFor: RecipientLocaleResolver,
inserted: Array<{ id: string; automationId: string | null; sendAt: Date | number;
recipientContactId: string | null; recipientRoleKey: string | null }>,
): Promise {
@@ -148,7 +158,13 @@ export async function createHeadersForInsertedLogs(
groups.set(key, g);
}
for (const g of groups.values()) {
- const wording = wordingFor(g.automationId);
+ // A group IS one recipient (the key carries the id), so this is the
+ // language of the person who will read this notice, resolved from what
+ // we know about THEM.
+ const locale = await localeFor(g.userId
+ ? { kind: 'user', id: g.userId }
+ : { kind: 'contact', id: g.contactId! });
+ const wording = await wordingFor(g.automationId, locale);
const classId = classFor(g.automationId);
const noticeId = await insertNoticeHeader(db, {
tenantId: ctx.tenantId,
diff --git a/server/services/automation/notice-wording.ts b/server/services/automation/notice-wording.ts
new file mode 100644
index 000000000..6875f2bee
--- /dev/null
+++ b/server/services/automation/notice-wording.ts
@@ -0,0 +1,100 @@
+/**
+ * What a notice SAYS, in the language its reader reads.
+ *
+ * Extracted from the trigger mixin for the file-size ratchet, and it earns the
+ * split: everything here is about wording — the tenant's own in-app template
+ * when a rule references one, the built-in titles when it does not — while the
+ * trigger is about which rows to write.
+ */
+import { interpolate } from './shared';
+import { m } from '../../lib/i18n/messages';
+import type { NoticeWording } from './notice-headers';
+import type { TemplateStore } from './template-store';
+import type { ContactLocale } from '../../lib/i18n/contact-locale';
+import type { automations, inspections } from '../../lib/db/schema';
+
+/**
+ * The title STORED on a notice when a rule's template has no subject, or
+ * resolves to no template at all. Staff/ledger voice with the address —
+ * distinct from the recipient-voiced `notice_title_*` family that
+ * `app/lib/notice-view.ts` renders for types it recognises, which is why these
+ * carry the `comm_` prefix (same split as `comm_reason_sms_opt_out` vs
+ * `notice_reason_sms_opt_out`).
+ *
+ * `locale` is REQUIRED and names the RECIPIENT. Paraglide's message functions
+ * default to `getLocale()` when no locale option is passed, and in this path
+ * that is always wrong: a trigger fires from another user's request, from cron,
+ * or from a queue consumer, so the ambient answer describes either the wrong
+ * person or nobody. Making the parameter mandatory is the guard — an ambient
+ * read here would be a silent mistranslation, not a type error, so the type
+ * system is asked to make it one.
+ */
+export function noticeTitleFor(
+ event: string,
+ insp: typeof inspections.$inferSelect,
+ locale: ContactLocale,
+): string {
+ const address = insp.propertyAddress || 'inspection';
+ const at = { locale };
+ switch (event) {
+ case 'inspection.created': return m.comm_notice_title_inspection_created({ address }, at);
+ case 'inspection.confirmed': return m.comm_notice_title_inspection_confirmed({ address }, at);
+ case 'inspection.cancelled': return m.comm_notice_title_inspection_cancelled({ address }, at);
+ case 'report.published': return m.comm_notice_title_report_published({ address }, at);
+ case 'invoice.created': return m.comm_notice_title_invoice_created({ address }, at);
+ case 'payment.received': return m.comm_notice_title_payment_received({ address }, at);
+ // Deliberately kept: a trigger can be added to the enum before a
+ // template exists for it, and a readable " — " beats an
+ // empty notice title. It is now translatable too.
+ default: return m.comm_notice_title_generic({ event, address }, at);
+ }
+}
+
+/**
+ * B3 (IA-115) — one firing's wording resolver. The wording comes from each
+ * rule's in-app template when it has one, memoized so a rule fanning out to
+ * eight staff does not re-read the same template eight times.
+ *
+ * The memo is keyed by (rule, LOCALE), not by rule: one firing can reach an
+ * English agent and a Spanish client, so a key without the language would hand
+ * the first recipient's wording to everyone — exactly the bug this whole change
+ * exists to remove, just relocated into a cache.
+ */
+export function createNoticeWordingResolver(args: {
+ store: TemplateStore;
+ tenantId: string;
+ triggerEvent: string;
+ companyName: string;
+ inspection: typeof inspections.$inferSelect;
+ rules: Array;
+}): (automationId: string | null, locale: ContactLocale) => Promise {
+ const { store, tenantId, triggerEvent, companyName, inspection, rules } = args;
+ const cache = new Map();
+ const ruleById = new Map(rules.map((r) => [r.id, r]));
+ const vars = {
+ property_address: inspection.propertyAddress || 'inspection',
+ company_name: companyName,
+ scheduled_date: inspection.date ?? '',
+ };
+
+ return async (automationId, locale) => {
+ const key = `${automationId ?? ''}:${locale}`;
+ const hit = cache.get(key);
+ if (hit) return hit;
+ const rule = automationId ? ruleById.get(automationId) : undefined;
+ // The recipient's own language picks the variant; a tenant with no
+ // variant in it keeps getting the English row, which is the whole
+ // degrade-never-block contract (message-template.service#resolveForLocale).
+ const tpl = rule?.inAppTemplateId
+ ? await store.resolve(tenantId, rule.inAppTemplateId, locale)
+ : null;
+ const wording: NoticeWording = tpl && tpl.channel === 'in_app'
+ ? {
+ title: interpolate(tpl.subject ?? '', vars) || noticeTitleFor(triggerEvent, inspection, locale),
+ body: tpl.body ? interpolate(tpl.body, vars) : null,
+ }
+ : { title: noticeTitleFor(triggerEvent, inspection, locale), body: null };
+ cache.set(key, wording);
+ return wording;
+ };
+}
diff --git a/server/services/automation/recipients.ts b/server/services/automation/recipients.ts
index 048a2d4a3..060d4226b 100644
--- a/server/services/automation/recipients.ts
+++ b/server/services/automation/recipients.ts
@@ -5,7 +5,9 @@ import { logger } from '../../lib/logger';
import { PeopleService } from '../people.service';
import { capabilitiesForProfile } from '../../lib/people/capabilities';
import { getInspectionRoster } from '../../lib/inspection/roster';
-import { STAFF_ROLE_KEY } from './shared';
+import { STAFF_ROLE_KEY, isStaffRecipient } from './shared';
+import { createRecipientLocaleResolver, type RecipientLocaleResolver } from '../../lib/i18n/recipient-locale';
+import type { ContactLocale } from '../../lib/i18n/contact-locale';
import type { AutomationChannel, RecipientKind } from './shared';
export interface ResolvedRecipient {
@@ -13,6 +15,20 @@ export interface ResolvedRecipient {
roleKey: string;
email?: string;
phone?: string;
+ /**
+ * The language to address THIS person in — not the language of whoever's
+ * request set the fan-out going, and not the ambient locale, which in a
+ * cron or queue firing is `baseLocale` regardless of who is reading.
+ * One trigger, two recipients, two languages.
+ */
+ locale: ContactLocale;
+}
+
+/** `contactId` carries a `users.id` for the staff and inspector kinds (see the
+ * doc below), so the role key — not the id's shape — decides which table the
+ * locale is read from. */
+function refFor(r: { contactId: string; roleKey: string }) {
+ return { kind: isStaffRecipient(r.roleKey) ? 'user' as const : 'contact' as const, id: r.contactId };
}
/**
@@ -38,7 +54,13 @@ export async function resolveRuleRecipients(
rule: { recipientKind: RecipientKind; recipientRoleProfileId: string | null },
inspection: typeof inspections.$inferSelect,
channel: AutomationChannel,
+ /** Shared across one firing so a rule fanning out to eight staff over two
+ * channels reads the tenant config once, not sixteen times. */
+ localeFor: RecipientLocaleResolver = createRecipientLocaleResolver(drizzle(rawDb), inspection.tenantId),
): Promise {
+ const withLocale = async (rows: Array>): Promise =>
+ Promise.all(rows.map(async (r) => ({ ...r, locale: await localeFor(refFor(r)) })));
+
if (rule.recipientKind === 'staff') {
// B2 — the workspace's ADMIN staff: the set `createForAllAdmins`
// names, which is the audience every hard-coded internal alert
@@ -89,7 +111,7 @@ export async function resolveRuleRecipients(
...(channel === 'sms' ? { phone: addr } : { email: addr }),
});
}
- return out;
+ return withLocale(out);
}
if (rule.recipientKind === 'inspector') {
@@ -119,11 +141,11 @@ export async function resolveRuleRecipients(
const { normalizeE164 } = await import('../../lib/sms/phone');
const addr = channel === 'email' ? (u?.email ?? null) : normalizeE164(u?.phone ?? null);
if (!addr) return [];
- return [{
+ return withLocale([{
contactId: inspectorId ?? '',
roleKey: 'inspector',
...(channel === 'email' ? { email: addr } : { phone: addr }),
- }];
+ }]);
}
// Honor the "never throws" contract (see the doc comment above): the
@@ -161,5 +183,5 @@ export async function resolveRuleRecipients(
...(channel === 'email' ? { email: addr } : { phone: addr }),
});
}
- return out;
+ return withLocale(out);
}
diff --git a/server/services/automation/sms.ts b/server/services/automation/sms.ts
index 3d59074ed..5fc034043 100644
--- a/server/services/automation/sms.ts
+++ b/server/services/automation/sms.ts
@@ -57,8 +57,19 @@ export function AutomationSms>(Base: T
if (!sms) return void (await skip('sms not configured'));
const { createOiTemplateStore } = await import('./template-store');
+ // Same rule as the email path: the variant is chosen by the
+ // RECIPIENT's language, read from this log's own recipient. A cron
+ // flush has no request locale to leak, which is precisely why an
+ // ambient read here would look correct and be wrong.
+ const { createRecipientLocaleResolver } = await import('../../lib/i18n/recipient-locale');
+ const { isStaffRecipient } = await import('./shared');
+ const locale = await createRecipientLocaleResolver(db, inspection.tenantId)(
+ log.recipientContactId
+ ? { kind: isStaffRecipient(log.recipientRoleKey) ? 'user' : 'contact', id: log.recipientContactId }
+ : null,
+ );
const tpl = automation.smsTemplateId
- ? await createOiTemplateStore(this.db).resolve(inspection.tenantId, automation.smsTemplateId)
+ ? await createOiTemplateStore(this.db).resolve(inspection.tenantId, automation.smsTemplateId, locale)
: null;
if (!tpl || tpl.channel !== 'sms' || !tpl.body.trim()) return void (await skip('no sms template'));
diff --git a/server/services/automation/template-store.ts b/server/services/automation/template-store.ts
index cf0020825..3f1f10b44 100644
--- a/server/services/automation/template-store.ts
+++ b/server/services/automation/template-store.ts
@@ -15,14 +15,24 @@ interface ResolvedTemplate {
variables: string[];
}
export interface TemplateStore {
- resolve(tenantId: string, templateId: string): Promise;
+ /**
+ * `locale` names the RECIPIENT's language, and supplying it switches the
+ * lookup from "this row" to "this row's variant for that reader", with the
+ * fallback chain in `MessageTemplateService#resolveForLocale`. Omitting it
+ * is not a shorthand for English — it means the caller is inspecting the
+ * referenced row itself (the agreement-URL content gate in trigger.ts),
+ * where walking to a translation would answer a different question.
+ */
+ resolve(tenantId: string, templateId: string, locale?: string | null): Promise;
}
export function createOiTemplateStore(db: D1Database): TemplateStore {
const svc = new MessageTemplateService(db);
return {
- async resolve(tenantId, templateId) {
- const t = await svc.get(tenantId, templateId);
+ async resolve(tenantId, templateId, locale) {
+ const t = locale === undefined
+ ? await svc.get(tenantId, templateId)
+ : await svc.resolveForLocale(tenantId, templateId, locale);
if (!t) return null;
const out: ResolvedTemplate = { channel: t.channel, body: t.body, variables: t.variables };
if (t.subject != null) out.subject = t.subject;
diff --git a/server/services/automation/trigger.ts b/server/services/automation/trigger.ts
index 47583b0f4..f84fabc88 100644
--- a/server/services/automation/trigger.ts
+++ b/server/services/automation/trigger.ts
@@ -2,17 +2,17 @@ import type { DrizzleD1Database } from 'drizzle-orm/d1';
import { eq, and } from 'drizzle-orm';
import { automations, automationLogs, inspections } from '../../lib/db/schema';
import { nanoid } from 'nanoid';
-import { createHeadersForInsertedLogs, type NoticeWording } from './notice-headers';
+import { createHeadersForInsertedLogs } from './notice-headers';
+import { createNoticeWordingResolver } from './notice-wording';
import { logger } from '../../lib/logger';
import { createOiTemplateStore } from './template-store';
import { resolveRuleRecipients, type ResolvedRecipient } from './recipients';
import { automationClassId } from '../../lib/notifications/automation-classes';
import { getInspectionRoster } from '../../lib/inspection/roster';
-import { interpolate } from './shared';
import type { AutomationChannel, RecipientKind, Constructor, TriggerContext } from './shared';
import type { AutomationBase, HasEnsureSeeds, HasParseChannels } from './shared';
import { PRIMARY_CLIENT_KEY } from '../../lib/people/default-role-profiles';
-import { m } from '../../lib/i18n/messages';
+import { createRecipientLocaleResolver, type RecipientLocaleResolver } from '../../lib/i18n/recipient-locale';
/**
* Trigger mixin: fan out pending automation_log rows when a domain event fires,
@@ -66,6 +66,10 @@ export function AutomationTrigger();
- for (const rule of filteredRules) {
- if (!rule.inAppTemplateId) continue;
- const tpl = await store.resolve(ctx.tenantId, rule.inAppTemplateId);
- if (!tpl || tpl.channel !== 'in_app') continue;
- const vars = {
- property_address: insp.propertyAddress || 'inspection',
- company_name: ctx.companyName,
- scheduled_date: insp.date ?? '',
- };
- wordingByRule.set(rule.id, {
- title: interpolate(tpl.subject ?? '', vars) || this.titleFor(ctx.triggerEvent, insp),
- body: tpl.body ? interpolate(tpl.body, vars) : null,
- });
- }
- const fallback: NoticeWording = { title: this.titleFor(ctx.triggerEvent, insp), body: null };
+ // Wording lives in notice-wording.ts — the in-app template
+ // per (rule, recipient LANGUAGE), falling back to the
+ // built-in titles.
+ const wordingFor = createNoticeWordingResolver({
+ store, tenantId: ctx.tenantId, triggerEvent: ctx.triggerEvent,
+ companyName: ctx.companyName, inspection: insp, rules: filteredRules,
+ });
// The class comes from the RULE, like the wording — two rules
// on one event are two different things to have a preference
// about, so a per-firing class would be wrong for the same
@@ -206,8 +197,9 @@ export function AutomationTrigger (automationId && wordingByRule.get(automationId)) || fallback,
+ wordingFor,
(automationId) => (automationId ? classByRule.get(automationId) : undefined),
+ localeFor,
inserted,
);
} catch (err) {
@@ -362,37 +354,11 @@ export function AutomationTrigger {
- return resolveRuleRecipients(this.db, rule, inspection, channel);
- }
-
- /**
- * The title STORED on a notice when a rule's template has no subject,
- * or resolves to no template at all. Staff/ledger voice with the address
- * — distinct from the recipient-voiced `notice_title_*` family that
- * `app/lib/notice-view.ts` renders for types it recognises, which is why
- * these carry the `comm_` prefix (same split as
- * `comm_reason_sms_opt_out` vs `notice_reason_sms_opt_out`).
- *
- * Reading these through the catalogue does not yet make them render in
- * the RECIPIENT's language — nothing resolves a recipient locale, and in
- * a cron or queue context there is no request locale at all. It makes
- * them reachable by a translator, which they were not before.
- */
- protected titleFor(event: string, insp: typeof inspections.$inferSelect): string {
- const address = insp.propertyAddress || 'inspection';
- switch (event) {
- case 'inspection.created': return m.comm_notice_title_inspection_created({ address });
- case 'inspection.confirmed': return m.comm_notice_title_inspection_confirmed({ address });
- case 'inspection.cancelled': return m.comm_notice_title_inspection_cancelled({ address });
- case 'report.published': return m.comm_notice_title_report_published({ address });
- case 'invoice.created': return m.comm_notice_title_invoice_created({ address });
- case 'payment.received': return m.comm_notice_title_payment_received({ address });
- // Deliberately kept: a trigger can be added to the enum before a
- // template exists for it, and a readable " — "
- // beats an empty notice title. It is now translatable too.
- default: return m.comm_notice_title_generic({ event, address });
- }
+ return localeFor
+ ? resolveRuleRecipients(this.db, rule, inspection, channel, localeFor)
+ : resolveRuleRecipients(this.db, rule, inspection, channel);
}
};
diff --git a/tests/unit/automations/recipient-locale.spec.ts b/tests/unit/automations/recipient-locale.spec.ts
new file mode 100644
index 000000000..019b39985
--- /dev/null
+++ b/tests/unit/automations/recipient-locale.spec.ts
@@ -0,0 +1,211 @@
+/**
+ * One trigger firing, two languages.
+ *
+ * The recipient's locale is NOT the request's locale. A trigger fires from
+ * another user's request, from cron, or from a queue consumer — and in the last
+ * two there is no request at all, so `getLocale()` answers `baseLocale` for
+ * everyone. Every assertion here is written so that an implementation which
+ * read the ambient locale, or which hardcoded English, would produce a
+ * DIFFERENT, observable string.
+ *
+ * The mixed-locale inspection is the subject: an English-reading agent and a
+ * Spanish-reading client on the same property, notified by one rule.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { AutomationService } from '../../../server/services/automation.service';
+import { PeopleService } from '../../../server/services/people.service';
+import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles';
+// eslint-disable-next-line no-restricted-imports -- the ambient locale is the thing under test; a spec that cannot set it cannot prove rendering ignores it.
+import { overwriteGetLocale, baseLocale } from '~/paraglide/runtime';
+
+const T = '00000000-0000-0000-0000-00000000c0de';
+const OTHER_T = '00000000-0000-0000-0000-00000000c0df';
+const INSP = '00000000-0000-0000-0000-00000000cafe';
+const roleProfileId = (key: string) => `crp_${T}_${key}`;
+
+let db: BetterSQLite3Database;
+let svc: AutomationService;
+
+beforeEach(async () => {
+ const fx = createTestDb();
+ db = fx.db;
+ await setupSchema(fx.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+
+ for (const [id, slug] of [[T, 'acme-c0de'], [OTHER_T, 'other-c0df']] as const) {
+ await db.insert(schema.tenants).values({
+ id, name: 'Acme', slug, status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ } as never);
+ }
+ // The tenant reads English. Every Spanish string below therefore has to
+ // come from the RECIPIENT, not from the company's own default.
+ await db.insert(schema.tenantConfigs).values({
+ tenantId: T, defaultLocale: 'en-US', updatedAt: new Date(),
+ } as never);
+ await seedRoleProfiles(db, T, new Date(1));
+ await db.insert(schema.inspections).values({
+ id: INSP, tenantId: T, propertyAddress: '12 Oak Lane', date: '2026-06-01',
+ status: 'completed', reportStatus: 'published', paymentStatus: 'unpaid',
+ price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(),
+ } as never);
+
+ svc = new AutomationService({} as D1Database);
+ vi.spyOn(svc, 'ensureSeeds').mockResolvedValue();
+});
+
+afterEach(() => {
+ overwriteGetLocale(() => baseLocale);
+});
+
+async function addPerson(id: string, opts: { role: string; email: string; locale: string | null; type?: 'client' | 'agent' }) {
+ await db.insert(schema.contacts).values({
+ id, tenantId: T, type: opts.type ?? 'client', name: id, email: opts.email,
+ locale: opts.locale, createdAt: new Date(),
+ } as never);
+ await new PeopleService({ DB: {} as D1Database }).addPerson(T, INSP, id, roleProfileId(opts.role));
+}
+
+/** Seeds the Spanish variant FIRST so no assertion can be satisfied by a row
+ * that merely happens to come back earlier. */
+async function seedInAppTemplate(opts: { spanish: boolean; tenantId?: string }) {
+ const tenantId = opts.tenantId ?? T;
+ if (opts.spanish) {
+ await db.insert(schema.messageTemplates).values({
+ id: `tpl-es-${tenantId}`, tenantId, name: 'Report ready (in-app)', channel: 'in_app',
+ subject: 'Informe listo — {{property_address}}', body: 'Publicado por {{company_name}}.',
+ variables: null, isSeeded: true, locale: 'es-419',
+ createdAt: new Date(1), updatedAt: new Date(1),
+ } as never);
+ }
+ await db.insert(schema.messageTemplates).values({
+ id: `tpl-en-${tenantId}`, tenantId, name: 'Report ready (in-app)', channel: 'in_app',
+ subject: 'Report ready — {{property_address}}', body: 'Published by {{company_name}}.',
+ variables: null, isSeeded: true, locale: 'en',
+ createdAt: new Date(2), updatedAt: new Date(2),
+ } as never);
+}
+
+/**
+ * The rule dispatches by EMAIL and carries an in-app template for the notice
+ * header's wording — the header is written for every inserted log whatever the
+ * channel. Deliberately not `channels: ["in_app"]`: `resolveRuleRecipients`
+ * resolves any non-`email` channel through the PHONE column, so an `in_app` rule
+ * aimed at contacts resolves nobody at all. That is a pre-existing defect on a
+ * path unrelated to language, and fixing it inside a locale change would hide it.
+ */
+async function seedRule(opts: { inAppTemplateId: string | null }) {
+ await db.insert(schema.automations).values({
+ id: 'auto-locale', tenantId: T, name: 'Tell everyone', trigger: 'report.published',
+ recipientKind: 'all', recipientRoleProfileId: null, delayMinutes: 0,
+ subjectTemplate: '', bodyTemplate: '', channels: '["email"]',
+ inAppTemplateId: opts.inAppTemplateId,
+ active: true, isDefault: false, createdAt: new Date(),
+ } as never);
+}
+
+const fire = () => svc.trigger({
+ tenantId: T, inspectionId: INSP, triggerEvent: 'report.published',
+ companyName: 'Acme', reportBaseUrl: 'https://app.example.com',
+});
+
+const titleFor = async (contactId: string) => {
+ const rows = await db.select().from(schema.notifications);
+ return rows.find((r) => r.contactId === contactId)?.title ?? null;
+};
+
+describe('recipient-locale notifications', () => {
+ it('renders one trigger firing in two languages', async () => {
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await addPerson('agent-1', { role: 'buyer_agent', email: 'a@example.com', locale: 'en', type: 'agent' });
+ await seedInAppTemplate({ spanish: true });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('agent-1')).toBe('Report ready — 12 Oak Lane');
+ expect(await titleFor('client-1')).toBe('Informe listo — 12 Oak Lane');
+ });
+
+ it('does not read the ambient locale', async () => {
+ // Set the ambient locale to Spanish and notify an English reader. If
+ // rendering leaks ambient state this returns Spanish — which is exactly
+ // what happens in a cron context today, only inverted and invisible.
+ // The rule carries no in-app template, so this exercises `titleFor`,
+ // the one place server code reads the message catalogue.
+ overwriteGetLocale(() => 'es-419');
+ await addPerson('agent-1', { role: 'buyer_agent', email: 'a@example.com', locale: 'en', type: 'agent' });
+ await seedRule({ inAppTemplateId: null });
+
+ await fire();
+
+ expect(await titleFor('agent-1')).toBe('Report published — 12 Oak Lane');
+ });
+
+ it('renders the built-in title in the recipient\'s language, not the base locale', async () => {
+ // The mirror of the test above, and the reason it is not enough on its
+ // own: a `titleFor` that ignored its argument entirely would pass an
+ // English-only assertion.
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await seedRule({ inAppTemplateId: null });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Informe publicado — 12 Oak Lane');
+ });
+
+ it('keeps sending English to a Spanish reader when no Spanish variant exists', async () => {
+ // Degrade, never block. A tenant who has authored nothing in Spanish
+ // must still deliver something — silence is the one unacceptable
+ // outcome for a notification.
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await seedInAppTemplate({ spanish: false });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Report ready — 12 Oak Lane');
+ });
+
+ it('treats a null contact locale as absence, falling through to the tenant default', async () => {
+ // The booking form deliberately does not ask for a language on the
+ // agent-on-behalf branch, so NULL is the common case. It must mean
+ // "fall back", never "fail" — and never "Spanish because the last
+ // recipient was".
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await addPerson('other-1', { role: 'buyer_agent', email: 'o@example.com', locale: null, type: 'agent' });
+ await seedInAppTemplate({ spanish: true });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Informe listo — 12 Oak Lane');
+ expect(await titleFor('other-1')).toBe('Report ready — 12 Oak Lane');
+ });
+
+ it('never picks another tenant\'s variant', async () => {
+ // The only es-419 row in the table belongs to someone else, under the
+ // same name and channel. A fallback chain that walked locales without
+ // pinning the tenant would put another company's copy in this client's
+ // inbox.
+ await db.insert(schema.messageTemplates).values({
+ id: 'tpl-leak', tenantId: OTHER_T, name: 'Report ready (in-app)', channel: 'in_app',
+ subject: 'FILTRADO — {{property_address}}', body: 'x',
+ variables: null, isSeeded: true, locale: 'es-419',
+ createdAt: new Date(1), updatedAt: new Date(1),
+ } as never);
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await seedInAppTemplate({ spanish: false });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Report ready — 12 Oak Lane');
+ });
+});
diff --git a/tests/unit/automations/resolve-recipients.spec.ts b/tests/unit/automations/resolve-recipients.spec.ts
index d2d3840ee..a5f2790f2 100644
--- a/tests/unit/automations/resolve-recipients.spec.ts
+++ b/tests/unit/automations/resolve-recipients.spec.ts
@@ -74,7 +74,7 @@ describe('AutomationService.resolveRecipients', () => {
);
expect(result).toEqual([
- { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com' },
+ { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com', locale: 'en' },
]);
});
@@ -95,8 +95,8 @@ describe('AutomationService.resolveRecipients', () => {
);
expect(result.slice().sort((a, b) => a.contactId.localeCompare(b.contactId))).toEqual([
- { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com' },
- { contactId: 'c-client-1', roleKey: 'client', email: 'jane@example.com' },
+ { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com', locale: 'en' },
+ { contactId: 'c-client-1', roleKey: 'client', email: 'jane@example.com', locale: 'en' },
]);
expect(result.some(r => r.contactId === 'c-listing-1')).toBe(false);
});
@@ -115,7 +115,7 @@ describe('AutomationService.resolveRecipients', () => {
);
expect(result).toEqual([
- { contactId: 'u-inspector-1', roleKey: 'inspector', email: 'inspector@example.com' },
+ { contactId: 'u-inspector-1', roleKey: 'inspector', email: 'inspector@example.com', locale: 'en' },
]);
});
diff --git a/tests/unit/automations/staff-recipients.spec.ts b/tests/unit/automations/staff-recipients.spec.ts
index 471e55cbd..7ad34248d 100644
--- a/tests/unit/automations/staff-recipients.spec.ts
+++ b/tests/unit/automations/staff-recipients.spec.ts
@@ -140,9 +140,12 @@ describe('staff recipients (B2)', () => {
await createHeadersForInsertedLogs(
db,
{ tenantId: T, inspectionId: INSP, triggerEvent: 'report.published' },
- () => ({ title: 'Report published', body: null }),
+ async () => ({ title: 'Report published', body: null }),
// No class: this fixture exercises the XOR, not the preference gate.
() => undefined,
+ // No tenant config seeded here, so the resolver would answer 'en'
+ // anyway; stubbing it keeps this fixture about the XOR.
+ async () => 'en',
[{ id: 'log-staff-1', automationId: null, sendAt: new Date(0), recipientContactId: 'u-owner', recipientRoleKey: STAFF_ROLE_KEY }],
);
From 688703fe4bd6c7b8aaa34c6c0cadd262a0adff59 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 21:25:01 +0800
Subject: [PATCH 072/111] feat(i18n): let tenants author a template in each
language
The editor listed rows; it now lists TEMPLATES, with the languages each has been
written in. A tenant thinks "my reminder email, in Spanish" -- one template they
have written twice -- and a flat list showing two unrelated rows called "Reminder"
invites deleting the wrong one. Grouping is by (name, channel), the same key the
send path resolves a variant by, so what the screen shows and what the sender
does cannot disagree.
The languages NOT yet written are shown too, as an invitation rather than a
warning: most companies will only ever write one, and a warning colour would say
something is broken when nothing is. The note next to them says recipients still
receive the message in the language that exists -- which is what stops "my
Spanish clients got English" being filed as a bug.
Adding a language starts from the existing version: translating beats retyping
and it keeps the merge variables intact. The name is read-only there, because
versions are matched by name and letting it drift would silently create an
unrelated template no fallback would ever reach. Language is settable only at
create -- there is no PATCH path for it, since changing it would reassign copy to
readers it was not written for.
Seeds stay English-only, on purpose, and the seeds file now says why: this is
copy a tenant sends under their own company name, and our Spanish would impose
our vocabulary on a market we do not know. Ship the mechanism, not the wording.
Chrome, both themes: the muted token this surface reached for first fails WCAG AA
in both (2.45:1 light, 3.75:1 dark), so the new text sits one step up at 4.55 /
6.96. No horizontal overflow at 1260px. The editor modal moved to its own
component to stay under the file-size cap.
---
.../settings/TemplateEditorModal.tsx | 378 ++++++++++++++
app/routes/communication-templates.test.ts | 53 +-
.../settings-communication-templates.tsx | 494 +++++-------------
messages/en/settings-integrations.json | 9 +-
messages/es-419/settings-integrations.json | 9 +-
scripts/file-size-baseline.json | 2 +-
server/api/message-templates.ts | 3 +-
server/data/automation-seeds.ts | 17 +
server/lib/mcp/openapi-snapshot.json | 8 +
.../validations/message-template.schema.ts | 8 +
10 files changed, 626 insertions(+), 355 deletions(-)
create mode 100644 app/components/settings/TemplateEditorModal.tsx
diff --git a/app/components/settings/TemplateEditorModal.tsx b/app/components/settings/TemplateEditorModal.tsx
new file mode 100644
index 000000000..9b478fec7
--- /dev/null
+++ b/app/components/settings/TemplateEditorModal.tsx
@@ -0,0 +1,378 @@
+import { useState, useEffect, useRef } from "react";
+import { useFetcher } from "react-router";
+import { Button, Pill, Modal } from "@core/shared-ui";
+import { m } from "~/paraglide/messages";
+import { SUPPORTED_CONTACT_LOCALES } from "../../../server/lib/i18n/contact-locale";
+import { localeLabel } from "~/lib/locales";
+
+// ─── Exported pure helper ────────────────────────────────────────────────────
+
+/** GSM-ish client segment estimate — mirrors server smsSegmentInfo thresholds. */
+export function smsSegmentsClient(body: string): number {
+ const len = [...body].length;
+ if (len === 0) return 0;
+ // Client keeps the GSM happy-path estimate (server is authoritative on send).
+ return len <= 160 ? 1 : Math.ceil(len / 153);
+}
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export interface MessageTemplate {
+ id: string;
+ tenantId: string;
+ name: string;
+ channel: "email" | "sms";
+ subject: string | null;
+ body: string;
+ variables: string[];
+ /** Which language version this row IS. */
+ locale: string;
+ isSeeded: boolean;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export /** What the editor modal is currently doing. */
+type EditorTarget =
+ | { kind: "edit"; template: MessageTemplate }
+ | { kind: "new"; channel: "email" | "sms"; locale: string; prefill: MessageTemplate | null };
+
+
+// ─── Template editor modal ────────────────────────────────────────────────────
+
+export function TemplateEditorModal({
+ target,
+ onClose,
+}: {
+ target: EditorTarget;
+ onClose: () => void;
+}) {
+ const template = target.kind === "edit" ? target.template : null;
+ const prefill = target.kind === "new" ? target.prefill : null;
+ const channel = target.kind === "edit" ? target.template.channel : target.channel;
+ const isEmail = channel === "email";
+ // The language of the row being written. Fixed for an edit (a version's
+ // language is what it IS) and for a new version of an existing template
+ // (which is the whole reason the tenant clicked "Add Spanish"); choosable
+ // only when starting a template from nothing.
+ const fixedLocale = template?.locale ?? (prefill ? target.kind === "new" ? target.locale : "en" : null);
+ const [locale, setLocale] = useState(
+ template?.locale ?? (target.kind === "new" ? target.locale : "en"),
+ );
+
+ const fetcher = useFetcher<{
+ ok: boolean;
+ intent?: string;
+ preview?: { subject?: string; html?: string; text?: string };
+ error?: string;
+ }>();
+ const previewFetcher = useFetcher<{
+ ok: boolean;
+ intent?: string;
+ preview?: { subject?: string; html?: string; text?: string };
+ error?: string;
+ }>();
+
+ // A new language version starts from the existing one: translating beats
+ // retyping, and it keeps the merge variables intact.
+ const [name, setName] = useState(template?.name ?? prefill?.name ?? "");
+ const [subject, setSubject] = useState(template?.subject ?? prefill?.subject ?? "");
+ const [body, setBody] = useState(template?.body ?? prefill?.body ?? "");
+ const bodyRef = useRef(null);
+ const [testTo, setTestTo] = useState("");
+ const [testSent, setTestSent] = useState(false);
+
+ const segmentCount = !isEmail ? smsSegmentsClient(body) : 0;
+
+ useEffect(() => {
+ if (
+ fetcher.state === "idle" &&
+ fetcher.data?.ok &&
+ fetcher.data.intent !== "preview" &&
+ fetcher.data.intent !== "test-send"
+ ) {
+ onClose();
+ }
+ }, [fetcher.state, fetcher.data, onClose]);
+
+ useEffect(() => {
+ if (
+ fetcher.state === "idle" &&
+ fetcher.data?.ok &&
+ fetcher.data.intent === "test-send"
+ ) {
+ setTestSent(true);
+ }
+ }, [fetcher.state, fetcher.data]);
+
+ function insertVariable(v: string) {
+ const ta = bodyRef.current;
+ if (!ta) {
+ setBody((b) => b + `{{${v}}}`);
+ return;
+ }
+ const start = ta.selectionStart ?? body.length;
+ const end = ta.selectionEnd ?? body.length;
+ const snippet = `{{${v}}}`;
+ const next = body.slice(0, start) + snippet + body.slice(end);
+ setBody(next);
+ requestAnimationFrame(() => {
+ ta.setSelectionRange(start + snippet.length, start + snippet.length);
+ ta.focus();
+ });
+ }
+
+ const variables = template?.variables ?? prefill?.variables ?? [];
+ const isSaving = fetcher.state !== "idle";
+ const isTesting =
+ fetcher.state !== "idle" && fetcher.formData?.get("intent") === "test-send";
+ const isPreviewing = previewFetcher.state !== "idle";
+ const previewData = previewFetcher.data?.preview;
+
+ return (
+
+
+ {m.common_cancel()}
+
+
+
+ {template && }
+
+
+
+ {isEmail && }
+
+ {variables.map((v) => (
+
+ ))}
+
+ {template ? m.common_save() : m.settings_msgtpl_create()}
+
+
+ >
+ }
+ >
+
+ {fetcher.data && !fetcher.data.ok && fetcher.data.intent !== "test-send" && (
+
+ {fetcher.data.error ?? m.settings_error_generic()}
+
+ )}
+
+ {/* Language */}
+
+
+ {m.settings_msgtpl_language_label()}
+
+ {fixedLocale ? (
+
+
{localeLabel(fixedLocale)}
+
+ {m.settings_msgtpl_language_locked({ name: name || m.settings_msgtpl_name_placeholder() })}
+
+
+ ) : (
+
setLocale(e.target.value)}
+ className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1"
+ >
+ {SUPPORTED_CONTACT_LOCALES.map((l) => (
+ {localeLabel(l)}
+ ))}
+
+ )}
+
+
+ {/* Name */}
+
+
+ {m.settings_msgtpl_name_label()}
+
+ setName(e.target.value)}
+ placeholder={m.settings_msgtpl_name_placeholder()}
+ required
+ // Versions are matched by (name, channel). Letting the name drift
+ // here would silently create an unrelated template that no send
+ // path would ever fall back to.
+ readOnly={prefill !== null}
+ className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4 read-only:text-ih-fg-3"
+ />
+
+
+ {/* Subject (email only) */}
+ {isEmail && (
+
+
+ {m.settings_msgtpl_subject_line_label()}
+
+ setSubject(e.target.value)}
+ placeholder={m.settings_msgtpl_subject_placeholder()}
+ className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4"
+ />
+
+ )}
+
+ {/* Body */}
+
+
+ {isEmail ? m.settings_msgtpl_email_body_label() : m.settings_msgtpl_sms_body_label()}
+
+ {variables.length > 0 && (
+
+ {m.settings_msgtpl_insert_label()}
+ {variables.map((v) => (
+ insertVariable(v)}
+ className="text-[11px] px-1.5 py-0.5 rounded border border-ih-border bg-ih-bg-input text-ih-primary font-mono hover:bg-ih-primary-tint transition-colors"
+ >
+ {`{{${v}}}`}
+
+ ))}
+
+ )}
+
+
+ {/* Email preview */}
+ {isEmail && (
+
+
+
+ {m.settings_msgtpl_preview_label()}
+
+
+
+
+
+
+
+ {isPreviewing ? m.common_loading() : m.settings_msgtpl_refresh_preview()}
+
+
+
+ {previewData && (
+
+ {previewData.subject && (
+
+ {m.settings_msgtpl_preview_subject_label()}{" "}
+ {previewData.subject}
+
+ )}
+ {previewData.html && (
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Test send */}
+
+
+ {isEmail ? m.settings_msgtpl_test_send_email_heading() : m.settings_msgtpl_test_send_sms_heading()}
+
+
+
+
+ {isEmail ? m.settings_msgtpl_to_email_label() : m.settings_msgtpl_to_phone_label()}
+
+ {
+ setTestTo(e.target.value);
+ setTestSent(false);
+ }}
+ placeholder={isEmail ? m.settings_msgtpl_to_email_placeholder() : m.settings_msgtpl_to_phone_placeholder()}
+ type={isEmail ? "email" : "tel"}
+ className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4"
+ />
+
+
+
+
+ {isEmail && }
+
+
+
+ {isTesting ? m.settings_sending() : m.settings_send()}
+
+
+
+ {testSent && (
+
{m.settings_msgtpl_test_sent()}
+ )}
+ {fetcher.data && !fetcher.data.ok && fetcher.data.intent === "test-send" && (
+
{fetcher.data.error}
+ )}
+
+
+
+ );
+}
+
diff --git a/app/routes/communication-templates.test.ts b/app/routes/communication-templates.test.ts
index d6e37e65f..b78574611 100644
--- a/app/routes/communication-templates.test.ts
+++ b/app/routes/communication-templates.test.ts
@@ -1,6 +1,12 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
-import { smsSegmentsClient } from '~/routes/settings-communication-templates';
+import { smsSegmentsClient, groupTemplateVariants } from '~/routes/settings-communication-templates';
+
+type Row = Parameters[0][number];
+const row = (over: Partial & Pick): Row => ({
+ tenantId: 't', channel: 'email', subject: null, body: 'b', variables: [],
+ isSeeded: false, createdAt: 0, updatedAt: 0, ...over,
+});
// The route module exports a tiny pure helper used by the SMS editor so it is
// unit-testable without a DOM. (Mirror server smsSegmentInfo thresholds.)
@@ -10,4 +16,49 @@ describe('settings-communication-templates client helpers', () => {
expect(smsSegmentsClient('short')).toBe(1);
expect(smsSegmentsClient('a'.repeat(161))).toBe(2);
});
+
+ // The list is what tells a tenant their Spanish clients are getting English.
+ // Every case below is seeded so a WRONG grouping produces a different,
+ // observable answer -- the Spanish row first, so an implementation that
+ // trusted arrival order would name the wrong base.
+ describe('groupTemplateVariants', () => {
+ it('groups language versions of one template into one row', () => {
+ const groups = groupTemplateVariants([
+ row({ id: 'es', name: 'Reminder', locale: 'es-419', createdAt: 2 }),
+ row({ id: 'en', name: 'Reminder', locale: 'en', createdAt: 1 }),
+ ]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0].variants.map((v) => v.id)).toEqual(['en', 'es']);
+ expect(groups[0].base.id).toBe('en');
+ expect(groups[0].missing).toEqual([]);
+ });
+
+ it('names the languages a template has NOT been written in', () => {
+ const groups = groupTemplateVariants([row({ id: 'en', name: 'Reminder', locale: 'en' })]);
+ expect(groups[0].missing).toEqual(['es-419']);
+ });
+
+ it('never merges two channels that share a name', () => {
+ // An SMS "Reminder" is not a translation of the email one; merging them
+ // would offer to "add Spanish" to a template that already has it.
+ const groups = groupTemplateVariants([
+ row({ id: 'sms-en', name: 'Reminder', locale: 'en', channel: 'sms' }),
+ row({ id: 'email-en', name: 'Reminder', locale: 'en', channel: 'email' }),
+ ]);
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.missing.includes('es-419'))).toBe(true);
+ });
+
+ it('lists a duplicate language rather than hiding it', () => {
+ // Nothing enforces uniqueness on (name, channel, locale). A group that
+ // collapsed duplicates would leave the tenant unable to see -- or delete
+ // -- the row the send path is not using.
+ const groups = groupTemplateVariants([
+ row({ id: 'a', name: 'Reminder', locale: 'en', createdAt: 1 }),
+ row({ id: 'b', name: 'Reminder', locale: 'en', createdAt: 2 }),
+ ]);
+ expect(groups[0].variants.map((v) => v.id)).toEqual(['a', 'b']);
+ expect(groups[0].missing).toEqual(['es-419']);
+ });
+ });
});
diff --git a/app/routes/settings-communication-templates.tsx b/app/routes/settings-communication-templates.tsx
index 02d526394..f5566f9f5 100644
--- a/app/routes/settings-communication-templates.tsx
+++ b/app/routes/settings-communication-templates.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect } from "react";
import { Link, useLoaderData, useFetcher } from "react-router";
import { SettingsCrumb } from "~/components/SettingsCrumb";
import type { Route } from "./+types/settings-communication-templates";
@@ -7,32 +7,62 @@ import { createApi } from "~/lib/api-client.server";
import { requireAdminLoader } from "~/lib/access.server";
import { AccessDenied } from "~/components/AccessDenied";
import { Button, Pill, TabStrip, EmptyState, Card, Modal } from "@core/shared-ui";
+import {
+ TemplateEditorModal, smsSegmentsClient,
+ type MessageTemplate, type EditorTarget,
+} from "~/components/settings/TemplateEditorModal";
import { m } from "~/paraglide/messages";
import { LoadFailedNotice } from "~/components/LoadFailedNotice";
-
-// ─── Exported pure helper ────────────────────────────────────────────────────
-
-/** GSM-ish client segment estimate — mirrors server smsSegmentInfo thresholds. */
-export function smsSegmentsClient(body: string): number {
- const len = [...body].length;
- if (len === 0) return 0;
- // Client keeps the GSM happy-path estimate (server is authoritative on send).
- return len <= 160 ? 1 : Math.ceil(len / 153);
-}
+import { SUPPORTED_CONTACT_LOCALES } from "../../server/lib/i18n/contact-locale";
+import { localeLabel } from "~/lib/locales";
// ─── Types ───────────────────────────────────────────────────────────────────
-interface MessageTemplate {
- id: string;
- tenantId: string;
+// Re-exported: the SMS segment estimate moved to the editor that uses it, and
+// the co-located spec addresses it here.
+export { smsSegmentsClient };
+
+/** One template, in however many languages the tenant has written it. */
+export interface TemplateGroup {
+ key: string;
name: string;
channel: "email" | "sms";
- subject: string | null;
- body: string;
- variables: string[];
- isSeeded: boolean;
- createdAt: number;
- updatedAt: number;
+ /** Oldest first — the same order the send path resolves duplicates in. */
+ variants: MessageTemplate[];
+ /** The row a new language version is seeded from. */
+ base: MessageTemplate;
+ /** Supported languages this template has NOT been written in yet. */
+ missing: string[];
+}
+
+/**
+ * Group rows into templates-with-versions.
+ *
+ * The tenant thinks "my reminder email, in Spanish" — one template they have
+ * written twice. A flat list showing two unrelated rows called "Reminder"
+ * invites deleting the wrong one, and hides the fact that a language is
+ * missing, which is the thing they cannot otherwise see.
+ *
+ * Grouping is by (name, channel), matching how the send path finds a variant.
+ * Nothing enforces uniqueness on (name, channel, locale), so a group can hold
+ * two rows in one language; both are listed rather than silently collapsed, so
+ * a duplicate is visible to the person who can fix it.
+ */
+export function groupTemplateVariants(templates: MessageTemplate[]): TemplateGroup[] {
+ const groups = new Map();
+ for (const t of templates) {
+ const key = `${t.channel}::${t.name}`;
+ const g = groups.get(key);
+ if (g) { g.variants.push(t); continue; }
+ groups.set(key, { key, name: t.name, channel: t.channel, variants: [t], base: t, missing: [] });
+ }
+ for (const g of groups.values()) {
+ g.variants.sort((a, b) => (a.createdAt - b.createdAt) || a.id.localeCompare(b.id));
+ g.base = g.variants[0];
+ const present = new Set(g.variants.map((v) => v.locale));
+ g.missing = SUPPORTED_CONTACT_LOCALES.filter((l) => !present.has(l));
+ }
+ return [...groups.values()];
}
interface ReferencingAutomation {
@@ -86,9 +116,15 @@ export async function action({ request, context }: Route.ActionArgs) {
const subject = channel === "email" ? (String(form.get("subject") ?? "").trim() || null) : null;
const body = String(form.get("body") ?? "");
const variables = form.getAll("variables").map(String).filter(Boolean);
- const res = await api.messageTemplates.index.$post({
- json: { name, channel, subject, body, variables },
- });
+ // A version's language is set once, at create. There is deliberately no
+ // update path for it: changing it would reassign copy to readers it was
+ // not written for.
+ const locale = String(form.get("locale") ?? "en");
+ const res = await (
+ api.messageTemplates.index.$post as unknown as (a: {
+ json: { name: string; channel: "email" | "sms"; subject: string | null; body: string; variables: string[]; locale: string };
+ }) => Promise
+ )({ json: { name, channel, subject, body, variables, locale } });
if (!res.ok) return { ok: false, error: m.settings_msgtpl_create_error(), intent };
return { ok: true, intent };
}
@@ -185,13 +221,14 @@ export async function action({ request, context }: Route.ActionArgs) {
export default function SettingsCommunicationTemplates() {
const data = useLoaderData();
const [activeTab, setActiveTab] = useState<"email" | "sms">("email");
- const [editing, setEditing] = useState(null);
+ const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
if ("forbidden" in data) return ;
const { emailTemplates, smsTemplates } = data;
const templates = activeTab === "email" ? emailTemplates : smsTemplates;
+ const groups = groupTemplateVariants(templates);
return (
@@ -208,7 +245,7 @@ export default function SettingsCommunicationTemplates() {
{m.settings_msgtpl_intro()}
setEditing(activeTab === "email" ? "new-email" : "new-sms")}
+ onClick={() => setEditing({ kind: "new", channel: activeTab, locale: "en", prefill: null })}
>
{m.settings_msgtpl_new_button()}
@@ -224,8 +261,11 @@ export default function SettingsCommunicationTemplates() {
/>
setEditing({ kind: "edit", template: t })}
+ onAddVariant={(g, locale) =>
+ setEditing({ kind: "new", channel: g.channel, locale, prefill: g.base })
+ }
onDelete={setDeleting}
/>
@@ -234,16 +274,7 @@ export default function SettingsCommunicationTemplates() {
{editing !== null && (
setEditing(null)}
/>
)}
@@ -258,17 +289,19 @@ export default function SettingsCommunicationTemplates() {
// ─── Template list ────────────────────────────────────────────────────────────
function TemplateList({
- templates,
+ groups,
onEdit,
+ onAddVariant,
onDelete,
}: {
- templates: MessageTemplate[];
+ groups: TemplateGroup[];
onEdit: (t: MessageTemplate) => void;
+ onAddVariant: (g: TemplateGroup, locale: string) => void;
onDelete: (t: MessageTemplate) => void;
}) {
const fetcher = useFetcher<{ ok: boolean; error?: string }>();
- if (templates.length === 0) {
+ if (groups.length === 0) {
return (
- {templates.map((t) => (
+ {groups.map((g) => {
+ const t = g.base;
+ return (
-
{t.name}
+
{g.name}
{t.isSeeded &&
{m.settings_msgtpl_builtin_pill()} }
{t.subject && (
@@ -300,14 +335,9 @@ function TemplateList({
{m.settings_msgtpl_variables_prefix({ vars: t.variables.map((v) => `{{${v}}}`).join(", ") })}
)}
+
- onEdit(t)}
- className="text-[12px] text-ih-primary font-semibold hover:underline"
- >
- {m.common_edit()}
-
@@ -318,22 +348,77 @@ function TemplateList({
{m.settings_msgtpl_duplicate()}
- {!t.isSeeded && (
- onDelete(t)}
- className="text-[12px] text-ih-bad-fg font-semibold hover:underline"
- >
- {m.common_delete()}
-
- )}
- ))}
+ );
+ })}
);
}
+// ─── Language versions ────────────────────────────────────────────────────────
+
+/**
+ * Which languages this template has been written in, and which it has not.
+ *
+ * The missing ones are shown as an INVITATION, not an error: most companies
+ * will only ever write one language, and a warning colour would tell them
+ * something is broken when nothing is. Recipients in a language nobody wrote
+ * still receive the message — that is the fallback, and saying so here is what
+ * stops "my Spanish clients got English" being filed as a bug.
+ */
+function VariantRow({
+ group,
+ onEdit,
+ onAddVariant,
+ onDelete,
+}: {
+ group: TemplateGroup;
+ onEdit: (t: MessageTemplate) => void;
+ onAddVariant: (g: TemplateGroup, locale: string) => void;
+ onDelete: (t: MessageTemplate) => void;
+}) {
+ return (
+
+ {m.settings_msgtpl_languages_label()}
+ {group.variants.map((v) => (
+
+ onEdit(v)}
+ className="text-[11px] px-2 py-0.5 font-semibold text-ih-primary hover:underline"
+ >
+ {localeLabel(v.locale)}
+
+ {!v.isSeeded && (
+ onDelete(v)}
+ aria-label={m.settings_msgtpl_delete_variant_aria({ language: localeLabel(v.locale) })}
+ className="text-[11px] pr-2 pl-0.5 text-ih-fg-3 hover:text-ih-bad-fg"
+ >
+ ×
+
+ )}
+
+ ))}
+ {group.missing.map((loc) => (
+ onAddVariant(group, loc)}
+ className="text-[11px] px-2 py-0.5 rounded-md border border-dashed border-ih-border text-ih-fg-3 hover:text-ih-fg-1 hover:border-ih-fg-3 transition-colors"
+ >
+ {m.settings_msgtpl_variant_add({ language: localeLabel(loc) })}
+
+ ))}
+ {group.missing.length > 0 && (
+
+ {m.settings_msgtpl_variant_missing_note()}
+
+ )}
+
+ );
+}
+
// ─── Delete modal ─────────────────────────────────────────────────────────────
function DeleteModal({
@@ -408,297 +493,6 @@ function DeleteModal({
);
}
-// ─── Template editor modal ────────────────────────────────────────────────────
-
-function TemplateEditorModal({
- template,
- defaultChannel,
- onClose,
-}: {
- template: MessageTemplate | null;
- defaultChannel: "email" | "sms";
- onClose: () => void;
-}) {
- const channel = template?.channel ?? defaultChannel;
- const isEmail = channel === "email";
-
- const fetcher = useFetcher<{
- ok: boolean;
- intent?: string;
- preview?: { subject?: string; html?: string; text?: string };
- error?: string;
- }>();
- const previewFetcher = useFetcher<{
- ok: boolean;
- intent?: string;
- preview?: { subject?: string; html?: string; text?: string };
- error?: string;
- }>();
-
- const [name, setName] = useState(template?.name ?? "");
- const [subject, setSubject] = useState(template?.subject ?? "");
- const [body, setBody] = useState(template?.body ?? "");
- const bodyRef = useRef(null);
- const [testTo, setTestTo] = useState("");
- const [testSent, setTestSent] = useState(false);
-
- const segmentCount = !isEmail ? smsSegmentsClient(body) : 0;
-
- useEffect(() => {
- if (
- fetcher.state === "idle" &&
- fetcher.data?.ok &&
- fetcher.data.intent !== "preview" &&
- fetcher.data.intent !== "test-send"
- ) {
- onClose();
- }
- }, [fetcher.state, fetcher.data, onClose]);
-
- useEffect(() => {
- if (
- fetcher.state === "idle" &&
- fetcher.data?.ok &&
- fetcher.data.intent === "test-send"
- ) {
- setTestSent(true);
- }
- }, [fetcher.state, fetcher.data]);
-
- function insertVariable(v: string) {
- const ta = bodyRef.current;
- if (!ta) {
- setBody((b) => b + `{{${v}}}`);
- return;
- }
- const start = ta.selectionStart ?? body.length;
- const end = ta.selectionEnd ?? body.length;
- const snippet = `{{${v}}}`;
- const next = body.slice(0, start) + snippet + body.slice(end);
- setBody(next);
- requestAnimationFrame(() => {
- ta.setSelectionRange(start + snippet.length, start + snippet.length);
- ta.focus();
- });
- }
-
- const variables = template?.variables ?? [];
- const isSaving = fetcher.state !== "idle";
- const isTesting =
- fetcher.state !== "idle" && fetcher.formData?.get("intent") === "test-send";
- const isPreviewing = previewFetcher.state !== "idle";
- const previewData = previewFetcher.data?.preview;
-
- return (
-
-
- {m.common_cancel()}
-
-
-
- {template && }
-
-
- {isEmail && }
-
- {variables.map((v) => (
-
- ))}
-
- {template ? m.common_save() : m.settings_msgtpl_create()}
-
-
- >
- }
- >
-
- {fetcher.data && !fetcher.data.ok && fetcher.data.intent !== "test-send" && (
-
- {fetcher.data.error ?? m.settings_error_generic()}
-
- )}
-
- {/* Name */}
-
-
- {m.settings_msgtpl_name_label()}
-
- setName(e.target.value)}
- placeholder={m.settings_msgtpl_name_placeholder()}
- required
- className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4"
- />
-
-
- {/* Subject (email only) */}
- {isEmail && (
-
-
- {m.settings_msgtpl_subject_line_label()}
-
- setSubject(e.target.value)}
- placeholder={m.settings_msgtpl_subject_placeholder()}
- className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4"
- />
-
- )}
-
- {/* Body */}
-
-
- {isEmail ? m.settings_msgtpl_email_body_label() : m.settings_msgtpl_sms_body_label()}
-
- {variables.length > 0 && (
-
- {m.settings_msgtpl_insert_label()}
- {variables.map((v) => (
- insertVariable(v)}
- className="text-[11px] px-1.5 py-0.5 rounded border border-ih-border bg-ih-bg-input text-ih-primary font-mono hover:bg-ih-primary-tint transition-colors"
- >
- {`{{${v}}}`}
-
- ))}
-
- )}
-
-
- {/* Email preview */}
- {isEmail && (
-
-
-
- {m.settings_msgtpl_preview_label()}
-
-
-
-
-
-
-
- {isPreviewing ? m.common_loading() : m.settings_msgtpl_refresh_preview()}
-
-
-
- {previewData && (
-
- {previewData.subject && (
-
- {m.settings_msgtpl_preview_subject_label()}{" "}
- {previewData.subject}
-
- )}
- {previewData.html && (
-
- )}
-
- )}
-
- )}
-
- {/* Test send */}
-
-
- {isEmail ? m.settings_msgtpl_test_send_email_heading() : m.settings_msgtpl_test_send_sms_heading()}
-
-
-
-
- {isEmail ? m.settings_msgtpl_to_email_label() : m.settings_msgtpl_to_phone_label()}
-
- {
- setTestTo(e.target.value);
- setTestSent(false);
- }}
- placeholder={isEmail ? m.settings_msgtpl_to_email_placeholder() : m.settings_msgtpl_to_phone_placeholder()}
- type={isEmail ? "email" : "tel"}
- className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4"
- />
-
-
-
-
- {isEmail && }
-
-
-
- {isTesting ? m.settings_sending() : m.settings_send()}
-
-
-
- {testSent && (
-
{m.settings_msgtpl_test_sent()}
- )}
- {fetcher.data && !fetcher.data.ok && fetcher.data.intent === "test-send" && (
-
{fetcher.data.error}
- )}
-
-
-
- );
-}
-
// ─── Compliance SMS section ────────────────────────────────────────────────────
function ComplianceSmsSection() {
diff --git a/messages/en/settings-integrations.json b/messages/en/settings-integrations.json
index 12a6921d9..9fa7070b4 100644
--- a/messages/en/settings-integrations.json
+++ b/messages/en/settings-integrations.json
@@ -298,5 +298,12 @@
"settings_advanced_data_heading": "Data management",
"settings_advanced_data_desc": "Import data from another inspection platform or export your data for backup.",
"settings_advanced_import_export": "Import / Export data",
- "settings_automations_recipient_kind_staff": "Office staff (owners & managers)"
+ "settings_automations_recipient_kind_staff": "Office staff (owners & managers)",
+ "settings_msgtpl_languages_label": "Languages:",
+ "settings_msgtpl_variant_add": "+ Add {language}",
+ "settings_msgtpl_variant_missing_note": "A language you have not written yet is still delivered — in the language you have.",
+ "settings_msgtpl_language_label": "Language",
+ "settings_msgtpl_language_locked": "A language version of “{name}”. Versions share a name and a channel; only the language differs.",
+ "settings_msgtpl_new_variant_title": "New {language} version",
+ "settings_msgtpl_delete_variant_aria": "Delete the {language} version"
}
diff --git a/messages/es-419/settings-integrations.json b/messages/es-419/settings-integrations.json
index 081a58acf..98da1826c 100644
--- a/messages/es-419/settings-integrations.json
+++ b/messages/es-419/settings-integrations.json
@@ -298,5 +298,12 @@
"settings_advanced_data_heading": "Gestión de datos",
"settings_advanced_data_desc": "Importe datos desde otra plataforma de inspección o exporte sus datos como copia de seguridad.",
"settings_advanced_import_export": "Importar / Exportar datos",
- "settings_automations_recipient_kind_staff": "Personal de oficina (titulares y gerentes)"
+ "settings_automations_recipient_kind_staff": "Personal de oficina (titulares y gerentes)",
+ "settings_msgtpl_languages_label": "Idiomas:",
+ "settings_msgtpl_variant_add": "+ Agregar {language}",
+ "settings_msgtpl_variant_missing_note": "Un idioma que usted no haya escrito igual se entrega, en el idioma que sí tenga.",
+ "settings_msgtpl_language_label": "Idioma",
+ "settings_msgtpl_language_locked": "Una versión en otro idioma de «{name}». Las versiones comparten nombre y canal; solo cambia el idioma.",
+ "settings_msgtpl_new_variant_title": "Nueva versión en {language}",
+ "settings_msgtpl_delete_variant_aria": "Eliminar la versión en {language}"
}
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 099a8399a..da4835cd2 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -13,7 +13,6 @@
"server/api/admin/admin-settings.ts": 748,
"server/services/inspection.service.ts": 741,
"server/api/inspections/report-delivery.ts": 736,
- "app/routes/settings-communication-templates.tsx": 731,
"server/services/inspection/inspection-analytics.service.ts": 729,
"app/routes/template-edit.tsx": 719,
"server/index.ts": 693,
@@ -37,6 +36,7 @@
"server/services/inspection/inspection-photo.service.ts": 531,
"app/components/NewInspectionWizard.tsx": 530,
"server/api/inspections/media-studio.ts": 530,
+ "app/routes/settings-communication-templates.tsx": 525,
"server/api/portal.ts": 525,
"server/services/portal-access.service.ts": 525,
"server/api/inspections/publish.ts": 520,
diff --git a/server/api/message-templates.ts b/server/api/message-templates.ts
index 3a7e57877..b2c898163 100644
--- a/server/api/message-templates.ts
+++ b/server/api/message-templates.ts
@@ -98,10 +98,11 @@ const messageTemplateRoutes = createApiRouter()
.openapi(createMtRoute, async (c) => {
const tenantId = c.get('tenantId') as string;
const body = c.req.valid('json');
- const createPayload: { name: string; channel: TemplateChannel; subject: string | null; body: string; variables?: string[] } = {
+ const createPayload: { name: string; channel: TemplateChannel; subject: string | null; body: string; variables?: string[]; locale?: string } = {
name: body.name, channel: body.channel, subject: body.subject ?? null, body: body.body,
};
if (body.variables !== undefined) createPayload.variables = body.variables;
+ if (body.locale !== undefined) createPayload.locale = body.locale;
const data = await new MessageTemplateService(c.env.DB).create(tenantId, createPayload);
return c.json({ success: true as const, data }, 201);
})
diff --git a/server/data/automation-seeds.ts b/server/data/automation-seeds.ts
index 9451b6d96..3174fad2e 100644
--- a/server/data/automation-seeds.ts
+++ b/server/data/automation-seeds.ts
@@ -6,6 +6,23 @@
// {{agreement_sign_url}} is special: when present, AutomationService.flush()
// lazily creates an agreement_request row + token before substitution.
// Rules using this var are auto-skipped if inspection.agreementRequired === false.
+//
+// ─── ENGLISH ONLY, ON PURPOSE ────────────────────────────────────────────────
+// These seeds ship in English and no other language, and that is a DECISION,
+// not an oversight — do not "finish the job" by adding Spanish rows here.
+//
+// message_templates now carries a `locale` and the send path picks a variant by
+// the RECIPIENT's language, so seeding Spanish would be technically trivial. It
+// is the content that stops us: this is copy a tenant sends under their own
+// company name, and inspection terminology varies enough between markets that
+// our translation would be wrong for someone and disputed by someone else. A
+// tenant who edits the English seed owns the wording; a tenant who inherits our
+// Spanish inherits our vocabulary choices without ever agreeing to them.
+//
+// So: ship the mechanism, let each tenant author their own market's Spanish.
+// The authoring surface (Settings → Communication → Templates) shows which
+// variants are missing so this reads as an invitation rather than a gap.
+// ─────────────────────────────────────────────────────────────────────────────
export const AUTOMATION_SEEDS = [
{
diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json
index 77e5d35c7..5a8c09fc7 100644
--- a/server/lib/mcp/openapi-snapshot.json
+++ b/server/lib/mcp/openapi-snapshot.json
@@ -4541,6 +4541,14 @@
"type": "string"
},
"description": "Named interpolation variables used in the body."
+ },
+ "locale": {
+ "type": "string",
+ "enum": [
+ "en",
+ "es-419"
+ ],
+ "description": "Language variant this template is written in. Variants of one template share name + channel. Defaults to en."
}
},
"required": [
diff --git a/server/lib/validations/message-template.schema.ts b/server/lib/validations/message-template.schema.ts
index 74748daf8..a7e8ff1da 100644
--- a/server/lib/validations/message-template.schema.ts
+++ b/server/lib/validations/message-template.schema.ts
@@ -1,4 +1,5 @@
import { z } from '@hono/zod-openapi';
+import { SUPPORTED_CONTACT_LOCALES } from '../i18n/contact-locale';
// B1 — `in_app` templates hold a notice's title (`subject`) and body.
const ChannelSchema = z.enum(['email', 'sms', 'in_app']);
@@ -10,8 +11,14 @@ export const CreateMessageTemplateSchema = z.object({
subject: z.string().max(500).nullish().describe('Email subject line (email channel only; max 500 chars).'),
body: z.string().min(1).describe('Template body text; supports {{variable}} interpolation.'),
variables: z.array(z.string()).optional().describe('Named interpolation variables used in the body.'),
+ locale: z.enum(SUPPORTED_CONTACT_LOCALES).optional()
+ .describe('Language variant this template is written in. Variants of one template share name + channel. Defaults to en.'),
}).strip();
+// Deliberately absent from the update schema: a variant's language is what the
+// row IS, like its channel. Allowing a PATCH to change it would silently
+// reassign Spanish copy to English readers, and `.partial()` semantics make that
+// the kind of field a caller overwrites without ever naming it.
export const UpdateMessageTemplateSchema = z.object({
name: z.string().min(1).max(200).optional().describe('Updated display name (max 200 chars).'),
subject: z.string().max(500).nullish().describe('Updated email subject line (email channel only).'),
@@ -38,6 +45,7 @@ export const MessageTemplateSchema = z.object({
id: z.string(), tenantId: z.string(), name: z.string(),
channel: ChannelSchema, subject: z.string().nullable(), body: z.string(),
variables: z.array(z.string()), isSeeded: z.boolean(),
+ locale: z.string(),
createdAt: z.number(), updatedAt: z.number(),
});
From 993da290c711674ec142f7a426d888cae1820861 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 21:36:38 +0800
Subject: [PATCH 073/111] test(i18n): assert the tag reduction that UI
activation stands on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
normalizeLocale already exists in server/lib/i18n/contact-locale.ts with the
contract #269 needs, but the spec only exercised it through resolveContactLocale
and never asserted the load-bearing case: tenant_configs.default_locale ships as
'en-US', which is not a Paraglide locale. If that reduced to null the tenant
setting would silently stop working and activation would look like a no-op.
Asserted directly instead of building a second normaliser in app/ — two
normalisers that can disagree is the defect one level up from the one this
guards. Proven by mutation: matching on the exact tag reddens the three
reduction cases; returning a default instead of null reddens the null case.
---
tests/unit/contacts/contact-locale.spec.ts | 47 +++++++++++++++++++++-
1 file changed, 46 insertions(+), 1 deletion(-)
diff --git a/tests/unit/contacts/contact-locale.spec.ts b/tests/unit/contacts/contact-locale.spec.ts
index dfda66a95..0a753116c 100644
--- a/tests/unit/contacts/contact-locale.spec.ts
+++ b/tests/unit/contacts/contact-locale.spec.ts
@@ -1,10 +1,55 @@
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
import { describe, it, expect } from 'vitest';
-import { resolveContactLocale, SUPPORTED_CONTACT_LOCALES } from '../../../server/lib/i18n/contact-locale';
+import { normalizeLocale, resolveContactLocale, SUPPORTED_CONTACT_LOCALES } from '../../../server/lib/i18n/contact-locale';
const NONE = { contactLocale: null, linkedUserLocale: null, tenantDefault: null, acceptLanguage: null };
+/**
+ * The tag reduction, tested directly rather than only through the resolver.
+ *
+ * This is the load-bearing half of UI-locale activation (#269): the stored
+ * preferences are BCP-47 and do NOT match the Paraglide tags. `tenant_configs
+ * .default_locale` ships as 'en-US' and the settings picker offers 'en-US' /
+ * 'es-419' (`app/lib/locales.ts`), while the catalogue is compiled for 'en' /
+ * 'es-419'. If 'en-US' reduced to null, every tenant default would fall through
+ * to the browser hint and the tenant setting would silently stop working — a
+ * failure that looks exactly like "the feature does nothing".
+ *
+ * Returning NULL rather than a default is the other half: every caller is a
+ * precedence chain (`resolveContactLocale` here, `resolveUiLocale` for the UI),
+ * and a wrong non-null answer would stop the chain at its first rung and make
+ * every later rung unreachable.
+ */
+describe('normalizeLocale', () => {
+ it('reduces the tags the product actually stores', () => {
+ // Both values the settings pickers can write.
+ expect(normalizeLocale('en-US')).toBe('en');
+ expect(normalizeLocale('es-419')).toBe('es-419');
+ });
+
+ it('lands every Spanish variant on the one Spanish catalogue', () => {
+ expect(normalizeLocale('es')).toBe('es-419');
+ expect(normalizeLocale('es-MX')).toBe('es-419');
+ expect(normalizeLocale('es-ES')).toBe('es-419');
+ });
+
+ it('is case-insensitive, because Accept-Language is not normalised for us', () => {
+ expect(normalizeLocale('EN-us')).toBe('en');
+ expect(normalizeLocale('ES-419')).toBe('es-419');
+ });
+
+ it('returns null for unsupported and absent input, never a default', () => {
+ expect(normalizeLocale('fr-FR')).toBeNull();
+ expect(normalizeLocale('')).toBeNull();
+ expect(normalizeLocale(null)).toBeNull();
+ expect(normalizeLocale(undefined)).toBeNull();
+ // Malformed enough that `new Intl.Locale` throws — must be an absence,
+ // not the thing that breaks the page.
+ expect(normalizeLocale('not a locale!!')).toBeNull();
+ });
+});
+
describe('resolveContactLocale', () => {
it('prefers what the contact told us', () => {
expect(resolveContactLocale({ ...NONE,
From 7d479a9f3e4f5295502d27616c6dbb36a51c0755 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 21:55:20 +0800
Subject: [PATCH 074/111] feat(i18n): resolve a real UI locale per request
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The framework has been dormant because nothing ever wrote the PARAGLIDE_LOCALE
cookie, so every request resolved to baseLocale. This writes it.
The seam is A of the two the plan weighed: rewrite the incoming Cookie header
before paraglideMiddleware reads it, in workers/app.ts, keeping the middleware in
its outer position. Paraglide reads the locale off the INCOMING request, so a
locale decided later cannot change the render already under way — resolving here
is what makes a first visit render in the visitor's language rather than English
first. Chosen over a server-side overwriteGetLocale because it depends only on
the cookie contract already configured, while an override's behaviour under
concurrent multi-tenant SSR would have to be proven — the same hazard that keeps
the globalVariable strategy excluded.
Resolution is split by what a request can cheaply know. withResolvedUiLocale
reads only request-borne sources (cookie, Accept-Language) because it runs ahead
of the router; users.locale and tenant_configs.default_locale live in D1 and
reach the cookie via uiLocaleStampFor, called from the one loader that already
fetches the session context for every authenticated page. The stamp costs one
render in the previous language and cannot oscillate — asserted by feeding its
own output back through the seam.
Precedence: users.locale > tenant default > cookie > Accept-Language > en.
The cookie sits ABOVE Accept-Language, correcting the plan, which put it below.
That ordering makes the switcher non-functional for exactly the people who need
it: a Spanish-browsered viewer who picks English is handed Spanish back on the
next page load. Mutating the order back reddens four tests, two of them showing
it also makes the seam rewrite the cookie on every single request.
Reuses normalizeLocale and the Accept-Language ranking from contact-locale.ts
rather than adding a second normaliser — a product that renders Spanish and
emails English is one bug, not two.
---
app/routes/auth-layout.tsx | 32 +++-
server/lib/i18n/contact-locale.ts | 14 +-
server/lib/i18n/ui-locale.ts | 179 ++++++++++++++++++++++
tests/unit/i18n/ui-locale.spec.ts | 246 ++++++++++++++++++++++++++++++
workers/app.ts | 13 +-
5 files changed, 476 insertions(+), 8 deletions(-)
create mode 100644 server/lib/i18n/ui-locale.ts
create mode 100644 tests/unit/i18n/ui-locale.spec.ts
diff --git a/app/routes/auth-layout.tsx b/app/routes/auth-layout.tsx
index c41fa3fa0..bfee9bc7e 100644
--- a/app/routes/auth-layout.tsx
+++ b/app/routes/auth-layout.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
-import { Outlet, useLoaderData, useLocation, useNavigate, useNavigation } from "react-router";
+import { data, Outlet, useLoaderData, useLocation, useNavigate, useNavigation } from "react-router";
+import { uiLocaleStampFor } from "../../server/lib/i18n/ui-locale";
import type { Route } from "./+types/auth-layout";
import { requireToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
@@ -43,7 +44,34 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} catch {
// Graceful fallback — layout renders with defaults
}
- return { context: sessionContext };
+
+ // i18n activation (#269) — the DATABASE half of locale resolution.
+ //
+ // The worker entry resolves the request-borne rungs (the PARAGLIDE_LOCALE
+ // cookie, then Accept-Language) before the paraglide scope opens. It cannot
+ // read the other two: `users.locale` and `tenant_configs.default_locale` live
+ // in D1, and querying them ahead of the router on every page load — for a
+ // value that changes about once per user per career — is not a trade worth
+ // making. They arrive here instead, on the one loader that already fetches
+ // the session context for every authenticated page, at no extra query.
+ //
+ // By the time this runs the render has already committed to a locale, so the
+ // stored preference is STAMPED INTO THE COOKIE and takes effect from the next
+ // request. The cost is one render in the previous language, on the single
+ // page load where a stored preference first differs from the cookie. It
+ // cannot oscillate: the value written is the same value this chain resolves
+ // once the cookie carries it, so the next request finds them equal and writes
+ // nothing. The switcher (LocaleSwitcher) is the interactive path and does not
+ // pay this lag — it writes the cookie itself and revalidates.
+ const headers = new Headers();
+ const stamp = sessionContext
+ ? uiLocaleStampFor(request, {
+ userLocale: sessionContext.user?.locale ?? null,
+ tenantDefault: sessionContext.branding?.defaultLocale ?? null,
+ })
+ : null;
+ if (stamp) headers.append("Set-Cookie", stamp);
+ return data({ context: sessionContext }, { headers });
}
export default function AuthLayout() {
diff --git a/server/lib/i18n/contact-locale.ts b/server/lib/i18n/contact-locale.ts
index fed77e2c3..70d6318b2 100644
--- a/server/lib/i18n/contact-locale.ts
+++ b/server/lib/i18n/contact-locale.ts
@@ -47,9 +47,9 @@ export const SUPPORTED_CONTACT_LOCALES = ['en', 'es-419'] as const;
/** A locale the product can actually speak. */
export type ContactLocale = (typeof SUPPORTED_CONTACT_LOCALES)[number];
-/** The base locale, used when nothing else resolves. Module-local: exporting it
- * with no consumer is a knip finding, and the resolver is the only caller. */
-const DEFAULT_CONTACT_LOCALE: ContactLocale = 'en';
+/** The base locale, used when nothing else resolves. Shared with `ui-locale.ts`
+ * so the recipient chain and the UI chain cannot end at different languages. */
+export const DEFAULT_CONTACT_LOCALE: ContactLocale = 'en';
/**
* A BCP-47 tag reduced to a locale we have messages for, or `null` when we
@@ -75,8 +75,12 @@ export function normalizeLocale(raw: string | null | undefined): ContactLocale |
* Entries are ranked by their `q` weight (absent means 1), ties broken by the
* order sent, and unsupported entries are skipped rather than stopping the
* scan — `fr-FR,es-MX;q=0.9` means Spanish here, not English.
+ *
+ * Exported for `ui-locale.ts`: the UI chain reads the SAME header off the same
+ * request and must rank it identically, or a visitor's browser would mean one
+ * language for the page and another for the notification the page sends.
*/
-function fromAcceptLanguage(header: string | null | undefined): ContactLocale | null {
+export function localeFromAcceptLanguage(header: string | null | undefined): ContactLocale | null {
if (!header) return null;
return header
.split(',')
@@ -114,6 +118,6 @@ export function resolveContactLocale(input: ContactLocaleInput): ContactLocale {
return normalizeLocale(input.contactLocale)
?? normalizeLocale(input.linkedUserLocale)
?? normalizeLocale(input.tenantDefault)
- ?? fromAcceptLanguage(input.acceptLanguage)
+ ?? localeFromAcceptLanguage(input.acceptLanguage)
?? DEFAULT_CONTACT_LOCALE;
}
diff --git a/server/lib/i18n/ui-locale.ts b/server/lib/i18n/ui-locale.ts
new file mode 100644
index 000000000..7930a88c8
--- /dev/null
+++ b/server/lib/i18n/ui-locale.ts
@@ -0,0 +1,179 @@
+/**
+ * Which language to render the UI in, for THIS request.
+ *
+ * Sibling to `contact-locale.ts`, and deliberately a separate chain. That one
+ * answers "what language is this PERSON addressed in" for a notification that
+ * may be composed from cron with no request at all. This one answers "what
+ * language does this PAGE render in", where a request always exists and the
+ * viewer is the reader. The two share the tag reduction and the Accept-Language
+ * ranking — a product that renders Spanish and then emails English is one bug,
+ * not two — but their precedence differs, because only this one has a cookie.
+ *
+ * PRECEDENCE, highest first:
+ * 1. `userLocale` — `users.locale`, the viewer's own stated choice.
+ * 2. `tenantDefault` — `tenant_configs.default_locale`, the company's choice.
+ * 3. `cookie` — `PARAGLIDE_LOCALE`, what the switcher last stamped.
+ * 4. `acceptLanguage` — the browser hint. Weakest: it describes a device.
+ * 5. `'en'` — the base locale.
+ *
+ * WHY THE COOKIE SITS AT 3 AND NOT AT 5. It is below the stored preferences
+ * because it caches a past decision on ONE device, and a preference changed on
+ * another device must beat it. It is above `Accept-Language` because it is the
+ * only record of an EXPLICIT choice available on a request that has not reached
+ * the database yet: put it below, and a Spanish-browsered user who deliberately
+ * picks English gets Spanish back on the very next page load, with the switcher
+ * apparently ignoring them. (The plan for #269 originally ordered it below
+ * Accept-Language; that ordering makes the switcher non-functional for exactly
+ * the users who need it, and was corrected here with a test.)
+ *
+ * Each rung FALLS THROUGH on an absent or unsupported value rather than
+ * stopping on it — see `normalizeLocale`, which returns null rather than a
+ * default precisely so this chain can keep walking.
+ */
+import {
+ DEFAULT_CONTACT_LOCALE,
+ localeFromAcceptLanguage,
+ normalizeLocale,
+ type ContactLocale,
+} from './contact-locale';
+
+/**
+ * The cookie Paraglide's `cookie` strategy reads (`project.inlang/settings.json`
+ * / the compiled runtime's `cookieName`). Named here so the writer (the
+ * switcher), the reader (this module) and the seam below cannot drift apart.
+ */
+export const UI_LOCALE_COOKIE = 'PARAGLIDE_LOCALE';
+
+/** Everything a request can tell us about the viewer's language. Every field is
+ * optional because every field is genuinely often absent — a pre-auth page has
+ * no user and no tenant, and a first visit has no cookie. */
+export interface UiLocaleSources {
+ /** `users.locale` — the viewer's per-user override, or null to inherit. */
+ userLocale?: string | null;
+ /** `tenant_configs.default_locale` — the company's configured locale. */
+ tenantDefault?: string | null;
+ /** The `PARAGLIDE_LOCALE` cookie value, if the request carries one. */
+ cookie?: string | null;
+ /** The request's `Accept-Language` header. */
+ acceptLanguage?: string | null;
+}
+
+/** Resolve the language this request's UI renders in. See the precedence at the
+ * top of this file; it is documented there once and nowhere else. */
+export function resolveUiLocale(sources: UiLocaleSources): ContactLocale {
+ return normalizeLocale(sources.userLocale)
+ ?? normalizeLocale(sources.tenantDefault)
+ ?? normalizeLocale(sources.cookie)
+ ?? localeFromAcceptLanguage(sources.acceptLanguage)
+ ?? DEFAULT_CONTACT_LOCALE;
+}
+
+/**
+ * The raw `PARAGLIDE_LOCALE` value out of a `Cookie` header, unnormalised.
+ *
+ * Unnormalised on purpose: callers compare it against a resolved locale to
+ * decide whether to REWRITE the cookie, and a normalising read would report a
+ * stale `en-US` cookie as already correct and never replace it.
+ */
+export function readUiLocaleCookie(cookieHeader: string | null | undefined): string | null {
+ if (!cookieHeader) return null;
+ for (const part of cookieHeader.split(';')) {
+ const eq = part.indexOf('=');
+ if (eq < 0) continue;
+ if (part.slice(0, eq).trim() !== UI_LOCALE_COOKIE) continue;
+ return decodeURIComponent(part.slice(eq + 1).trim()) || null;
+ }
+ return null;
+}
+
+/** A `Cookie` header with `PARAGLIDE_LOCALE` set to `locale`, every other
+ * cookie preserved in order. The JWT rides in this header too, so dropping the
+ * rest would log the viewer out to change their language. */
+export function setUiLocaleInCookieHeader(
+ cookieHeader: string | null | undefined,
+ locale: ContactLocale,
+): string {
+ const pair = `${UI_LOCALE_COOKIE}=${locale}`;
+ if (!cookieHeader) return pair;
+ const kept = cookieHeader
+ .split(';')
+ .map((p) => p.trim())
+ .filter((p) => p !== '' && p.split('=')[0].trim() !== UI_LOCALE_COOKIE);
+ return [...kept, pair].join('; ');
+}
+
+/**
+ * THE SEAM. Paraglide reads the locale off the INCOMING request, so a locale
+ * decided after the request is handed over cannot affect the render that is
+ * already happening — a first visit would render English and only obey the
+ * visitor on their second page load. This rewrites the incoming `Cookie` header
+ * before `paraglideMiddleware` ever sees the request, so the very first render
+ * is already in the right language.
+ *
+ * Seam A of the two the plan weighed, and chosen over `overwriteGetLocale` on
+ * the server: this depends only on the cookie contract already configured
+ * (`strategy: ["cookie", "baseLocale"]`), while an override installs a resolver
+ * whose behaviour under concurrent multi-tenant SSR would have to be proven —
+ * the same hazard that keeps the `globalVariable` strategy excluded.
+ *
+ * ONLY request-borne sources are read here. `users.locale` and the tenant
+ * default live in D1, and reading them would mean authenticating and querying
+ * on every page load, ahead of the router, for a value that changes about once
+ * per user per career. Those two rungs reach this function the way every other
+ * device-level preference in this app does: stamped into the cookie by the
+ * surface that knows them (`auth-layout`'s loader, and the switcher).
+ *
+ * Returns the ORIGINAL request untouched when the cookie already says the right
+ * thing, which is every request after the first — so the steady-state cost is
+ * one header read.
+ */
+export function withResolvedUiLocale(request: Request): Request {
+ const cookieHeader = request.headers.get('Cookie');
+ const cookie = readUiLocaleCookie(cookieHeader);
+ const locale = resolveUiLocale({
+ cookie,
+ acceptLanguage: request.headers.get('Accept-Language'),
+ });
+ if (cookie === locale) return request;
+ const headers = new Headers(request.headers);
+ headers.set('Cookie', setUiLocaleInCookieHeader(cookieHeader, locale));
+ return new Request(request, { headers });
+}
+
+/** A `Set-Cookie` value that stamps the resolved locale for a year. Not
+ * `HttpOnly`: the switcher writes the same cookie from the client, and a
+ * display preference is not a credential. `SameSite=Lax` matches the other
+ * UI-preference cookies (`oi-color-scheme`, `oi-sidebar-collapsed`). */
+export function uiLocaleSetCookie(locale: ContactLocale): string {
+ return `${UI_LOCALE_COOKIE}=${locale}; Path=/; Max-Age=31536000; SameSite=Lax`;
+}
+
+/**
+ * The OTHER half of the seam: the two rungs that live in D1.
+ *
+ * `withResolvedUiLocale` runs ahead of the router and cannot see `users.locale`
+ * or `tenant_configs.default_locale`. This decides, given a request and the
+ * stored preferences an authenticated loader already holds, whether the cookie
+ * needs correcting — returning the `Set-Cookie` value, or `null` when it is
+ * already right.
+ *
+ * Returning null in the common case is what makes this safe to call on every
+ * authenticated page load, and is also what makes it impossible to oscillate:
+ * the value written is the value `withResolvedUiLocale` will resolve once the
+ * cookie carries it, so the next request finds them equal and stamps nothing.
+ */
+export function uiLocaleStampFor(
+ request: Request,
+ stored: Pick,
+): string | null {
+ const cookie = readUiLocaleCookie(request.headers.get('Cookie'));
+ const desired = resolveUiLocale({
+ // `?? null` rather than a spread: `exactOptionalPropertyTypes` makes an
+ // explicitly-passed `undefined` a different thing from an absent key.
+ userLocale: stored.userLocale ?? null,
+ tenantDefault: stored.tenantDefault ?? null,
+ cookie,
+ acceptLanguage: request.headers.get('Accept-Language'),
+ });
+ return cookie === desired ? null : uiLocaleSetCookie(desired);
+}
diff --git a/tests/unit/i18n/ui-locale.spec.ts b/tests/unit/i18n/ui-locale.spec.ts
new file mode 100644
index 000000000..307f03dbf
--- /dev/null
+++ b/tests/unit/i18n/ui-locale.spec.ts
@@ -0,0 +1,246 @@
+import { describe, it, expect } from 'vitest';
+import {
+ readUiLocaleCookie,
+ resolveUiLocale,
+ setUiLocaleInCookieHeader,
+ uiLocaleSetCookie,
+ uiLocaleStampFor,
+ withResolvedUiLocale,
+} from '../../../server/lib/i18n/ui-locale';
+
+/**
+ * Every fixture starts from ALL FOUR sources present and ADVERSE — each test
+ * then names the one rung it is about. A chain test seeded with only the value
+ * it expects back passes against a hardcoded return, which is how a resolver
+ * ships answering one thing forever.
+ */
+const ADVERSE = {
+ userLocale: 'fr-FR',
+ tenantDefault: 'fr-FR',
+ cookie: 'fr-FR',
+ acceptLanguage: 'fr-FR,fr;q=0.9',
+};
+
+describe('resolveUiLocale precedence', () => {
+ it('prefers the viewer’s own stored choice above everything', () => {
+ expect(resolveUiLocale({
+ userLocale: 'es-419',
+ tenantDefault: 'en-US',
+ cookie: 'en',
+ acceptLanguage: 'en-GB,en;q=0.9',
+ })).toBe('es-419');
+ // ...and the other way round, so a test that only ever expects Spanish
+ // cannot pass on a resolver that only ever answers Spanish.
+ expect(resolveUiLocale({
+ userLocale: 'en-US',
+ tenantDefault: 'es-419',
+ cookie: 'es-419',
+ acceptLanguage: 'es-419,es;q=0.9',
+ })).toBe('en');
+ });
+
+ it('falls to the tenant default when the viewer has none', () => {
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: null, tenantDefault: 'es-MX',
+ })).toBe('es-419');
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: null, tenantDefault: 'en-US', cookie: 'es-419',
+ })).toBe('en');
+ });
+
+ it('lets the cookie beat the browser, because it is the explicit choice', () => {
+ // The switcher's whole contract. A Spanish-browsered viewer who picks
+ // English must KEEP English on the next page load; ranking the browser
+ // above the cookie hands it straight back and reads as a dead control.
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null,
+ cookie: 'en', acceptLanguage: 'es-419,es;q=0.9,en;q=0.5',
+ })).toBe('en');
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null,
+ cookie: 'es-419', acceptLanguage: 'en-US,en;q=0.9',
+ })).toBe('es-419');
+ });
+
+ it('falls to Accept-Language when no preference of any kind is stored', () => {
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null, cookie: null,
+ acceptLanguage: 'es-419,es;q=0.9,en;q=0.5',
+ })).toBe('es-419');
+ // Weighted, not first-listed: 'fr' outranks nothing we speak, so the
+ // scan must continue to the Spanish entry rather than stop.
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null, cookie: null,
+ acceptLanguage: 'fr-FR,es-MX;q=0.8,en;q=0.3',
+ })).toBe('es-419');
+ });
+
+ it('ends at English, never at undefined', () => {
+ expect(resolveUiLocale(ADVERSE)).toBe('en');
+ expect(resolveUiLocale({})).toBe('en');
+ });
+
+ it('ignores an unsupported value instead of stopping the chain on it', () => {
+ // A viewer whose stored locale names a language we dropped must still
+ // get their tenant's language, not a silent drop to English.
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: 'de-DE', tenantDefault: 'es-419',
+ })).toBe('es-419');
+ // Same for junk that makes `new Intl.Locale` throw.
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: 'not a locale!!', tenantDefault: 'es-419',
+ })).toBe('es-419');
+ });
+});
+
+describe('readUiLocaleCookie', () => {
+ it('finds the value among the other cookies a real request carries', () => {
+ expect(readUiLocaleCookie('__Host-inspector_token=abc.def; PARAGLIDE_LOCALE=es-419; oi-color-scheme=dark'))
+ .toBe('es-419');
+ });
+
+ it('returns null when absent, empty, or only a name that ENDS with ours', () => {
+ expect(readUiLocaleCookie('__Host-inspector_token=abc')).toBeNull();
+ expect(readUiLocaleCookie('')).toBeNull();
+ expect(readUiLocaleCookie(null)).toBeNull();
+ // A substring match here would read someone else's cookie as the locale.
+ expect(readUiLocaleCookie('XPARAGLIDE_LOCALE=es-419')).toBeNull();
+ expect(readUiLocaleCookie('PARAGLIDE_LOCALE=')).toBeNull();
+ });
+
+ it('does NOT normalise — a stale tag must read back as itself', () => {
+ // The caller compares this against a resolved locale to decide whether
+ // to rewrite. Normalising here would report 'en-US' as already correct
+ // and the cookie would never be replaced.
+ expect(readUiLocaleCookie('PARAGLIDE_LOCALE=en-US')).toBe('en-US');
+ });
+});
+
+describe('setUiLocaleInCookieHeader', () => {
+ it('keeps every other cookie — the JWT rides in this header', () => {
+ const out = setUiLocaleInCookieHeader('__Host-inspector_token=abc.def; oi-color-scheme=dark', 'es-419');
+ expect(out).toContain('__Host-inspector_token=abc.def');
+ expect(out).toContain('oi-color-scheme=dark');
+ expect(out).toContain('PARAGLIDE_LOCALE=es-419');
+ });
+
+ it('replaces an existing value rather than appending a second one', () => {
+ const out = setUiLocaleInCookieHeader('PARAGLIDE_LOCALE=en; a=1', 'es-419');
+ expect(out.match(/PARAGLIDE_LOCALE=/g)).toHaveLength(1);
+ expect(readUiLocaleCookie(out)).toBe('es-419');
+ });
+
+ it('handles a request that carried no cookies at all', () => {
+ expect(setUiLocaleInCookieHeader(null, 'es-419')).toBe('PARAGLIDE_LOCALE=es-419');
+ });
+});
+
+describe('withResolvedUiLocale (the seam)', () => {
+ const req = (headers: Record) =>
+ new Request('https://example.test/login', { headers });
+
+ it('stamps the browser’s language onto a first visit that has no cookie', () => {
+ const out = withResolvedUiLocale(req({ 'Accept-Language': 'es-419,es;q=0.9' }));
+ expect(readUiLocaleCookie(out.headers.get('Cookie'))).toBe('es-419');
+ });
+
+ it('leaves the request untouched once the cookie already agrees', () => {
+ const original = req({ Cookie: 'PARAGLIDE_LOCALE=es-419', 'Accept-Language': 'en-US' });
+ // Identity, not equality: the steady state must not allocate a Request
+ // per page load, and must not risk dropping anything a copy would.
+ expect(withResolvedUiLocale(original)).toBe(original);
+ });
+
+ it('rewrites a stale tag the cookie contract cannot serve', () => {
+ // 'en-US' is what the settings picker stores; Paraglide has no such
+ // locale, so an unrewritten cookie falls back to baseLocale by accident
+ // rather than by decision.
+ const out = withResolvedUiLocale(req({ Cookie: 'PARAGLIDE_LOCALE=en-US' }));
+ expect(readUiLocaleCookie(out.headers.get('Cookie'))).toBe('en');
+ });
+
+ it('preserves the session cookie while rewriting the locale one', () => {
+ const out = withResolvedUiLocale(req({
+ Cookie: '__Host-inspector_token=abc.def',
+ 'Accept-Language': 'es-MX',
+ }));
+ expect(out.headers.get('Cookie')).toContain('__Host-inspector_token=abc.def');
+ expect(readUiLocaleCookie(out.headers.get('Cookie'))).toBe('es-419');
+ });
+
+ it('preserves the method and URL it was handed', () => {
+ const post = new Request('https://example.test/inspections', {
+ method: 'POST', body: 'x', headers: { 'Accept-Language': 'es-419' },
+ });
+ const out = withResolvedUiLocale(post);
+ expect(out.method).toBe('POST');
+ expect(out.url).toBe('https://example.test/inspections');
+ });
+});
+
+describe('uiLocaleStampFor (the database half)', () => {
+ const req = (headers: Record) =>
+ new Request('https://example.test/inspections', { headers });
+ const NO_PREFERENCE = { userLocale: null, tenantDefault: null };
+
+ it('corrects a cookie that disagrees with the stored preference', () => {
+ // The whole point of #269: a viewer who set Spanish in Settings before
+ // this shipped must get Spanish without touching the new switcher.
+ const stamp = uiLocaleStampFor(
+ req({ Cookie: 'PARAGLIDE_LOCALE=en', 'Accept-Language': 'en-US' }),
+ { userLocale: 'es-419', tenantDefault: 'en-US' },
+ );
+ expect(stamp).not.toBeNull();
+ expect(readUiLocaleCookie(stamp!.split(';')[0])).toBe('es-419');
+ });
+
+ it('stamps nothing once the cookie already agrees — the common case', () => {
+ expect(uiLocaleStampFor(
+ req({ Cookie: 'PARAGLIDE_LOCALE=es-419' }),
+ { userLocale: 'es-419', tenantDefault: 'en-US' },
+ )).toBeNull();
+ });
+
+ it('cannot oscillate: what it writes is what the seam then resolves', () => {
+ // Feed the stamp's own output back through the request-borne resolver
+ // and then through the stamp again. A second stamp here would mean the
+ // two halves disagree and every page load would rewrite the cookie.
+ const stored = { userLocale: null, tenantDefault: 'en-US' };
+ const first = uiLocaleStampFor(
+ req({ 'Accept-Language': 'es-419,es;q=0.9' }), stored,
+ );
+ expect(first).not.toBeNull();
+ const settled = req({
+ Cookie: first!.split(';')[0],
+ 'Accept-Language': 'es-419,es;q=0.9',
+ });
+ expect(uiLocaleStampFor(settled, stored)).toBeNull();
+ expect(withResolvedUiLocale(settled)).toBe(settled);
+ });
+
+ it('corrects the stale tag the settings picker actually writes', () => {
+ // users.locale is 'en-US'/'es-419' (app/lib/locales.ts LOCALE_OPTIONS),
+ // never 'en'. Stamping it verbatim would put a tag in the cookie that
+ // Paraglide has no locale for.
+ const stamp = uiLocaleStampFor(req({}), { userLocale: 'en-US', tenantDefault: null });
+ expect(readUiLocaleCookie(stamp!.split(';')[0])).toBe('en');
+ });
+
+ it('falls to the browser when nothing at all is stored', () => {
+ const stamp = uiLocaleStampFor(
+ req({ 'Accept-Language': 'es-MX,es;q=0.9' }), NO_PREFERENCE,
+ );
+ expect(readUiLocaleCookie(stamp!.split(';')[0])).toBe('es-419');
+ });
+});
+
+describe('uiLocaleSetCookie', () => {
+ it('round-trips through the reader and lasts beyond the session', () => {
+ const value = uiLocaleSetCookie('es-419');
+ expect(readUiLocaleCookie(value.split(';')[0])).toBe('es-419');
+ expect(value).toContain('Path=/');
+ expect(value).toContain('Max-Age=31536000');
+ // Readable by the client switcher, which writes the same cookie.
+ expect(value).not.toContain('HttpOnly');
+ });
+});
diff --git a/workers/app.ts b/workers/app.ts
index b41895f1d..01662991e 100644
--- a/workers/app.ts
+++ b/workers/app.ts
@@ -10,6 +10,11 @@ import { buildOAuthHandler } from "../server/lib/mcp/oauth-provider";
// module-global) across the multi-tenant Worker. Generated (git-ignored); the
// paraglide vite plugin + the prebuild `i18n:compile` step keep it present.
import { paraglideMiddleware } from "../app/paraglide/server.js";
+// i18n activation (#269) — the request-borne half of locale resolution. Runs
+// BEFORE paraglideMiddleware because paraglide reads the locale off the
+// INCOMING request: a locale decided later cannot change the render that is
+// already under way. See the seam note in ui-locale.ts.
+import { withResolvedUiLocale } from "../server/lib/i18n/ui-locale";
import type { WorkerEnv } from "./env";
import { cloudflareContext } from "../app/lib/load-context";
@@ -60,7 +65,13 @@ const ssr = (c: Ctx) => {
// middleware: it has to cover loaders, actions, AND the render pass, and only
// the outer position does. Moving it inside would narrow the scope silently —
// locale would fall back to baseLocale with nothing raising an error.
- return paraglideMiddleware(c.req.raw, ({ request }) =>
+ //
+ // withResolvedUiLocale stamps the resolved locale into the Cookie header the
+ // middleware is about to read, so a first visit renders in the visitor's
+ // language instead of English-then-Spanish. It returns the SAME request
+ // object once the cookie already agrees, which is every request after the
+ // first — the steady-state cost here is one header read.
+ return paraglideMiddleware(withResolvedUiLocale(c.req.raw), ({ request }) =>
requestHandler(request, context),
);
};
From 1b47becb71ed5bef3784733dc4d8e8951b6b8ae1 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 22:29:29 +0800
Subject: [PATCH 075/111] feat(i18n): language switcher in the user menu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two pickers already persisted a language, both buried in Settings. Neither is
reachable by the person who needs them most: someone who cannot read the word
"Settings". This puts the control beside the theme control in the user menu and
the mobile drawer, on the same SegmentedControl its neighbour uses.
It performs both writes, and both are load-bearing. The cookie takes effect on
the next request because the worker resolves the render locale from it before
the router runs; users.locale makes the choice survive a new device AND keeps
auth-layout's stamp — which ranks the stored preference above the cookie — from
correcting the click back on the next navigation. The re-render is a consequence
of the second write rather than a separate mechanism: React Router revalidates
after a fetcher submission, so the root loader re-runs inside the new request's
paraglide scope and plus the whole tree come back translated.
Persists through the existing profile action under its own intent. The default
branch parses the whole profile form, which a two-field submission cannot
satisfy, and a second writer of users.locale would be a second set of rules
about what a valid stored tag is. storedLocaleTag maps the Paraglide tag onto
the tag that stores — saving 'en' would leave Profile showing "Use
workspace default" for a preference just set. The file-size baseline moves for
that branch; the alternative was a second writer.
Also adds the headers export auth-layout needed. Without it React Router drops a
nested loader's Set-Cookie entirely, so the stamp added in the previous commit
reached nothing; found against a running server, not by reading. Only Set-Cookie
is forwarded, so nothing else on that loader can leak onto every authenticated
document response.
Language names come from the locale table rather than message keys — a language
name is not translated, and per-locale keys would let the catalogues disagree
about what a language is called. The new headings use ih-fg-3: ih-fg-4, which
the adjacent Theme heading uses, measures 2.45:1 light and 3.75:1 dark.
Verified in the browser in both languages and both themes: menu 220x330 fully in
viewport, segments 40px/43px unclipped, no horizontal page scroll, contrast
4.76/5.71 on the heading and 4.34-4.90 on the segments.
---
app/components/LocaleSwitcher.test.tsx | 100 +++++++++++++++++++++
app/components/LocaleSwitcher.tsx | 76 ++++++++++++++++
app/components/sidebar/MobileDrawer.tsx | 8 ++
app/components/sidebar/UserMenuPopover.tsx | 13 +++
app/lib/locales.test.ts | 71 +++++++++++++++
app/lib/locales.ts | 40 +++++++++
app/lib/ui-prefs.ts | 21 +++++
app/routes/auth-layout.tsx | 27 ++++++
app/routes/settings-profile.tsx | 17 ++++
messages/en/nav.json | 4 +-
messages/es-419/nav.json | 4 +-
scripts/file-size-baseline.json | 2 +-
server/lib/i18n/ui-locale.ts | 17 +++-
tests/unit/i18n/ui-locale.spec.ts | 17 +++-
14 files changed, 410 insertions(+), 7 deletions(-)
create mode 100644 app/components/LocaleSwitcher.test.tsx
create mode 100644 app/components/LocaleSwitcher.tsx
create mode 100644 app/lib/locales.test.ts
diff --git a/app/components/LocaleSwitcher.test.tsx b/app/components/LocaleSwitcher.test.tsx
new file mode 100644
index 000000000..247065f5b
--- /dev/null
+++ b/app/components/LocaleSwitcher.test.tsx
@@ -0,0 +1,100 @@
+// @vitest-environment happy-dom
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+import { createRoutesStub } from "react-router";
+import { LocaleSwitcher } from "./LocaleSwitcher";
+
+/**
+ * The switcher has to do BOTH writes or it is broken in a way that looks like
+ * it works. Cookie only: the choice is lost on the next device, and — worse —
+ * `auth-layout`'s stamp ranks the stored preference above the cookie and
+ * corrects it straight back on the following navigation. Database only: nothing
+ * changes until a round trip completes, so the control appears dead.
+ *
+ * Rendered through a real router stub rather than bare, so the assertion is on
+ * what the PROFILE ACTION actually receives — a spy would pass against a
+ * component that submits to nowhere.
+ */
+function renderSwitcher(serverLocale: string) {
+ const submitted: { intent?: string; locale?: string } = {};
+ const Stub = createRoutesStub([
+ {
+ id: "root",
+ path: "/",
+ loader: () => ({ locale: serverLocale }),
+ Component: () => ,
+ },
+ {
+ path: "/settings/profile",
+ action: async ({ request }: { request: Request }) => {
+ const fd = await request.formData();
+ submitted.intent = String(fd.get("intent"));
+ submitted.locale = String(fd.get("locale"));
+ return { success: true };
+ },
+ },
+ ]);
+ render( );
+ return submitted;
+}
+
+describe("LocaleSwitcher", () => {
+ beforeEach(() => {
+ // A cookie surviving between cases would let a test pass on the previous
+ // test's write.
+ document.cookie = "PARAGLIDE_LOCALE=; path=/; max-age=0";
+ });
+
+ it("writes the cookie and persists the choice", async () => {
+ const submitted = renderSwitcher("en");
+ fireEvent.click(await screen.findByRole("radio", { name: /español/i }));
+
+ expect(document.cookie).toContain("PARAGLIDE_LOCALE=es-419");
+ // Persisted as the tag the settings stores, not the Paraglide tag:
+ // saving 'es-419' here and 'en' in the other direction would make Profile
+ // show "Use workspace default" for a preference just set.
+ await waitFor(() => expect(submitted.intent).toBe("set-locale"));
+ expect(submitted.locale).toBe("es-419");
+ });
+
+ it("switches back to English, storing the region-qualified tag", async () => {
+ // The reverse direction, because a switcher that only ever answers Spanish
+ // passes the test above.
+ const submitted = renderSwitcher("es-419");
+ fireEvent.click(await screen.findByRole("radio", { name: /english/i }));
+
+ expect(document.cookie).toContain("PARAGLIDE_LOCALE=en");
+ await waitFor(() => expect(submitted.locale).toBe("en-US"));
+ });
+
+ it("reflects the locale the SERVER rendered, not a local default", async () => {
+ renderSwitcher("es-419");
+ expect(await screen.findByRole("radio", { name: /español/i })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ expect(screen.getByRole("radio", { name: /english/i })).toHaveAttribute(
+ "aria-checked",
+ "false",
+ );
+ });
+
+ it("does nothing when the current language is re-selected", async () => {
+ const submitted = renderSwitcher("en");
+ fireEvent.click(await screen.findByRole("radio", { name: /english/i }));
+
+ expect(document.cookie).not.toContain("PARAGLIDE_LOCALE=");
+ expect(submitted.intent).toBeUndefined();
+ });
+
+ it("understands a stored tag the cookie contract cannot serve", async () => {
+ // The root loader reports whatever the paraglide scope resolved, but a
+ // regional tag reaching this control must still select a real segment
+ // rather than leaving every one of them unchecked.
+ renderSwitcher("es-MX");
+ expect(await screen.findByRole("radio", { name: /español/i })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ });
+});
diff --git a/app/components/LocaleSwitcher.tsx b/app/components/LocaleSwitcher.tsx
new file mode 100644
index 000000000..fc06341ea
--- /dev/null
+++ b/app/components/LocaleSwitcher.tsx
@@ -0,0 +1,76 @@
+import { useFetcher, useRouteLoaderData } from "react-router";
+import { SegmentedControl, type SegmentedControlOption } from "@core/shared-ui";
+import { SUPPORTED_CONTACT_LOCALES, normalizeLocale } from "../../server/lib/i18n/contact-locale";
+import { localeShortLabel, storedLocaleTag } from "~/lib/locales";
+import { writeUiLocaleCookie } from "~/lib/ui-prefs";
+import { m } from "~/paraglide/messages";
+
+/**
+ * The always-reachable language control (#269).
+ *
+ * Two pickers already persist a language — Settings → Profile writes
+ * `users.locale`, Settings → Workspace writes `tenant_configs.default_locale`.
+ * Neither is reachable from the page you are on, and, more to the point,
+ * neither is reachable by someone who cannot read the English word "Settings".
+ * This is the one control a person can find when the interface is in a language
+ * they do not speak, which is why it sits beside the theme control in the user
+ * menu rather than on a settings page.
+ *
+ * Deliberately built on the same `SegmentedControl` as `ThemeSegmentControl`,
+ * next to which it renders: a bespoke dropdown here would be the third language
+ * control in the app and the only one shaped unlike its own neighbour.
+ *
+ * TWO WRITES, and both are needed:
+ *
+ * 1. The COOKIE, written first and synchronously. The worker resolves the
+ * render locale from it before the router runs (`withResolvedUiLocale`), so
+ * this is what makes the change take effect on the very next request rather
+ * than after a database round trip.
+ * 2. `users.locale`, through the existing profile action, so the choice
+ * survives a new device — and so `auth-layout`'s stamp, which ranks the
+ * stored preference ABOVE the cookie, agrees with it instead of correcting
+ * it back on the next navigation.
+ *
+ * The re-render is a consequence of (2), not a separate mechanism: React Router
+ * revalidates every loader after a fetcher submission, so the root loader re-runs
+ * server-side inside the new request's paraglide scope and the whole tree — plus
+ * `` — comes back in the new language. No reload, no flash.
+ */
+export function LocaleSwitcher({ className }: { className?: string }) {
+ // Root loader data, exactly as ThemeSegmentControl reads the color scheme:
+ // it is the locale the SERVER rendered this page in, so the control always
+ // shows what the reader is actually looking at rather than what was last
+ // clicked. Absent while the error boundary renders — fall back to English.
+ const rootData = useRouteLoaderData("root") as { locale?: string } | undefined;
+ const current = normalizeLocale(rootData?.locale) ?? "en";
+ const fetcher = useFetcher();
+
+ // Built at render time (not a module const) so `m.*()` resolves inside the
+ // paraglide request scope — same reason ThemeSegmentControl builds its own.
+ //
+ // The option LABELS come from the locale table, not from message keys: a
+ // language name is not translated ("Español" is Español in every language),
+ // and a per-locale key would let the two catalogues disagree about what a
+ // language is called.
+ const options: SegmentedControlOption[] = SUPPORTED_CONTACT_LOCALES.map((value) => ({
+ value,
+ label: localeShortLabel(value),
+ }));
+
+ return (
+ {
+ if (next === current) return;
+ writeUiLocaleCookie(next);
+ fetcher.submit(
+ { intent: "set-locale", locale: storedLocaleTag(next) },
+ { method: "post", action: "/settings/profile" },
+ );
+ }}
+ />
+ );
+}
diff --git a/app/components/sidebar/MobileDrawer.tsx b/app/components/sidebar/MobileDrawer.tsx
index d9e15dd1a..237085c81 100644
--- a/app/components/sidebar/MobileDrawer.tsx
+++ b/app/components/sidebar/MobileDrawer.tsx
@@ -2,6 +2,7 @@ import { NavLink } from "react-router";
import { useSessionContext } from "~/hooks/useSessionContext";
import { IC, WORKSPACE_ITEMS } from "~/components/sidebar/nav-items";
import { ThemeSegmentControl } from "~/components/sidebar/ThemeSegmentControl";
+import { LocaleSwitcher } from "~/components/LocaleSwitcher";
import { Avatar, Icon } from "@core/shared-ui";
import { m } from "~/paraglide/messages";
@@ -96,6 +97,13 @@ export function MobileDrawer({ open, onClose }: { open: boolean; onClose: () =>
{m.nav_theme_label()}
+ {/* Language (#269) — the mobile drawer is the phone's user menu, so
+ the switcher has to be here too or it is desktop-only. */}
+
+ {/* ih-fg-3 — see the note in UserMenuPopover; ih-fg-4 fails AA. */}
+
{m.nav_language_label()}
+
+
diff --git a/app/components/sidebar/UserMenuPopover.tsx b/app/components/sidebar/UserMenuPopover.tsx
index c48c8db81..b1fee4f1d 100644
--- a/app/components/sidebar/UserMenuPopover.tsx
+++ b/app/components/sidebar/UserMenuPopover.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { NavLink } from "react-router";
import { IC } from "~/components/sidebar/nav-items";
import { ThemeSegmentControl } from "~/components/sidebar/ThemeSegmentControl";
+import { LocaleSwitcher } from "~/components/LocaleSwitcher";
import { m } from "~/paraglide/messages";
// ─── User Menu popover (desktop sidebar) ─────────────────────────────────────
@@ -85,6 +86,18 @@ export function UserMenuPopover({
+ {/* Language (#269) — beside Theme, not on a settings page. Someone who
+ cannot read the interface cannot navigate to Settings to fix that;
+ this is the one language control reachable from wherever they are. */}
+
+ {/* ih-fg-3, not the ih-fg-4 its neighbour above uses: fg-4 measures
+ 2.45:1 in light and 3.75:1 in dark, both below WCAG AA. The Theme
+ heading is a pre-existing instance and is left for a token sweep;
+ new text does not join it. */}
+
{m.nav_language_label()}
+
+
+
{/* Divider + Account items */}
{/* ds-allow: compact menu row rhythm (7px), no semantic spacing token */}
diff --git a/app/lib/locales.test.ts b/app/lib/locales.test.ts
new file mode 100644
index 000000000..504ba280d
--- /dev/null
+++ b/app/lib/locales.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import { LOCALE_OPTIONS, localeLabel, localeShortLabel, storedLocaleTag } from "./locales";
+import { SUPPORTED_CONTACT_LOCALES } from "../../server/lib/i18n/contact-locale";
+
+/**
+ * Three tables have to agree about what languages exist: the compiled catalogue
+ * (`SUPPORTED_CONTACT_LOCALES`), the settings pickers' stored tags
+ * (`LOCALE_OPTIONS`), and the switcher's short labels. They are separate on
+ * purpose — the catalogue is a build artifact, the stored tags are BCP-47, and
+ * the labels are copy — but a language present in one and missing from another
+ * shows up as an empty segment or a preference that will not stick, never as an
+ * error. Asserted here rather than left to a comment saying "keep in sync".
+ */
+describe("the locale tables agree", () => {
+ it("offers a stored tag for every locale the catalogue is compiled for", () => {
+ for (const locale of SUPPORTED_CONTACT_LOCALES) {
+ const stored = storedLocaleTag(locale);
+ expect(LOCALE_OPTIONS.map((o) => o.value)).toContain(stored);
+ }
+ });
+
+ it("gives every offered locale a short label distinct from the raw tag", () => {
+ for (const option of LOCALE_OPTIONS) {
+ const short = localeShortLabel(option.value);
+ // Falling through to the tag is the failure mode: it renders 'es-419' as
+ // a segment label, which names a UN region code rather than a language.
+ expect(short).not.toBe(option.value);
+ expect(short.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("names languages in their own language, not the reader's", () => {
+ // Someone who cannot read the current interface language has to be able to
+ // find their own — an all-English list defeats the control entirely.
+ expect(localeShortLabel("es-419")).toBe("Español");
+ expect(localeShortLabel("en")).toBe("English");
+ });
+});
+
+describe("storedLocaleTag", () => {
+ it("maps a Paraglide tag onto the tag the settings picker stores", () => {
+ // 'en' is what the cookie and the catalogue call it; 'en-US' is what
+ // users.locale holds. Persisting 'en' would leave the Profile
with
+ // no matching option, so it would read as "Use workspace default".
+ expect(storedLocaleTag("en")).toBe("en-US");
+ expect(storedLocaleTag("es-419")).toBe("es-419");
+ });
+
+ it("is idempotent, so a stored tag round-trips unchanged", () => {
+ for (const option of LOCALE_OPTIONS) {
+ expect(storedLocaleTag(storedLocaleTag(option.value))).toBe(option.value);
+ }
+ });
+
+ it("hands back an unknown tag rather than inventing a language", () => {
+ expect(storedLocaleTag("fr-FR")).toBe("fr-FR");
+ });
+});
+
+describe("localeShortLabel vs localeLabel", () => {
+ it("drops the region qualifier the popover has no room for", () => {
+ // The long label stays available for the settings , where one row
+ // per stored tag means the region is the disambiguator.
+ expect(localeLabel("es-419")).toContain("Latinoam");
+ expect(localeShortLabel("es-419")).not.toContain("(");
+ });
+
+ it("falls back to the long label for a language with no short one", () => {
+ expect(localeShortLabel("fr-FR")).toBe(localeLabel("fr-FR"));
+ });
+});
diff --git a/app/lib/locales.ts b/app/lib/locales.ts
index fedcff406..58c8371df 100644
--- a/app/lib/locales.ts
+++ b/app/lib/locales.ts
@@ -26,6 +26,46 @@ export function localeLabel(tag: string): string {
return LOCALE_OPTIONS.find((o) => languageOf(o.value) === language)?.label ?? tag;
}
+/**
+ * Short labels for the always-reachable language switcher (#269).
+ *
+ * The full labels above name the REGION as well, because the settings
+ * offers one row per stored tag and "Español" alone would not say which Spanish
+ * is stored. The switcher is a two-segment control in a 220px popover, where
+ * the region qualifier does not fit and answers a question nobody is asking
+ * mid-task: there is exactly one Spanish to switch to.
+ *
+ * Keyed by LANGUAGE subtag for the same reason `localeLabel` matches that way —
+ * the cookie holds a Paraglide tag ('en'), the settings picker holds a stored
+ * tag ('en-US'), and both must find the same row. Falls back to the full label
+ * (and through it to the raw tag) so an unlisted locale still renders SOMETHING
+ * rather than an empty segment; `locales.test.ts` asserts every option has one.
+ */
+const SHORT_LABELS: Record = {
+ en: "English",
+ es: "Español",
+};
+
+/** The switcher's label for a locale: short where we have one, else the full
+ * label. Written in the locale's OWN language, like `localeLabel`. */
+export function localeShortLabel(tag: string): string {
+ return SHORT_LABELS[languageOf(tag)] ?? localeLabel(tag);
+}
+
+/**
+ * The tag to STORE for a locale the UI resolved to.
+ *
+ * The two vocabularies differ: the UI and the cookie speak Paraglide tags
+ * ('en', 'es-419'), while `users.locale` / `tenant_configs.default_locale` hold
+ * what the settings pickers write ('en-US', 'es-419'). Persisting 'en' verbatim
+ * would save a value the Profile has no option for, so the page would
+ * show "Use workspace default" for a preference the user had just set.
+ */
+export function storedLocaleTag(tag: string): string {
+ const language = languageOf(tag);
+ return LOCALE_OPTIONS.find((o) => languageOf(o.value) === language)?.value ?? tag;
+}
+
/** Supported tenant currencies (ISO 4217). */
export const CURRENCY_OPTIONS: { value: string; label: string }[] = [
{ value: "USD", label: "USD — US Dollar" },
diff --git a/app/lib/ui-prefs.ts b/app/lib/ui-prefs.ts
index 9da52203c..10ab29333 100644
--- a/app/lib/ui-prefs.ts
+++ b/app/lib/ui-prefs.ts
@@ -7,7 +7,12 @@
* localStorage is invisible to the server; reading it in a `useState` initializer
* makes the client's first render diverge from the server HTML. Cookies are sent
* with every request, so server and client agree on the first render.
+ *
+ * The UI language (#269) joined them for exactly that reason, and is read back
+ * by `withResolvedUiLocale` in the worker rather than parsed here — Paraglide
+ * owns that cookie's contract, so this module only writes it.
*/
+import { UI_LOCALE_COOKIE } from "../../server/lib/i18n/ui-locale";
/** Track H (migration step 5) — 'field' is a high-contrast, large-type variant of dark
* for outdoor/sunlight use (18px base font + stronger contrast). A first-class
@@ -63,6 +68,22 @@ export function writeColorSchemeCookie(scheme: ColorScheme): void {
document.cookie = `${COLOR_SCHEME_COOKIE}=${scheme}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax`;
}
+/**
+ * Persist the UI language client-side so the next SSR render is correct (#269).
+ *
+ * Sibling of the two above and written the same way for the same reason, but
+ * this one is load-bearing rather than cosmetic: the worker resolves the render
+ * locale from THIS cookie before the router runs, so writing it is what makes a
+ * language change take effect on the very next request instead of after the
+ * preference has round-tripped through D1.
+ *
+ * The name is imported from the resolver rather than restated — the reader and
+ * the writer of a cookie drifting apart is a silent, total failure.
+ */
+export function writeUiLocaleCookie(locale: string): void {
+ document.cookie = `${UI_LOCALE_COOKIE}=${locale}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax`;
+}
+
/** Persist the sidebar-collapsed flag client-side so the next SSR render is correct. */
export function writeSidebarCookie(collapsed: boolean): void {
document.cookie = `${SIDEBAR_COOKIE}=${collapsed ? "1" : "0"}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax`;
diff --git a/app/routes/auth-layout.tsx b/app/routes/auth-layout.tsx
index bfee9bc7e..121aa587c 100644
--- a/app/routes/auth-layout.tsx
+++ b/app/routes/auth-layout.tsx
@@ -74,6 +74,33 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return data({ context: sessionContext }, { headers });
}
+/**
+ * Surfaces the loader's `Set-Cookie` on the document response.
+ *
+ * Without this the stamp above is silently dropped: React Router does not
+ * propagate a nested loader's headers by default — it uses the deepest
+ * `headers` export it can find, and if no route on the branch exports one, the
+ * loader's headers go nowhere. Verified against a running server rather than
+ * assumed: before this existed, `document.cookie` held no PARAGLIDE_LOCALE
+ * after any number of authenticated page loads, and the language a viewer had
+ * saved in Settings never took effect.
+ *
+ * Only `Set-Cookie` is forwarded. Passing `loaderHeaders` through wholesale
+ * would let any future header set on this loader leak onto every authenticated
+ * document response, including caching directives that must not apply to a
+ * per-viewer page.
+ *
+ * No other route on this branch exports `headers` (asserted by
+ * `auth-layout-headers.test.ts`) — one that did would win as the deeper export
+ * and would have to forward this itself.
+ */
+export function headers({ loaderHeaders }: Route.HeadersArgs) {
+ const out = new Headers();
+ const cookie = loaderHeaders.get("Set-Cookie");
+ if (cookie) out.append("Set-Cookie", cookie);
+ return out;
+}
+
export default function AuthLayout() {
const { context } = useLoaderData();
const navigation = useNavigation();
diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx
index 6728f042c..5353646df 100644
--- a/app/routes/settings-profile.tsx
+++ b/app/routes/settings-profile.tsx
@@ -209,6 +209,23 @@ export async function action({ request, context }: Route.ActionArgs) {
);
}
+ // The language switcher (#269) lives in the sidebar user menu, not on this
+ // page, and submits here through a fetcher — this action is already the one
+ // place `users.locale` is written, and a second writer would be a second set
+ // of rules about what a valid stored tag is.
+ //
+ // Its own intent rather than the default branch: that branch parses the WHOLE
+ // profile form, and a submission carrying two fields would fail validation on
+ // everything the switcher does not know about.
+ if (intent === "set-locale") {
+ const res = await api.profile.index.$patch({ json: { locale: String(fd.get("locale") ?? "") } });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ return { success: false, error: (err as Record)?.message || m.settings_error_save_failed(), intent };
+ }
+ return { success: true, error: null, intent };
+ }
+
// The email-signature toggle saves itself (it is no longer inside the profile
// form), so it needs its own intent rather than riding the default branch.
if (intent === "signature-toggle") {
diff --git a/messages/en/nav.json b/messages/en/nav.json
index b3c786bb6..dabab438c 100644
--- a/messages/en/nav.json
+++ b/messages/en/nav.json
@@ -29,5 +29,7 @@
"nav_theme_dark": "Dark",
"nav_theme_field": "Field",
"nav_theme_field_title": "High-contrast large type for outdoor use",
- "nav_theme_aria": "Color theme"
+ "nav_theme_aria": "Color theme",
+ "nav_language_label": "Language",
+ "nav_language_aria": "Interface language"
}
diff --git a/messages/es-419/nav.json b/messages/es-419/nav.json
index dd1c976b1..769464d66 100644
--- a/messages/es-419/nav.json
+++ b/messages/es-419/nav.json
@@ -29,5 +29,7 @@
"nav_theme_dark": "Oscuro",
"nav_theme_field": "Campo",
"nav_theme_field_title": "Tipografía grande de alto contraste para uso en exteriores",
- "nav_theme_aria": "Tema de color"
+ "nav_theme_aria": "Tema de color",
+ "nav_language_label": "Idioma",
+ "nav_language_aria": "Idioma de la interfaz"
}
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index da4835cd2..ac23322e3 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -31,8 +31,8 @@
"server/services/agent/referral.ts": 564,
"app/lib/collab/results-binding.ts": 560,
"server/api/inspections/core.ts": 560,
+ "app/routes/settings-profile.tsx": 548,
"server/api/calendar.ts": 547,
- "app/routes/settings-profile.tsx": 531,
"server/services/inspection/inspection-photo.service.ts": 531,
"app/components/NewInspectionWizard.tsx": 530,
"server/api/inspections/media-studio.ts": 530,
diff --git a/server/lib/i18n/ui-locale.ts b/server/lib/i18n/ui-locale.ts
index 7930a88c8..aea7a8747 100644
--- a/server/lib/i18n/ui-locale.ts
+++ b/server/lib/i18n/ui-locale.ts
@@ -124,8 +124,21 @@ export function setUiLocaleInCookieHeader(
* surface that knows them (`auth-layout`'s loader, and the switcher).
*
* Returns the ORIGINAL request untouched when the cookie already says the right
- * thing, which is every request after the first — so the steady-state cost is
- * one header read.
+ * thing.
+ *
+ * It does NOT persist what it resolved, and that is deliberate rather than an
+ * omission. Nothing pre-auth constitutes an explicit choice — the switcher
+ * lives in the authenticated sidebar — so an anonymous visitor's language is
+ * re-read from `Accept-Language` on every request, which is both stateless and
+ * correct: change the browser's language and the next page follows, with no
+ * stale cookie to fight. Persistence begins where a CHOICE begins, at
+ * `uiLocaleStampFor` (a stored preference) and the switcher (a click).
+ *
+ * One consequence to keep in mind when reading `uiLocaleStampFor`: by the time
+ * a loader runs, the header this rewrote is what it sees, so the "cookie" rung
+ * downstream is the locale ALREADY IN EFFECT, not necessarily one the browser
+ * ever stored. That is what makes the stamp fire only when a stored preference
+ * genuinely disagrees with the rendered page.
*/
export function withResolvedUiLocale(request: Request): Request {
const cookieHeader = request.headers.get('Cookie');
diff --git a/tests/unit/i18n/ui-locale.spec.ts b/tests/unit/i18n/ui-locale.spec.ts
index 307f03dbf..c5c20e34f 100644
--- a/tests/unit/i18n/ui-locale.spec.ts
+++ b/tests/unit/i18n/ui-locale.spec.ts
@@ -146,11 +146,24 @@ describe('withResolvedUiLocale (the seam)', () => {
it('leaves the request untouched once the cookie already agrees', () => {
const original = req({ Cookie: 'PARAGLIDE_LOCALE=es-419', 'Accept-Language': 'en-US' });
- // Identity, not equality: the steady state must not allocate a Request
- // per page load, and must not risk dropping anything a copy would.
+ // Identity, not equality: once a choice has been persisted there is
+ // nothing to rewrite, and a copy that dropped anything off the request
+ // would be a cost paid on every page load for no benefit.
expect(withResolvedUiLocale(original)).toBe(original);
});
+ it('does not persist what it resolved — pre-auth there is no choice yet', () => {
+ // An anonymous visitor's language is re-read from Accept-Language every
+ // request rather than frozen into a cookie they never chose. Asserted
+ // because the alternative looks like a harmless optimisation: stamping
+ // here would outrank the browser on the NEXT request and a visitor who
+ // switched their browser language would be stuck.
+ const first = withResolvedUiLocale(req({ 'Accept-Language': 'es-419' }));
+ expect(readUiLocaleCookie(first.headers.get('Cookie'))).toBe('es-419');
+ const second = withResolvedUiLocale(req({ 'Accept-Language': 'en-US' }));
+ expect(readUiLocaleCookie(second.headers.get('Cookie'))).toBe('en');
+ });
+
it('rewrites a stale tag the cookie contract cannot serve', () => {
// 'en-US' is what the settings picker stores; Paraglide has no such
// locale, so an unrewritten cookie falls back to baseLocale by accident
From df6f9971673959a0a3ad69760c02586280b6a7a2 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 22:35:14 +0800
Subject: [PATCH 076/111] test(i18n): prove activation end to end, and verify
the catalog gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Five pre-auth cases against /login, the surface fully externalized in Phase C so
a failure here is resolution rather than missing translation. Each asserts the
LANGUAGE OF THE RENDER, not only — lang alone stays green if the
resolver works and the catalogue never loads, which is the failure this rollout
exists to rule out.
Proven to fail without the feature: bypassing withResolvedUiLocale in
workers/app.ts reddens exactly two of the five — the Spanish browser with no
cookie, and the 'es-MX' cookie that only a normaliser can serve. The other three
stay green on purpose, because paraglide's own cookie strategy already handles
an exact-tag cookie; a suite where all five reddened would mean they were
testing paraglide rather than this change.
The cookie-beats-browser case is the one that would have caught the plan's
original precedence, in both directions.
The catalog gate needed no flip: #268 landed first and rollout4 (6fe51fde)
already made it a hard parity gate with an empty FALLBACK_ALLOW. Verified rather
than touched — 4366/4366 with this plan's two new nav keys, and deleting an
es-419 key reports the key and exits 1 rather than passing softly. The plan's
instruction to leave it report-only is stale and is struck out at source.
---
playwright.config.ts | 7 ++
tests/e2e/locale-activation.spec.ts | 99 +++++++++++++++++++++++++++++
2 files changed, 106 insertions(+)
create mode 100644 tests/e2e/locale-activation.spec.ts
diff --git a/playwright.config.ts b/playwright.config.ts
index cb5881372..344babc5e 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -153,6 +153,13 @@ export default defineConfig({
name: 'responsive',
testMatch: 'public-pages-responsive.spec.ts',
},
+ {
+ // #269 — i18n activation. Pre-auth only (/login), so it needs no D1
+ // seed and no dependency on another project: the whole point is
+ // that the locale resolves from the request alone.
+ name: 'locale-activation',
+ testMatch: 'locale-activation.spec.ts',
+ },
{
// Sprint 1 D-8 — report-gate end-to-end (auth + payment + agreement
// gates). Depends on browser project to ensure user is created.
diff --git a/tests/e2e/locale-activation.spec.ts b/tests/e2e/locale-activation.spec.ts
new file mode 100644
index 000000000..13fadd4e3
--- /dev/null
+++ b/tests/e2e/locale-activation.spec.ts
@@ -0,0 +1,99 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * #269 — proof that the i18n framework is ACTIVATED, not merely present.
+ *
+ * `/login` is the right surface for this: it was fully externalized in Phase C,
+ * so a failure here is a resolution failure rather than a missing translation.
+ * It is also pre-auth, which means no `users.locale` and no tenant default are
+ * in play — exactly the rungs this file is NOT trying to test. What is under
+ * test is the seam in `workers/app.ts`, which rewrites the incoming Cookie
+ * header before paraglide reads it.
+ *
+ * Every case asserts the LANGUAGE OF THE RENDER, not just the `lang` attribute.
+ * `lang` alone would stay green if the resolver worked and the message
+ * catalogue never loaded — which is the precise failure this whole rollout
+ * exists to rule out.
+ */
+
+const SPANISH_ON_LOGIN = /Iniciar sesión|Contraseña|Correo/;
+const ENGLISH_ON_LOGIN = /Sign in|Password|Email/;
+
+test.describe('locale activation', () => {
+ test('a Spanish browser with no cookie gets Spanish', async ({ browser }) => {
+ const ctx = await browser.newContext({ locale: 'es-419' });
+ const page = await ctx.newPage();
+ await page.goto('/login');
+
+ await expect(page.locator('html')).toHaveAttribute('lang', 'es-419');
+ await expect(page.locator('body')).toContainText(SPANISH_ON_LOGIN);
+ await ctx.close();
+ });
+
+ test('an English browser is byte-for-byte unaffected', async ({ browser }) => {
+ // The other half of the contract, and the one that would break every
+ // existing user: someone who has set no preference must see exactly
+ // what they saw before activation.
+ const ctx = await browser.newContext({ locale: 'en-US' });
+ const page = await ctx.newPage();
+ await page.goto('/login');
+
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(page.locator('body')).toContainText(ENGLISH_ON_LOGIN);
+ await ctx.close();
+ });
+
+ test('a language we do not speak falls through to English, not to itself', async ({ browser }) => {
+ // The resolver must IGNORE an unsupported tag rather than stop on it.
+ // Stopping would render `lang="fr-FR"` over English text, which tells
+ // a screen reader to pronounce English with French phonetics.
+ const ctx = await browser.newContext({ locale: 'fr-FR' });
+ const page = await ctx.newPage();
+ await page.goto('/login');
+
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(page.locator('body')).toContainText(ENGLISH_ON_LOGIN);
+ await ctx.close();
+ });
+
+ test('an explicit cookie beats the browser, in both directions', async ({ browser }) => {
+ // The switcher's contract, at the level where it can actually break.
+ // The plan for #269 ranked the cookie BELOW Accept-Language; under that
+ // ordering the first half of this test fails, because a Spanish-
+ // browsered person who picks English is handed Spanish straight back.
+ const spanishBrowser = await browser.newContext({ locale: 'es-419' });
+ await spanishBrowser.addCookies([
+ { name: 'PARAGLIDE_LOCALE', value: 'en', url: 'http://127.0.0.1:8789' },
+ ]);
+ const englishPage = await spanishBrowser.newPage();
+ await englishPage.goto('/login');
+ await expect(englishPage.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(englishPage.locator('body')).toContainText(ENGLISH_ON_LOGIN);
+ await spanishBrowser.close();
+
+ const englishBrowser = await browser.newContext({ locale: 'en-US' });
+ await englishBrowser.addCookies([
+ { name: 'PARAGLIDE_LOCALE', value: 'es-419', url: 'http://127.0.0.1:8789' },
+ ]);
+ const spanishPage = await englishBrowser.newPage();
+ await spanishPage.goto('/login');
+ await expect(spanishPage.locator('html')).toHaveAttribute('lang', 'es-419');
+ await expect(spanishPage.locator('body')).toContainText(SPANISH_ON_LOGIN);
+ await englishBrowser.close();
+ });
+
+ test('a stored tag the catalogue has no locale for still resolves', async ({ browser }) => {
+ // 'en-US' is what the settings pickers write. Paraglide has no such
+ // locale, so an unnormalised cookie would fall back to baseLocale by
+ // accident — right answer, wrong reason, and 'es-MX' would then be
+ // wrong for real. Both are asserted here so the pair cannot drift.
+ const ctx = await browser.newContext({ locale: 'en-US' });
+ await ctx.addCookies([
+ { name: 'PARAGLIDE_LOCALE', value: 'es-MX', url: 'http://127.0.0.1:8789' },
+ ]);
+ const page = await ctx.newPage();
+ await page.goto('/login');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'es-419');
+ await ctx.close();
+ });
+});
From cfaa65930a60b319eed2081d9127aff9cc638b61 Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 22:52:06 +0800
Subject: [PATCH 077/111] fix(ai): make the model configurable instead of
pinned in the URL
ai.service.ts hardcoded gemini-1.5-flash into the request URL, so every AI
feature was quality-capped at one model with no way to change it.
The model now comes from deployment configuration (AI_MODEL) with NO baked-in
default: an unconfigured model fails closed with a 503, at every deployment
mode. A fallback constant would reproduce the same silent pin the next time
the chosen model ages out, and the repo rule for keys/endpoints/model ids is
config-or-fail-closed.
The standalone dev mock is unchanged and deliberately NOT widened to cover a
missing model - it exists for a self-hoster with no key, and covering this
case would write placeholder prose into a real report.
---
scripts/file-size-baseline.json | 2 +-
server/lib/middleware/di.ts | 3 +
server/services/ai.service.ts | 35 +++++++++++-
server/types/hono.ts | 5 +-
tests/unit/ai/ai.service.spec.ts | 8 +--
tests/unit/ai/model-config.spec.ts | 92 ++++++++++++++++++++++++++++++
6 files changed, 138 insertions(+), 7 deletions(-)
create mode 100644 tests/unit/ai/model-config.spec.ts
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index ac23322e3..2ae0fbab0 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -60,8 +60,8 @@
"app/components/media-studio/VideoCapture.tsx": 433,
"app/routes/public/portal-inspection.tsx": 430,
"server/api/inspections/results.ts": 430,
+ "server/lib/middleware/di.ts": 425,
"app/hooks/useStructureEdit.ts": 424,
- "server/lib/middleware/di.ts": 422,
"app/routes/templates.tsx": 414,
"app/routes/calendar.tsx": 410,
"server/services/agreement/signer-state.ts": 409,
diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts
index 55334a663..388529fc4 100644
--- a/server/lib/middleware/di.ts
+++ b/server/lib/middleware/di.ts
@@ -169,6 +169,9 @@ export async function diMiddleware(c: Context, next: Next) {
// active profile permits it (standalone) and
// no API key is configured, instead of throwing 503.
c.var.profile.aiDevMockFallback ? 'standalone' : 'saas',
+ // No default here on purpose: an unset AI_MODEL fails
+ // closed at the service rather than picking a model.
+ c.env.AI_MODEL ?? '',
);
break;
case 'auth':
diff --git a/server/services/ai.service.ts b/server/services/ai.service.ts
index d1225eafa..5ce6e39ab 100644
--- a/server/services/ai.service.ts
+++ b/server/services/ai.service.ts
@@ -12,12 +12,20 @@ import { Errors } from '../lib/errors';
* exercise the UI flow end-to-end. Production deploys (`saas` mode or
* unspecified) throw `Errors.AINotConfigured` (503) so the client can
* route the inspector to AI settings instead of showing a silent failure.
+ *
+ * The MODEL is configuration, never a source constant. There is deliberately
+ * no baked-in default: a model id compiled into the binary is how the request
+ * URL ended up pinned to one model for two years with no way to change it, and
+ * a fallback would hide the same mistake next time. Unconfigured fails closed.
*/
export class AIService {
constructor(
private db: D1Database,
private apiKey: string,
private appMode?: 'standalone' | 'saas',
+ /** Model id from deployment configuration (`AI_MODEL`). Empty = not
+ * configured, which is an error rather than a cue to pick one. */
+ private model: string = '',
) {}
private isDevMode(): boolean {
@@ -28,6 +36,23 @@ export class AIService {
return Boolean(this.apiKey) && !this.apiKey.includes('your_api_key');
}
+ /**
+ * Fail closed on an unconfigured model.
+ *
+ * Deliberately NOT folded into the dev-mock branch: the mock exists for a
+ * self-hoster who has no key yet, and widening it to cover a missing model
+ * would write `[DEV] …` placeholder prose into a real report for someone
+ * whose key works fine. A missing model is a configuration error at every
+ * deployment mode, so it always throws.
+ */
+ private assertModelConfigured(): void {
+ if (!this.model) {
+ throw Errors.AINotConfigured(
+ 'AI is unavailable: no AI model is configured. Set AI_MODEL for this deployment.',
+ );
+ }
+ }
+
private getDrizzle() {
return drizzle(this.db);
}
@@ -39,8 +64,11 @@ export class AIService {
if (!this.apiKey || this.apiKey.includes('your_api_key')) {
throw new Error('Gemini API Key missing');
}
+ // Backstop for the two entry points that do not pre-check
+ // (generateProfessionalComment / generateInspectionSummary).
+ this.assertModelConfigured();
- const res = await fetch(`https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key=${this.apiKey}`, {
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1/models/${encodeURIComponent(this.model)}:generateContent?key=${this.apiKey}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -141,6 +169,7 @@ Summary:`;
'AI is not configured. Set GEMINI_API_KEY in Settings → Advanced → AI.'
);
}
+ this.assertModelConfigured();
const ctxLines = [
`Item: "${input.itemLabel}"`,
@@ -197,6 +226,10 @@ Return only the rewritten comment text — no preamble, no quotes, no markdown.`
'AI is not configured. Set GEMINI_API_KEY in Settings → Advanced → AI.'
);
}
+ // Outside the try/catch below on purpose: that catch turns RUNTIME
+ // failures into an empty suggestion list, and a configuration error
+ // must not disappear into "no suggestions today".
+ this.assertModelConfigured();
const context = [
params.rating ? `Rating: ${params.rating}` : null,
diff --git a/server/types/hono.ts b/server/types/hono.ts
index 1ed3ce74a..8f72c981c 100644
--- a/server/types/hono.ts
+++ b/server/types/hono.ts
@@ -60,7 +60,10 @@ export interface AppEnv {
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GEMINI_API_KEY: string;
-
+ /** AI model id (e.g. a Gemini model name). No default is compiled in —
+ * when unset, AI features fail closed rather than picking a model. */
+ AI_MODEL?: string;
+
// Communication
RESEND_API_KEY: string;
SENDER_EMAIL: string;
diff --git a/tests/unit/ai/ai.service.spec.ts b/tests/unit/ai/ai.service.spec.ts
index 3f10c0724..e98ff52a6 100644
--- a/tests/unit/ai/ai.service.spec.ts
+++ b/tests/unit/ai/ai.service.spec.ts
@@ -49,7 +49,7 @@ describe('Spec 5B P2B — AIService.rewriteComment', () => {
it('returns the rewritten text with surrounding quotes stripped', async () => {
mockGeminiOK('"Major cracking observed at NW corner; recommend evaluation."');
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
const out = await svc.rewriteComment({
itemLabel: 'Roof Covering', sectionTitle: 'Roof', tab: 'defects',
originalComment: 'Cracks observed.', instruction: 'add NW corner detail',
@@ -61,7 +61,7 @@ describe('Spec 5B P2B — AIService.rewriteComment', () => {
it('includes item / section / tab / category / location in the prompt', async () => {
mockGeminiOK('rewritten body');
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
await svc.rewriteComment({
itemLabel: 'Roof Covering',
sectionTitle: 'Roof',
@@ -85,7 +85,7 @@ describe('Spec 5B P2B — AIService.rewriteComment', () => {
it('omits defect-only context fields when tab is not "defects"', async () => {
mockGeminiOK('rewritten');
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
await svc.rewriteComment({
itemLabel: 'Inspection Method',
sectionTitle: 'Roof',
@@ -103,7 +103,7 @@ describe('Spec 5B P2B — AIService.rewriteComment', () => {
it('throws on Gemini error responses', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, text: async () => 'rate limited' } as Response);
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
await expect(svc.rewriteComment({
itemLabel: 'Roof', sectionTitle: 'Roof', tab: 'defects',
originalComment: 'foo', instruction: 'shorten',
diff --git a/tests/unit/ai/model-config.spec.ts b/tests/unit/ai/model-config.spec.ts
new file mode 100644
index 000000000..3fca737c2
--- /dev/null
+++ b/tests/unit/ai/model-config.spec.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { AIService } from '../../../server/services/ai.service';
+
+/**
+ * The AI model is configuration, not a source-code constant.
+ *
+ * The request URL used to hardcode `gemini-1.5-flash`, so every AI feature was
+ * quality-capped at one model with no way to change it. The model now arrives
+ * as configuration, and — like every other credential/endpoint in this repo —
+ * there is NO baked-in fallback: an unconfigured model fails closed.
+ *
+ * The fail-closed cases are the load-bearing ones. A suite that only exercises
+ * the configured path passes just as happily against a hardcoded default, which
+ * is exactly the bug this file exists to prevent.
+ */
+describe('AIService — model configuration', () => {
+ const fetchMock = vi.fn();
+ let originalFetch: typeof globalThis.fetch;
+
+ beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ candidates: [{ content: { parts: [{ text: 'ok' }] } }] }),
+ } as Response);
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ const REWRITE_INPUT = {
+ itemLabel: 'Roof', sectionTitle: 'Roof', tab: 'defects' as const,
+ originalComment: 'foo', instruction: 'shorten',
+ };
+
+ it('sends the configured model in the request URL', async () => {
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'gemini-3.1-flash-lite');
+ await svc.rewriteComment(REWRITE_INPUT);
+ expect(String(fetchMock.mock.calls[0]![0])).toContain('gemini-3.1-flash-lite');
+ });
+
+ it('carries no trace of the retired hardcoded pin', async () => {
+ // Asserting the ABSENCE of the stale pin rather than the presence of a
+ // specific model: pinning this to today's choice would make this test
+ // the thing that has to be edited on every model upgrade.
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'some-other-model');
+ await svc.rewriteComment(REWRITE_INPUT);
+ expect(String(fetchMock.mock.calls[0]![0])).not.toContain('gemini-1.5-flash');
+ });
+
+ it('fails closed — rewriteComment throws when no model is configured', async () => {
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', '');
+ await expect(svc.rewriteComment(REWRITE_INPUT)).rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('fails closed — suggestComment throws rather than degrading to an empty list', async () => {
+ // suggestComment swallows RUNTIME failures into `[]`. A missing model is
+ // a configuration failure, not a runtime one: it must reach the caller
+ // as a 503 so the UI says "configure AI" instead of "no suggestions".
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', '');
+ await expect(svc.suggestComment({ itemName: 'Roof', sectionName: 'Roof' }))
+ .rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('fails closed in standalone too — the dev mock never covers a missing model', async () => {
+ // A self-hoster WITH a key but no model must get a clear error, not
+ // `[DEV] ...` placeholder prose silently written into a real report.
+ const svc = new AIService({} as D1Database, 'test-key', 'standalone', '');
+ await expect(svc.rewriteComment(REWRITE_INPUT)).rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('fails closed on the unguarded summary path as well', async () => {
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', '');
+ await expect(svc.generateProfessionalComment('rough note'))
+ .rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('still dev-mocks in standalone when there is no key at all', async () => {
+ // Unchanged behavior: the local-development mock is gated on the KEY
+ // being absent, and a missing model does not widen it.
+ const svc = new AIService({} as D1Database, '', 'standalone', '');
+ const out = await svc.rewriteComment(REWRITE_INPUT);
+ expect(out).toMatch(/^\[DEV\] /);
+ });
+});
From 8791c7ec304e0b1b4d319f5bb1c1038ca8abb87e Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 22:55:11 +0800
Subject: [PATCH 078/111] feat(ai): provider abstraction with managed and BYO
credential sources
Managed is a credential SOURCE on the same provider, not a second
implementation - otherwise every future platform has to be written twice, and
'so we can add platforms later' is the whole reason for the abstraction.
AiProvider carries no backend-specific concept; the Gemini URL, request
envelope and candidates/parts response shape now live in exactly one file,
which AIService.callGemini delegates to rather than keeping a second copy.
Standalone never resolves managed: absent, not disabled. The check reads a new
profile.hasManagedAi capability rather than APP_MODE, per deployment-profile's
own rule, and both that guard and the unprovisioned-platform-key guard were
confirmed to fail the suite when removed.
---
server/lib/ai/provider.ts | 45 ++++++++++++++
server/lib/ai/providers/gemini.ts | 60 +++++++++++++++++++
server/lib/ai/providers/recording.ts | 31 ++++++++++
server/lib/ai/resolve-provider.ts | 77 ++++++++++++++++++++++++
server/lib/deployment-profile.ts | 8 +++
server/services/ai.service.ts | 47 ++++-----------
tests/unit/ai/resolve-provider.spec.ts | 83 ++++++++++++++++++++++++++
7 files changed, 316 insertions(+), 35 deletions(-)
create mode 100644 server/lib/ai/provider.ts
create mode 100644 server/lib/ai/providers/gemini.ts
create mode 100644 server/lib/ai/providers/recording.ts
create mode 100644 server/lib/ai/resolve-provider.ts
create mode 100644 tests/unit/ai/resolve-provider.spec.ts
diff --git a/server/lib/ai/provider.ts b/server/lib/ai/provider.ts
new file mode 100644
index 000000000..c8ef9f2a1
--- /dev/null
+++ b/server/lib/ai/provider.ts
@@ -0,0 +1,45 @@
+/**
+ * AiProvider — the single contract every AI backend adapter satisfies.
+ *
+ * Modelled on `server/lib/email/provider.ts`. The interface is intentionally
+ * minimal and, most importantly, carries NO backend-specific concept: no
+ * "candidates", no "parts", no model-family naming, no vendor-shaped request
+ * envelope. That neutrality is the entire reason the interface exists — the
+ * moment one vendor's payload shape leaks into it, adding a second backend
+ * means rewriting every caller instead of adding one file under `providers/`.
+ *
+ * Note what is NOT here: credentials. A provider is constructed with the creds
+ * it needs; WHERE those creds came from (the tenant's own key, or a platform
+ * key) is a separate decision owned by `resolve-provider.ts`. Managed access is
+ * a credential SOURCE, not a second implementation.
+ */
+
+/** A single completion request. Sampling knobs are the ones every mainstream
+ * text backend exposes; anything vendor-specific belongs in the adapter. */
+export interface AiRequest {
+ /** The full prompt text. Prompt construction stays with the caller. */
+ prompt: string;
+ temperature?: number;
+ topP?: number;
+ topK?: number;
+ maxOutputTokens?: number;
+}
+
+/** A completion result. Trimmed text only — callers that need structure parse
+ * it themselves, exactly as they did against the raw HTTP response. */
+export interface AiResponse {
+ text: string;
+}
+
+export interface AiProvider {
+ /** Stable adapter id for logs and metering tags (e.g. `gemini`). */
+ readonly id: string;
+
+ /**
+ * Produce a completion. Implementations throw on transport/credential
+ * failure — unlike EmailProvider.sendEmail, there is no result-shape
+ * error channel here, because every existing AI call site already treats
+ * a throw as the failure path and none of them can proceed without text.
+ */
+ complete(input: AiRequest): Promise;
+}
diff --git a/server/lib/ai/providers/gemini.ts b/server/lib/ai/providers/gemini.ts
new file mode 100644
index 000000000..69ac9b109
--- /dev/null
+++ b/server/lib/ai/providers/gemini.ts
@@ -0,0 +1,60 @@
+import type { AiProvider, AiRequest, AiResponse } from '../provider';
+import { logger } from '../../logger';
+import { Errors } from '../../errors';
+
+/**
+ * Google Gemini adapter — the only place in the codebase that knows Gemini's
+ * URL shape, request envelope, or `candidates[].content.parts[].text` response.
+ *
+ * Credentials arrive already resolved (see `resolve-provider.ts`); this class
+ * is identical whether the key is the tenant's own or the platform's.
+ */
+export interface GeminiCreds {
+ apiKey: string;
+ /** Model id from deployment configuration. Empty = not configured, which
+ * fails closed — there is deliberately no default model in the source. */
+ model: string;
+}
+
+export class GeminiProvider implements AiProvider {
+ readonly id = 'gemini';
+
+ constructor(private creds: GeminiCreds) {}
+
+ async complete(input: AiRequest): Promise {
+ if (!this.creds.apiKey || this.creds.apiKey.includes('your_api_key')) {
+ throw new Error('Gemini API Key missing');
+ }
+ if (!this.creds.model) {
+ throw Errors.AINotConfigured(
+ 'AI is unavailable: no AI model is configured. Set AI_MODEL for this deployment.',
+ );
+ }
+
+ const res = await fetch(
+ `https://generativelanguage.googleapis.com/v1/models/${encodeURIComponent(this.creds.model)}:generateContent?key=${this.creds.apiKey}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: input.prompt }] }],
+ generationConfig: {
+ temperature: input.temperature ?? 0.2,
+ topP: input.topP ?? 0.8,
+ topK: input.topK ?? 40,
+ maxOutputTokens: input.maxOutputTokens ?? 1024,
+ },
+ }),
+ },
+ );
+
+ if (!res.ok) {
+ const error = await res.text();
+ logger.error('Gemini API Error', { response: error });
+ throw new Error('Failed to generate content from AI');
+ }
+
+ const data = await res.json() as { candidates: Array<{ content: { parts: Array<{ text: string }> } }> };
+ return { text: data.candidates[0].content.parts[0].text.trim() };
+ }
+}
diff --git a/server/lib/ai/providers/recording.ts b/server/lib/ai/providers/recording.ts
new file mode 100644
index 000000000..584ce7371
--- /dev/null
+++ b/server/lib/ai/providers/recording.ts
@@ -0,0 +1,31 @@
+import type { AiProvider, AiRequest, AiResponse } from '../provider';
+
+/**
+ * TEST-ONLY AI transport. Records every request and replays canned text
+ * instead of calling a backend, so a test can assert what a caller ASKED for
+ * without stubbing global `fetch` and without a network dependency.
+ *
+ * It also serves as the proof that `AiProvider` is genuinely backend-neutral:
+ * this class satisfies the whole interface in a dozen lines and mentions no
+ * vendor concept anywhere. If a future change to `AiProvider` cannot be
+ * implemented here, that change has leaked a backend detail into the contract.
+ *
+ * Mirrors `server/lib/email/providers/recording.ts` in intent. Unlike that one
+ * it is never wired into the worker — there is no AI equivalent of the E2E
+ * email sink, so this stays out of every production code path by construction.
+ */
+export class RecordingAiProvider implements AiProvider {
+ readonly id = 'recording';
+
+ /** Every request handed to `complete`, in order. */
+ readonly requests: AiRequest[] = [];
+
+ /** Canned replies, consumed in order; the last one repeats once exhausted. */
+ constructor(private replies: string[] = ['']) {}
+
+ async complete(input: AiRequest): Promise {
+ this.requests.push(input);
+ const text = this.replies.length > 1 ? this.replies.shift()! : (this.replies[0] ?? '');
+ return { text };
+ }
+}
diff --git a/server/lib/ai/resolve-provider.ts b/server/lib/ai/resolve-provider.ts
new file mode 100644
index 000000000..ec9894b00
--- /dev/null
+++ b/server/lib/ai/resolve-provider.ts
@@ -0,0 +1,77 @@
+/**
+ * AI provider resolution — decides WHICH credentials an AI call runs on, and
+ * whether it may run at all.
+ *
+ * A pure selection function, like `server/lib/email/resolve-provider.ts`: it
+ * does no I/O, so callers supply the already-read tenant key, entitlement and
+ * cap state. That keeps the whole policy readable in one screen and testable
+ * without a database.
+ *
+ * The rule, in order:
+ * 1. A tenant's OWN key always wins, in every deployment mode. BYOK is
+ * unchanged by the managed path and is never silently overridden.
+ * 2. Managed credentials exist only where there is a platform behind them
+ * (`profile.hasManagedAi`). A standalone deploy has no managed path at
+ * all — absent, not disabled.
+ * 3. Managed additionally requires an entitlement, headroom under the cap,
+ * and a platform key that is actually configured.
+ *
+ * `null` means the feature is OFF. Every caller already handles the
+ * not-configured shape (503 → "set up AI"), so reusing it gives one failure
+ * path instead of two, and a self-hoster never sees a quota error pointing at
+ * a billing portal that does not exist for them.
+ */
+import type { DeploymentProfile } from '../deployment-profile';
+import type { AiProvider } from './provider';
+import { GeminiProvider } from './providers/gemini';
+
+/** Where the credentials for a resolved call came from. Also selects the
+ * usage metric at the call site — platform-funded volume is metered apart
+ * from bring-your-own volume, the same split `policy.ts` documents for sends. */
+export type AiCredentialSource = 'managed' | 'byo';
+
+export interface ResolvedAi {
+ provider: AiProvider;
+ source: AiCredentialSource;
+}
+
+export interface ResolveAiContext {
+ /** Capability surface for this deployment. Never branch on `APP_MODE`. */
+ profile: DeploymentProfile;
+ /** The tenant's own stored key (Settings → Advanced → AI), or null. */
+ tenantKey: string | null;
+ /** Platform-provided key, when the deployment has one configured. */
+ managedKey?: string | null;
+ /** Whether this tenant is granted managed access. Supplied by the caller;
+ * OI receives a boolean and never learns what grants it. */
+ managedEntitled: boolean;
+ /** Whether the tenant is below its managed allowance. */
+ underCap: boolean;
+ /** Model id from deployment configuration. Passed through unchanged; an
+ * empty value fails closed inside the adapter, not with a default here. */
+ model?: string | null;
+}
+
+export function resolveAi(ctx: ResolveAiContext): ResolvedAi | null {
+ const model = ctx.model ?? '';
+
+ // 1. BYOK wins everywhere, including SaaS. A tenant who paid for their own
+ // key keeps using it; managed credentials never quietly take over.
+ if (ctx.tenantKey) {
+ return { provider: new GeminiProvider({ apiKey: ctx.tenantKey, model }), source: 'byo' };
+ }
+
+ // 2. Standalone has no managed path to offer. This check is the one that
+ // protects self-hosted deploys; do not "simplify" it away.
+ if (!ctx.profile.hasManagedAi) return null;
+
+ // 3. Entitlement, then headroom, then an actually-configured platform key.
+ // The last one is fail-closed: an entitled tenant on a deployment whose
+ // platform key was never provisioned gets the feature OFF, not a
+ // confusing runtime credential error mid-report.
+ if (!ctx.managedEntitled) return null;
+ if (!ctx.underCap) return null;
+ if (!ctx.managedKey) return null;
+
+ return { provider: new GeminiProvider({ apiKey: ctx.managedKey, model }), source: 'managed' };
+}
diff --git a/server/lib/deployment-profile.ts b/server/lib/deployment-profile.ts
index f26a2cca5..c316b9fe0 100644
--- a/server/lib/deployment-profile.ts
+++ b/server/lib/deployment-profile.ts
@@ -36,6 +36,12 @@ export interface DeploymentProfile {
aiDevMockFallback: boolean;
+ /** Whether a platform-provided AI credential may ever be resolved for a
+ * tenant. False in standalone: there is no platform behind a self-hosted
+ * deploy, so the managed path is ABSENT rather than disabled-by-default.
+ * Read this instead of branching on APP_MODE — see the file header. */
+ hasManagedAi: boolean;
+
brandingSource: 'env' | 'tenant-config';
}
@@ -48,6 +54,7 @@ export const STANDALONE_PROFILE: DeploymentProfile = {
loginRedirectBase: null,
hasSetupWizard: true,
aiDevMockFallback: true,
+ hasManagedAi: false,
brandingSource: 'env',
};
@@ -58,6 +65,7 @@ export const SAAS_PROFILE: DeploymentProfile = {
loginRedirectBase: null,
hasSetupWizard: false,
aiDevMockFallback: false,
+ hasManagedAi: true,
brandingSource: 'tenant-config',
};
diff --git a/server/services/ai.service.ts b/server/services/ai.service.ts
index 5ce6e39ab..e0eddda75 100644
--- a/server/services/ai.service.ts
+++ b/server/services/ai.service.ts
@@ -1,8 +1,8 @@
import { drizzle } from 'drizzle-orm/d1';
import { eq, and } from 'drizzle-orm';
import { inspections, inspectionResults } from '../lib/db/schema';
-import { logger } from '../lib/logger';
import { Errors } from '../lib/errors';
+import { GeminiProvider } from '../lib/ai/providers/gemini';
/**
* Service to handle AI-powered features using Google Gemini.
@@ -58,42 +58,19 @@ export class AIService {
}
/**
- * Internal helper to call Gemini API.
+ * Run one completion through the resolved provider.
+ *
+ * The Gemini HTTP shape lives in `lib/ai/providers/gemini.ts` and nowhere
+ * else. Keeping a second copy here would mean every future backend gets
+ * written twice, which is the exact cost the abstraction exists to avoid.
+ * Credential and model validation (including the fail-closed empty-model
+ * case) is the adapter's, so the two entry points below that do not
+ * pre-check are still covered.
*/
private async callGemini(prompt: string) {
- if (!this.apiKey || this.apiKey.includes('your_api_key')) {
- throw new Error('Gemini API Key missing');
- }
- // Backstop for the two entry points that do not pre-check
- // (generateProfessionalComment / generateInspectionSummary).
- this.assertModelConfigured();
-
- const res = await fetch(`https://generativelanguage.googleapis.com/v1/models/${encodeURIComponent(this.model)}:generateContent?key=${this.apiKey}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- contents: [{
- parts: [{ text: prompt }]
- }],
- generationConfig: {
- temperature: 0.2,
- topP: 0.8,
- topK: 40,
- maxOutputTokens: 1024,
- }
- })
- });
-
- if (!res.ok) {
- const error = await res.text();
- logger.error('Gemini API Error', { response: error });
- throw new Error('Failed to generate content from AI');
- }
-
- const data = await res.json() as { candidates: Array<{ content: { parts: Array<{ text: string }> } }> };
- return data.candidates[0].content.parts[0].text.trim();
+ const provider = new GeminiProvider({ apiKey: this.apiKey, model: this.model });
+ const { text } = await provider.complete({ prompt });
+ return text;
}
/**
diff --git a/tests/unit/ai/resolve-provider.spec.ts b/tests/unit/ai/resolve-provider.spec.ts
new file mode 100644
index 000000000..f7eec0825
--- /dev/null
+++ b/tests/unit/ai/resolve-provider.spec.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest';
+import { resolveAi } from '../../../server/lib/ai/resolve-provider';
+import { RecordingAiProvider } from '../../../server/lib/ai/providers/recording';
+import type { AiProvider } from '../../../server/lib/ai/provider';
+import { SAAS_PROFILE, STANDALONE_PROFILE } from '../../../server/lib/deployment-profile';
+
+/**
+ * Credential-source resolution for AI calls.
+ *
+ * `resolveAi` owns one decision — which key an AI call runs on, or whether it
+ * runs at all. Every branch below is a rule someone could plausibly "simplify"
+ * away, so each is pinned by a test that fails loudly if it disappears.
+ */
+describe('resolveAi', () => {
+ const SAAS = SAAS_PROFILE;
+ const STANDALONE = STANDALONE_PROFILE;
+ const base = { managedKey: 'platform-key', model: 'a-model' };
+
+ it('prefers the tenant own key', () => {
+ const r = resolveAi({ ...base, profile: SAAS, tenantKey: 'k', managedEntitled: true, underCap: true });
+ expect(r).toMatchObject({ source: 'byo' });
+ });
+
+ it('prefers the tenant own key even when managed is fully available', () => {
+ // BYOK is never silently overridden: a tenant who configured a key
+ // keeps spending on it, and the platform never takes over the bill.
+ const r = resolveAi({ ...base, profile: SAAS, tenantKey: 'k', managedEntitled: true, underCap: true });
+ expect(r?.source).toBe('byo');
+ });
+
+ it('uses managed when the tenant has no key, is entitled, and is under cap', () => {
+ const r = resolveAi({ ...base, profile: SAAS, tenantKey: null, managedEntitled: true, underCap: true });
+ expect(r).toMatchObject({ source: 'managed' });
+ });
+
+ it('returns null — feature OFF — when over cap', () => {
+ // Not "degraded", not "silently English": the caller already handles the
+ // not-configured shape, and reusing it means one failure path, not two.
+ expect(resolveAi({ ...base, profile: SAAS, tenantKey: null, managedEntitled: true, underCap: false })).toBeNull();
+ });
+
+ it('returns null when the tenant is not entitled to managed', () => {
+ expect(resolveAi({ ...base, profile: SAAS, tenantKey: null, managedEntitled: false, underCap: true })).toBeNull();
+ });
+
+ it('never offers managed in standalone', () => {
+ // Absent, not disabled: a self-hosted deploy has no platform to bill.
+ expect(resolveAi({ ...base, profile: STANDALONE, tenantKey: null, managedEntitled: true, underCap: true })).toBeNull();
+ });
+
+ it('still resolves BYO in standalone — the profile check gates only managed', () => {
+ // The standalone guard must not become "AI is off in standalone".
+ const r = resolveAi({ ...base, profile: STANDALONE, tenantKey: 'k', managedEntitled: false, underCap: true });
+ expect(r).toMatchObject({ source: 'byo' });
+ });
+
+ it('fails closed when the deployment has no managed key provisioned', () => {
+ // An entitlement with nothing behind it is OFF, not a runtime credential
+ // error surfacing halfway through a report.
+ const r = resolveAi({ profile: SAAS, tenantKey: null, managedKey: null, managedEntitled: true, underCap: true, model: 'a-model' });
+ expect(r).toBeNull();
+ });
+
+ it('passes the configured model through and adds no default of its own', () => {
+ // The resolver must not invent a model to paper over missing config;
+ // the adapter fails closed on an empty one (see model-config.spec).
+ const r = resolveAi({ profile: SAAS, tenantKey: 'k', managedEntitled: false, underCap: true, model: '' });
+ expect(r?.provider.id).toBe('gemini');
+ return expect(r!.provider.complete({ prompt: 'x' })).rejects.toThrow(/no AI model is configured/i);
+ });
+});
+
+describe('AiProvider contract', () => {
+ it('is satisfiable without any backend-specific concept', async () => {
+ // The recording double implements the whole interface and mentions no
+ // vendor shape. If this ever stops compiling, the contract has leaked
+ // a backend detail and a second backend just became a rewrite.
+ const provider: AiProvider = new RecordingAiProvider(['hello']);
+ const out = await provider.complete({ prompt: 'ask', temperature: 0.1 });
+ expect(out.text).toBe('hello');
+ expect((provider as RecordingAiProvider).requests[0]).toMatchObject({ prompt: 'ask', temperature: 0.1 });
+ });
+});
From 7bdb45962ed99cf520ad2d5fa5bed5a8e30a759c Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 23:14:57 +0800
Subject: [PATCH 079/111] feat(quota): meter managed AI as translate/assist,
free tier stays BYOK
Two metrics, not one: roughly one translation per report against tens of
assist calls per inspection. A shared cap would force a choice between an
unusable assistant and an uncapped translation budget. Each is split
platform/bring-your-own, the same split policy.ts already documents for sends.
NOT a second meter. It writes through the existing MeteringService into
usage_counters under the same period key, wired at one chokepoint - the single
method every AI feature funnels through - exactly as the unified email
interface is the one metering gate for sends. The credential tag comes from
resolveAi, the same resolver the runtime runs on, so the counter written and
the counter checked cannot drift apart.
Meter AFTER success: a failed model call must not consume an allowance it did
not spend, and a metering failure must not fail the inspector's operation.
Both are asserted, and both assertions were confirmed to fail when the
behavior is removed.
FREE_TIER_CAPS is deliberately unchanged - the free tier's limit is having no
managed path at all, which costs the deployment nothing and needs no meter.
checkAiQuota mirrors checkMessagingQuota and reads per-tier allowances supplied
as configuration; none are configured, so it is a no-op today. Metering ships
before enforcement: a number chosen now would be invented. The tests pair every
'does not block' case with a configured-cap control, because 'resolves' proves
nothing against a guard that cannot read the meter at all.
The usage_counters column enum needed the four new values (type-layer only, no
DDL - db:check confirms zero drift). Its 'keep in sync with UsageMetric'
coupling is now executable in both directions rather than a comment.
---
scripts/file-size-baseline.json | 2 +-
server/api/usage.ts | 20 ++-
server/features/plan-quota/guard.ts | 31 +++-
server/features/plan-quota/policy.ts | 20 +++
server/lib/ai/metering.ts | 57 ++++++
server/lib/db/schema/usage.ts | 13 +-
server/lib/middleware/di.ts | 7 +
server/lib/usage/period.ts | 28 ++-
server/services/ai.service.ts | 14 +-
server/types/hono.ts | 4 +
tests/unit/usage/ai-quota.spec.ts | 194 +++++++++++++++++++++
tests/unit/usage/usage-schema.spec.ts | 28 +++
tests/unit/usage/usage-summary-api.spec.ts | 9 +
13 files changed, 418 insertions(+), 9 deletions(-)
create mode 100644 server/lib/ai/metering.ts
create mode 100644 tests/unit/usage/ai-quota.spec.ts
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 2ae0fbab0..356dd9ea2 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -58,9 +58,9 @@
"app/lib/collab/results-doc-connection.ts": 435,
"server/api/inspections/media.ts": 435,
"app/components/media-studio/VideoCapture.tsx": 433,
+ "server/lib/middleware/di.ts": 432,
"app/routes/public/portal-inspection.tsx": 430,
"server/api/inspections/results.ts": 430,
- "server/lib/middleware/di.ts": 425,
"app/hooks/useStructureEdit.ts": 424,
"app/routes/templates.tsx": 414,
"app/routes/calendar.tsx": 410,
diff --git a/server/api/usage.ts b/server/api/usage.ts
index 577c8d87c..f3ba3b5f6 100644
--- a/server/api/usage.ts
+++ b/server/api/usage.ts
@@ -2,8 +2,9 @@
* Tenant-scoped usage summary read API.
*
* `GET /api/usage/summary` returns the current tenant's cumulative usage
- * across every metered dimension (inspections, sms/email — platform and
- * bring-your-own — plus the storage gauge and seat usage) and, for a free
+ * across every metered dimension (inspections, sms/email and AI
+ * translate/assist — platform and bring-your-own for each — plus the storage
+ * gauge and seat usage) and, for a free
* tenant on a deployment that enforces the free-tier quota (`profile.
* hasUsageQuota`), the caps those platform metrics are measured against.
* `caps` is null for every other tenant/deployment — the UI hides progress
@@ -43,13 +44,24 @@ const usageRoutes = createApiRouter()
if (!tenantId) throw Errors.Unauthorized();
const metering = new MeteringService(c.env.DB);
- const [inspections, sms, email, smsByo, emailByo, r2Bytes, seatUsage, tier] = await Promise.all([
+ const [
+ inspections, sms, email, smsByo, emailByo, r2Bytes,
+ aiTranslate, aiTranslateByo, aiAssist, aiAssistByo,
+ seatUsage, tier,
+ ] = await Promise.all([
metering.lifetimeTotal(tenantId, 'inspections'),
metering.lifetimeTotal(tenantId, 'sms'),
metering.lifetimeTotal(tenantId, 'email'),
metering.lifetimeTotal(tenantId, 'sms_byo'),
metering.lifetimeTotal(tenantId, 'email_byo'),
metering.lifetimeTotal(tenantId, 'r2_bytes'),
+ // AI is reported platform/bring-your-own separately for the same
+ // reason sends are: only platform-funded volume is ever something
+ // this deployment could cap.
+ metering.lifetimeTotal(tenantId, 'ai_translate'),
+ metering.lifetimeTotal(tenantId, 'ai_translate_byo'),
+ metering.lifetimeTotal(tenantId, 'ai_assist'),
+ metering.lifetimeTotal(tenantId, 'ai_assist_byo'),
getSeatUsage(tenantId, c.env.DB),
readTenantTier(c.env.DB, tenantId),
]);
@@ -64,6 +76,8 @@ const usageRoutes = createApiRouter()
usage: {
inspections, sms, email,
smsByo, emailByo,
+ aiTranslate, aiTranslateByo,
+ aiAssist, aiAssistByo,
seatsUsed: seatUsage.used,
seatsMax: seatUsage.max,
r2Bytes,
diff --git a/server/features/plan-quota/guard.ts b/server/features/plan-quota/guard.ts
index d400609e1..c4ad24f4c 100644
--- a/server/features/plan-quota/guard.ts
+++ b/server/features/plan-quota/guard.ts
@@ -4,7 +4,7 @@ import { tenants } from '../../lib/db/schema';
import { MeteringService } from '../../services/metering.service';
import { STOCK_PERIOD } from '../../lib/usage/period';
import { Errors } from '../../lib/errors';
-import { FREE_TIER_CAPS } from './policy';
+import { FREE_TIER_CAPS, type AiCappedMetric, type AiTierCaps } from './policy';
/**
* Free-tier usage-quota guard. Two calling shapes:
@@ -34,7 +34,13 @@ export async function readTenantTier(db: D1Database, tenantId: string): Promise<
export class PlanQuotaGuard {
constructor(
private db: D1Database,
- private opts: { enforced: boolean; billingPortalUrl: string | null },
+ private opts: {
+ enforced: boolean;
+ billingPortalUrl: string | null;
+ /** Per-tier AI allowances, when the deployment has been given any.
+ * Absent/empty means no AI enforcement — see `checkAiQuota`. */
+ aiCaps?: AiTierCaps;
+ },
) {}
/** Atomic consume for inspection creation. Free+enforced: increment-if-below-cap
@@ -77,4 +83,25 @@ export class PlanQuotaGuard {
const cap = FREE_TIER_CAPS[metric];
if (used >= cap) throw Errors.QuotaExhausted({ metric, used, cap, billingPortalUrl: this.opts.billingPortalUrl });
}
+
+ /** Pre-flight check for a MANAGED (platform-funded) AI call. Same shape as
+ * `checkMessagingQuota` and for the same reason: read-only, so the counter
+ * increment stays at the single AI call-site meter and a failed model call
+ * never consumes an allowance it did not spend. AI calls fail more often
+ * than sends, which makes that ordering matter more here, not less.
+ *
+ * No-op when enforcement is off (standalone) and no-op when no cap has been
+ * configured for the tier — which is every tier today. Metering ships before
+ * enforcement on purpose: this path exists and is tested, and the number
+ * arrives later as configuration rather than as an invented literal.
+ *
+ * `metric` is always a managed metric: `*_byo` volume is the tenant's own
+ * bill and never counts toward anything this guard enforces. */
+ async checkAiQuota(tenantId: string, tier: string, metric: AiCappedMetric): Promise {
+ if (!this.opts.enforced) return;
+ const cap = this.opts.aiCaps?.[tier]?.[metric];
+ if (cap === undefined) return;
+ const used = await new MeteringService(this.db).lifetimeTotal(tenantId, metric);
+ if (used >= cap) throw Errors.QuotaExhausted({ metric, used, cap, billingPortalUrl: this.opts.billingPortalUrl });
+ }
}
diff --git a/server/features/plan-quota/policy.ts b/server/features/plan-quota/policy.ts
index 1b7825e9d..7efc9a4de 100644
--- a/server/features/plan-quota/policy.ts
+++ b/server/features/plan-quota/policy.ts
@@ -3,4 +3,24 @@
* volume is uncapped and metered separately under the `sms_byo`/`email_byo`
* metrics. Spec: free-tier usage quotas (2026-07).
*/
+/** No `ai_*` entry, deliberately: the free tier has no MANAGED AI path to cap —
+ * it is bring-your-own-key only, which costs the deployment nothing and needs
+ * no meter. Capping a thing is more machinery than not offering it. An absent
+ * entry here looks like an oversight, so: it is not one. */
export const FREE_TIER_CAPS = { inspections: 5, sms: 50, email: 50 } as const;
+
+/** The AI metrics a cap can be expressed against. Only the managed (platform-
+ * funded) side appears: `*_byo` volume is the tenant's own bill, so there is
+ * nothing for this deployment to limit. */
+export type AiCappedMetric = 'ai_translate' | 'ai_assist';
+
+/**
+ * Per-tier AI allowances, keyed by tier then metric.
+ *
+ * Empty by construction and supplied at runtime, NOT hardcoded here: any number
+ * chosen before real usage data exists would be invented, and a wrong number
+ * silently blocks every tenant on that tier. An absent entry means "no cap
+ * configured", which the guard reads as no enforcement — metering still runs,
+ * so the number can be set the day one is justified rather than guessed today.
+ */
+export type AiTierCaps = Readonly>>>;
diff --git a/server/lib/ai/metering.ts b/server/lib/ai/metering.ts
new file mode 100644
index 000000000..ea5264fb5
--- /dev/null
+++ b/server/lib/ai/metering.ts
@@ -0,0 +1,57 @@
+/**
+ * The AI usage meter, built once per request and injected into AIService.
+ *
+ * Deliberately NOT a second metering system: it writes through the same
+ * `MeteringService` into the same `usage_counters` table under the same
+ * `currentPeriodKey` bucket as sms/email, and it is wired at exactly one
+ * chokepoint — the single method every AI feature funnels through. The email
+ * pipeline learned this the expensive way: one unified interface is the meter,
+ * and any counter added beside it becomes a second number that must agree with
+ * the first and eventually doesn't.
+ *
+ * The credential source that tags the metric comes from `resolveAi` — the same
+ * resolver the runtime uses to decide which key the call runs on — rather than
+ * from a separate "is this managed?" test that could disagree with it.
+ */
+import { MeteringService } from '../../services/metering.service';
+import { aiUsageMetric, currentPeriodKey, type AiUsageKind } from '../usage/period';
+import { resolveAi } from './resolve-provider';
+import type { DeploymentProfile } from '../deployment-profile';
+
+export interface AiMeter {
+ record(kind: AiUsageKind): Promise;
+}
+
+export function buildAiMeter(args: {
+ db: D1Database;
+ profile: DeploymentProfile;
+ tenantId: string | null;
+ tenantKey: string | null;
+ managedKey: string | null;
+ model: string;
+}): AiMeter | undefined {
+ const { db, tenantId } = args;
+ // No tenant to attribute usage to (public/unauthenticated paths): no meter,
+ // rather than a row nobody can bill, explain, or delete.
+ if (!tenantId) return undefined;
+
+ const resolved = resolveAi({
+ profile: args.profile,
+ tenantKey: args.tenantKey,
+ managedKey: args.managedKey,
+ // Entitlement is delivered as configuration, not decided here. Until it
+ // arrives no tenant resolves managed, so managed metrics simply have no
+ // producer yet — the meter is correct either way, and flipping this to
+ // a real value is the only change needed on that day. AIService must
+ // take the RESOLVED PROVIDER at that point rather than a raw key.
+ managedEntitled: false,
+ underCap: true,
+ model: args.model,
+ });
+ const source = resolved?.source ?? 'byo';
+
+ const metering = new MeteringService(db);
+ return {
+ record: (kind) => metering.record(tenantId, aiUsageMetric(kind, source), currentPeriodKey(new Date())),
+ };
+}
diff --git a/server/lib/db/schema/usage.ts b/server/lib/db/schema/usage.ts
index e03d9a701..9c0f89e02 100644
--- a/server/lib/db/schema/usage.ts
+++ b/server/lib/db/schema/usage.ts
@@ -8,10 +8,21 @@ import { sqliteTable, text, integer, primaryKey, index } from 'drizzle-orm/sqlit
* period_key = 'lifetime', overwritten by the daily measurement job.
* `sms_byo`/`email_byo` count sends made through a tenant's own credentials
* (bring-your-own), tracked separately from platform-metered `sms`/`email`.
+ * `ai_translate`/`ai_assist` carry the same split for AI work, and are two
+ * metrics rather than one because their cost profiles differ by an order of
+ * magnitude — roughly one translation per report against tens of assist calls
+ * per inspection, so a single counter could not govern both.
+ *
+ * The enum is type-layer only (no DDL), so adding a metric needs no migration —
+ * it must nonetheless stay in step with `UsageMetric` in `lib/usage/period.ts`,
+ * which `tests/unit/usage/usage-schema.spec.ts` asserts.
*/
export const usageCounters = sqliteTable('usage_counters', {
tenantId: text('tenant_id').notNull(),
- metric: text('metric', { enum: ['sms', 'email', 'r2_bytes', 'inspections', 'sms_byo', 'email_byo'] }).notNull(),
+ metric: text('metric', { enum: [
+ 'sms', 'email', 'r2_bytes', 'inspections', 'sms_byo', 'email_byo',
+ 'ai_translate', 'ai_translate_byo', 'ai_assist', 'ai_assist_byo',
+ ] }).notNull(),
periodKey: text('period_key').notNull(),
value: integer('value').notNull().default(0),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts
index 388529fc4..755ba4bf4 100644
--- a/server/lib/middleware/di.ts
+++ b/server/lib/middleware/di.ts
@@ -6,6 +6,7 @@ import { UnitService } from '../../services/unit.service';
import { UnitSwitchService } from '../../services/unit-switch.service';
import { ReportVersionService } from '../../services/report-version.service';
import { AIService } from '../../services/ai.service';
+import { buildAiMeter } from '../ai/metering';
import { AuthService } from '../../services/auth.service';
import { OutboxService } from '../../portal/outbox.service';
import { publishRow } from '../../portal/outbox.service';
@@ -172,6 +173,12 @@ export async function diMiddleware(c: Context, next: Next) {
// No default here on purpose: an unset AI_MODEL fails
// closed at the service rather than picking a model.
c.env.AI_MODEL ?? '',
+ buildAiMeter({
+ db: c.env.DB, profile: c.var.profile, tenantId,
+ tenantKey: emailCfg.dbSecrets.geminiApiKey || null,
+ managedKey: c.env.AI_MANAGED_API_KEY ?? null,
+ model: c.env.AI_MODEL ?? '',
+ }),
);
break;
case 'auth':
diff --git a/server/lib/usage/period.ts b/server/lib/usage/period.ts
index e0404aa35..afeed55f4 100644
--- a/server/lib/usage/period.ts
+++ b/server/lib/usage/period.ts
@@ -1,5 +1,31 @@
-export type UsageMetric = 'sms' | 'email' | 'r2_bytes' | 'inspections' | 'sms_byo' | 'email_byo';
+export type UsageMetric =
+ | 'sms' | 'email' | 'r2_bytes' | 'inspections'
+ | 'sms_byo' | 'email_byo'
+ | 'ai_translate' | 'ai_translate_byo'
+ | 'ai_assist' | 'ai_assist_byo';
/** Calendar-month bucket key, UTC. Flows (sms/email) use this. */
export function currentPeriodKey(now: Date): string { return now.toISOString().slice(0, 7); }
/** Sentinel period for stock metrics (r2_bytes), overwritten rather than summed. */
export const STOCK_PERIOD = 'lifetime';
+
+/**
+ * The two AI workloads, metered separately because their cost profiles differ
+ * by an order of magnitude: roughly one translation per report against tens of
+ * assist calls per inspection. One shared metric would force a choice between
+ * an unusable assistant and an effectively uncapped translation budget.
+ */
+export type AiUsageKind = 'translate' | 'assist';
+
+/** Where the credentials for a call came from — the same `*_byo` split
+ * `plan-quota/policy.ts` already documents for sends. Platform-funded volume
+ * is what a cap can ever be about; bring-your-own volume is the tenant's own
+ * bill and is counted for analytics only. */
+export type AiCredentialSourceTag = 'managed' | 'byo';
+
+/** Map (workload, credential source) to the metric it accumulates under. The
+ * single mapping both the recorder and the guard read, so the counter that
+ * gets written and the counter that gets checked can never drift apart. */
+export function aiUsageMetric(kind: AiUsageKind, source: AiCredentialSourceTag): UsageMetric {
+ if (kind === 'translate') return source === 'managed' ? 'ai_translate' : 'ai_translate_byo';
+ return source === 'managed' ? 'ai_assist' : 'ai_assist_byo';
+}
diff --git a/server/services/ai.service.ts b/server/services/ai.service.ts
index e0eddda75..e90c14405 100644
--- a/server/services/ai.service.ts
+++ b/server/services/ai.service.ts
@@ -3,6 +3,7 @@ import { eq, and } from 'drizzle-orm';
import { inspections, inspectionResults } from '../lib/db/schema';
import { Errors } from '../lib/errors';
import { GeminiProvider } from '../lib/ai/providers/gemini';
+import type { AiUsageKind } from '../lib/usage/period';
/**
* Service to handle AI-powered features using Google Gemini.
@@ -26,6 +27,12 @@ export class AIService {
/** Model id from deployment configuration (`AI_MODEL`). Empty = not
* configured, which is an error rather than a cue to pick one. */
private model: string = '',
+ /** The ONE metering hook for AI, injected the same way the email
+ * pipeline injects its meter. Every AI feature funnels through
+ * `callGemini`, so one `record` there is the whole meter — a second
+ * counter at a route or a hook is how two numbers that have to agree
+ * stop agreeing. Undefined when there is no tenant to attribute to. */
+ private meter?: { record(kind: AiUsageKind): Promise },
) {}
private isDevMode(): boolean {
@@ -67,9 +74,14 @@ export class AIService {
* case) is the adapter's, so the two entry points below that do not
* pre-check are still covered.
*/
- private async callGemini(prompt: string) {
+ private async callGemini(prompt: string, kind: AiUsageKind = 'assist') {
const provider = new GeminiProvider({ apiKey: this.apiKey, model: this.model });
const { text } = await provider.complete({ prompt });
+ // Meter AFTER success, never before — a model call that failed must not
+ // consume an allowance it did not spend. The swallowed rejection
+ // matches the send sites: a metering failure must never fail the
+ // inspector's operation.
+ if (this.meter) await this.meter.record(kind).catch(() => {});
return text;
}
diff --git a/server/types/hono.ts b/server/types/hono.ts
index 8f72c981c..021dda476 100644
--- a/server/types/hono.ts
+++ b/server/types/hono.ts
@@ -63,6 +63,10 @@ export interface AppEnv {
/** AI model id (e.g. a Gemini model name). No default is compiled in —
* when unset, AI features fail closed rather than picking a model. */
AI_MODEL?: string;
+ /** Optional deployment-provided AI key, used only for tenants the
+ * deployment grants managed access to. Absent in standalone, where a
+ * tenant's own key is the only credential source. */
+ AI_MANAGED_API_KEY?: string;
// Communication
RESEND_API_KEY: string;
diff --git a/tests/unit/usage/ai-quota.spec.ts b/tests/unit/usage/ai-quota.spec.ts
new file mode 100644
index 000000000..5a952c31e
--- /dev/null
+++ b/tests/unit/usage/ai-quota.spec.ts
@@ -0,0 +1,194 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { AIService } from '../../../server/services/ai.service';
+import { createTestDb, setupSchema, toRawD1 } from '../db';
+import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import * as schema from '../../../server/lib/db/schema';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { PlanQuotaGuard } from '../../../server/features/plan-quota/guard';
+import { FREE_TIER_CAPS } from '../../../server/features/plan-quota/policy';
+import { MeteringService } from '../../../server/services/metering.service';
+import { aiUsageMetric } from '../../../server/lib/usage/period';
+import { resolveAi } from '../../../server/lib/ai/resolve-provider';
+import { SAAS_PROFILE } from '../../../server/lib/deployment-profile';
+
+/**
+ * Managed-AI metering and its (currently unconfigured) enforcement path.
+ *
+ * The load-bearing risk here is a false green: with no cap configured, every
+ * `checkAiQuota` call resolves, so a suite that only asserts "resolves" would
+ * pass just as happily against a guard that cannot read the meter at all.
+ * Each no-block assertion below is therefore paired with a configured-cap case
+ * proving the guard DOES see the counter it claims to be ignoring.
+ */
+describe('AI quota + metering', () => {
+ let testDb: BetterSQLite3Database;
+ let testD1: D1Database;
+ const T = 'tenant-ai';
+
+ beforeEach(async () => {
+ const setup = createTestDb();
+ testDb = setup.db;
+ await setupSchema(setup.sqlite);
+ (mockDrizzle as never as { mockReturnValue: (v: unknown) => void }).mockReturnValue(testDb);
+ testD1 = toRawD1(setup.sqlite);
+ });
+
+ /** Seed in an ADVERSE order — the metric under test written last, and
+ * interleaved across period buckets — so no assertion can pass by reading
+ * whichever row happens to come back first. */
+ async function seedAdversely(m: MeteringService) {
+ await m.record(T, 'ai_translate_byo', '2026-06', 9_000);
+ await m.record(T, 'ai_assist', '2026-07', 7_000);
+ await m.record(T, 'ai_assist_byo', '2026-06', 8_000);
+ await m.record(T, 'ai_translate', '2026-05', 4_000);
+ await m.record(T, 'ai_translate', '2026-07', 6_000);
+ }
+
+ describe('metric selection', () => {
+ it('splits translate and assist, and platform from bring-your-own', () => {
+ expect(aiUsageMetric('translate', 'managed')).toBe('ai_translate');
+ expect(aiUsageMetric('translate', 'byo')).toBe('ai_translate_byo');
+ expect(aiUsageMetric('assist', 'managed')).toBe('ai_assist');
+ expect(aiUsageMetric('assist', 'byo')).toBe('ai_assist_byo');
+ });
+
+ it('tags the metric from the same resolver the runtime runs on', () => {
+ // Not a second "is this managed?" test that could disagree with the
+ // credential actually used.
+ const r = resolveAi({ profile: SAAS_PROFILE, tenantKey: 'own-key', managedKey: 'plat', managedEntitled: true, underCap: true, model: 'm' });
+ expect(aiUsageMetric('assist', r!.source)).toBe('ai_assist_byo');
+ });
+ });
+
+ describe('checkAiQuota', () => {
+ it('meters paid managed usage without blocking it — no cap is configured', async () => {
+ // Metering ships before enforcement: any cap chosen today would be
+ // invented, and the metric is cheap to record and expensive to guess.
+ await seedAdversely(new MeteringService(testD1));
+ const g = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null });
+ await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).resolves.toBeUndefined();
+ await expect(g.checkAiQuota(T, 'pro', 'ai_assist')).resolves.toBeUndefined();
+ });
+
+ it('DOES block once a cap is configured — proving the guard can read the meter', async () => {
+ // The control for the test above. Without this, "resolves" proves
+ // nothing: a guard wired to the wrong table would also resolve.
+ await seedAdversely(new MeteringService(testD1));
+ const g = new PlanQuotaGuard(testD1, {
+ enforced: true, billingPortalUrl: 'https://x/billing',
+ aiCaps: { pro: { ai_translate: 10_000 } },
+ });
+ await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).rejects.toMatchObject({
+ status: 402,
+ code: 'QUOTA_EXHAUSTED',
+ // 4_000 + 6_000 across two period buckets — the lifetime total,
+ // not whichever bucket sorted first.
+ details: { metric: 'ai_translate', used: 10_000, cap: 10_000 },
+ });
+ });
+
+ it('never counts BYO usage against a configured cap', async () => {
+ // 9_000 of BYO translate volume against a cap of 10 — this can only
+ // pass if the guard reads `ai_translate`, not `ai_translate_byo`.
+ await new MeteringService(testD1).record(T, 'ai_translate_byo', '2026-06', 9_000);
+ const g = new PlanQuotaGuard(testD1, {
+ enforced: true, billingPortalUrl: null, aiCaps: { pro: { ai_translate: 10 } },
+ });
+ await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).resolves.toBeUndefined();
+ });
+
+ it('keeps translate and assist independent', async () => {
+ // A shared metric would force one cap to govern two workloads whose
+ // cost profiles differ by an order of magnitude.
+ await seedAdversely(new MeteringService(testD1));
+ const g = new PlanQuotaGuard(testD1, {
+ enforced: true, billingPortalUrl: null, aiCaps: { pro: { ai_assist: 100 } },
+ });
+ await expect(g.checkAiQuota(T, 'pro', 'ai_assist')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' });
+ await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).resolves.toBeUndefined();
+ });
+
+ it('applies a cap only to the tier it was configured for', async () => {
+ await seedAdversely(new MeteringService(testD1));
+ const g = new PlanQuotaGuard(testD1, {
+ enforced: true, billingPortalUrl: null, aiCaps: { pro: { ai_translate: 10 } },
+ });
+ await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' });
+ await expect(g.checkAiQuota(T, 'enterprise', 'ai_translate')).resolves.toBeUndefined();
+ });
+
+ it('does not block when enforcement is off (standalone), even with a cap', async () => {
+ await seedAdversely(new MeteringService(testD1));
+ const g = new PlanQuotaGuard(testD1, {
+ enforced: false, billingPortalUrl: null, aiCaps: { pro: { ai_translate: 1 } },
+ });
+ await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).resolves.toBeUndefined();
+ });
+ });
+
+ describe('call-site metering', () => {
+ const fetchMock = vi.fn();
+ let originalFetch: typeof globalThis.fetch;
+
+ beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
+ fetchMock.mockReset();
+ });
+ afterEach(() => { globalThis.fetch = originalFetch; });
+
+ function service(meter?: { record(kind: 'translate' | 'assist'): Promise }) {
+ return new AIService({} as D1Database, 'a-key', 'saas', 'a-model', meter);
+ }
+
+ it('records exactly once per successful call, tagged by workload', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true, json: async () => ({ candidates: [{ content: { parts: [{ text: 'x' }] } }] }),
+ } as Response);
+ const record = vi.fn(async () => {});
+ await service({ record }).generateProfessionalComment('note');
+ expect(record).toHaveBeenCalledTimes(1);
+ expect(record).toHaveBeenCalledWith('assist');
+ });
+
+ it('does NOT meter a failed model call', async () => {
+ // Meter after success, check before: a provider failure must never
+ // consume an allowance it did not spend. AI calls fail more often
+ // than sends, so this ordering matters more here, not less.
+ fetchMock.mockResolvedValue({ ok: false, text: async () => 'rate limited' } as Response);
+ const record = vi.fn(async () => {});
+ await expect(service({ record }).generateProfessionalComment('note')).rejects.toThrow();
+ expect(record).not.toHaveBeenCalled();
+ });
+
+ it('a metering failure never fails the inspector operation', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true, json: async () => ({ candidates: [{ content: { parts: [{ text: 'kept' }] } }] }),
+ } as Response);
+ const record = vi.fn(async () => { throw new Error('d1 down'); });
+ await expect(service({ record }).generateProfessionalComment('note')).resolves.toBe('kept');
+ });
+ });
+
+ describe('the free tier', () => {
+ it('never offers managed AI at all — so there is nothing to cap', () => {
+ // The free tier's boundary, expressed where it is actually enforced:
+ // no entitlement, hence no managed credential, hence no platform
+ // cost and no quota machinery.
+ expect(resolveAi({
+ profile: SAAS_PROFILE, tenantKey: null, managedKey: 'plat',
+ managedEntitled: false, underCap: true, model: 'm',
+ })).toBeNull();
+ });
+
+ it('carries no ai_* entry in FREE_TIER_CAPS', () => {
+ // Assert on the ABSENCE of the keys, not on the object's value:
+ // an equality check would still pass if a cap were added under a
+ // name this test does not mention.
+ expect(Object.keys(FREE_TIER_CAPS).some(k => k.startsWith('ai_'))).toBe(false);
+ });
+ });
+});
diff --git a/tests/unit/usage/usage-schema.spec.ts b/tests/unit/usage/usage-schema.spec.ts
index cfcb237ce..6fe312f3b 100644
--- a/tests/unit/usage/usage-schema.spec.ts
+++ b/tests/unit/usage/usage-schema.spec.ts
@@ -2,6 +2,22 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { createTestDb, setupSchema } from '../db';
import { usageCounters } from '../../../server/lib/db/schema/usage';
+import type { UsageMetric } from '../../../server/lib/usage/period';
+
+/**
+ * The metric list exists twice — as the column's drizzle enum and as the
+ * `UsageMetric` union — and the two must agree or a recorded metric becomes
+ * unreadable through the typed handle. Executable coupling instead of a
+ * "keep these in sync" comment: the Record below fails TYPE-CHECK if the union
+ * grows a member it does not name, and the equality below fails at RUNTIME if
+ * the column enum drifts from it.
+ */
+const UNION_METRICS: Record = {
+ sms: true, email: true, r2_bytes: true, inspections: true,
+ sms_byo: true, email_byo: true,
+ ai_translate: true, ai_translate_byo: true,
+ ai_assist: true, ai_assist_byo: true,
+};
describe('usage_counters schema', () => {
let testDb: ReturnType['db'];
@@ -15,6 +31,18 @@ describe('usage_counters schema', () => {
expect(rows).toHaveLength(1);
expect(rows[0]?.value).toBe(3);
});
+ it('the column enum and the UsageMetric union list the same metrics', () => {
+ expect([...usageCounters.metric.enumValues].sort()).toEqual(Object.keys(UNION_METRICS).sort());
+ });
+ it('persists every AI metric through the typed handle', async () => {
+ // A metric the column enum rejects is a counter that can be written by the
+ // raw path and never read back by the typed one.
+ for (const metric of ['ai_translate', 'ai_translate_byo', 'ai_assist', 'ai_assist_byo'] as const) {
+ await testDb.insert(usageCounters).values({ tenantId: 't1', metric, periodKey: '2026-06', value: 1, updatedAt: new Date() });
+ }
+ const rows = await testDb.select().from(usageCounters).where(eq(usageCounters.tenantId, 't1')).all();
+ expect(rows.map(r => r.metric).sort()).toEqual(['ai_assist', 'ai_assist_byo', 'ai_translate', 'ai_translate_byo']);
+ });
it('enforces the composite primary key', async () => {
await testDb.insert(usageCounters).values({ tenantId: 't1', metric: 'sms', periodKey: '2026-06', value: 1, updatedAt: new Date() });
await expect(testDb.insert(usageCounters).values({ tenantId: 't1', metric: 'sms', periodKey: '2026-06', value: 9, updatedAt: new Date() })).rejects.toThrow();
diff --git a/tests/unit/usage/usage-summary-api.spec.ts b/tests/unit/usage/usage-summary-api.spec.ts
index 926af9655..4d7240c57 100644
--- a/tests/unit/usage/usage-summary-api.spec.ts
+++ b/tests/unit/usage/usage-summary-api.spec.ts
@@ -89,6 +89,12 @@ describe('GET /api/usage/summary', () => {
await m.record(TENANT, 'sms_byo', '2026-06', 500);
await m.record(TENANT, 'email_byo', '2026-06', 250);
await m.record(TENANT, 'r2_bytes', 'lifetime', 4096);
+ // AI is reported per workload AND per credential source: four counters,
+ // seeded to four distinct values so a mis-wired field cannot pass.
+ await m.record(TENANT, 'ai_translate', '2026-06', 7);
+ await m.record(TENANT, 'ai_translate_byo', '2026-06', 11);
+ await m.record(TENANT, 'ai_assist', '2026-06', 13);
+ await m.record(TENANT, 'ai_assist_byo', '2026-06', 17);
const app = buildApp(testDb, SAAS_PROFILE);
const env = { DB: testD1 } as unknown as HonoConfig['Bindings'];
@@ -101,6 +107,8 @@ describe('GET /api/usage/summary', () => {
usage: {
inspections: 3, sms: 10, email: 20,
smsByo: 500, emailByo: 250,
+ aiTranslate: 7, aiTranslateByo: 11,
+ aiAssist: 13, aiAssistByo: 17,
seatsUsed: 2, seatsMax: 5,
r2Bytes: 4096,
},
@@ -142,6 +150,7 @@ describe('GET /api/usage/summary', () => {
const body = await res.json() as { data: { usage: Record } };
expect(body.data.usage).toEqual({
inspections: 0, sms: 0, email: 0, smsByo: 0, emailByo: 0,
+ aiTranslate: 0, aiTranslateByo: 0, aiAssist: 0, aiAssistByo: 0,
seatsUsed: 0, seatsMax: 5, r2Bytes: 0,
});
});
From 5138113b25a978e294aa7c96d42a494a917fd8ad Mon Sep 17 00:00:00 2001
From: important-new
Date: Tue, 4 Aug 2026 23:26:39 +0800
Subject: [PATCH 080/111] docs: AI has two credential sources, not one
The bring-your-own-key-only stance was stated in CLAUDE.md's GEMINI_API_KEY
row, in ai.service.ts's own header, in di.ts at the construction site, and in
two settings-surface comments. Leaving them would mean the next reader treats
the managed path as a regression and removes it.
Each is now precise rather than merely reworded: a tenant's own key always
wins and is unchanged; a deployment-provided key may serve tenants granted
managed access, in saas mode only; standalone has no managed path at all, so
it stays tenant-key-or-nothing. The settings-surface comments were narrowed to
what they actually describe - that panel's view of the tenant's own key -
rather than restated as a global rule.
Also documents AI_MODEL and AI_MANAGED_API_KEY, and corrects two API-reference
notes that claimed the env GEMINI_API_KEY gates the AI endpoints and that a
missing key returns 500 (it is 503, and the env key is not what they read).
CLAUDE.md is public OSS documentation, so this states mechanism only - which
credential resolves, where, and which counter it meters under. No packaging,
pricing, plan names, or platform-side commercial context, per the repo rule
and the plan's own global constraint.
---
CLAUDE.md | 4 +++-
app/routes/settings-advanced.tsx | 6 ++++--
docs/developers/02_deploy.md | 4 +++-
docs/developers/03_api_reference.md | 4 ++--
server/lib/middleware/di.ts | 6 +++---
server/services/ai.service.ts | 13 +++++++++++++
server/services/integrations.service.ts | 6 ++++--
7 files changed, 32 insertions(+), 11 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 64e561713..a75e513cf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -165,7 +165,9 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls
| `APP_BASE_URL` | No | Public URL for OAuth and link generation |
| `APP_BASE_URL` | No | Public origin used when building absolute links (reports, hosted `/legal/:tenant/…` Privacy & Terms). |
| `RESEND_API_KEY`| No | Platform-default email delivery (Resend). Tenants may switch to their OWN Resend key + verified sender via Settings → Communication (per-tenant override; the email pipeline resolves own-vs-platform explicitly). |
-| `GEMINI_API_KEY`| No | DEPRECATED as a platform key — AI assistance is strictly bring-your-own-key: `AIService` reads the tenant's own stored key (Settings → Advanced) and ignores this env. AI features stay off until a tenant configures a key. |
+| `GEMINI_API_KEY`| No | Not the credential AI features run on. `AIService` resolves credentials per call (`server/lib/ai/resolve-provider.ts`): a tenant's own stored key (Settings → Advanced → AI) always wins, and in `saas` mode a deployment-provided key may be used instead for tenants the deployment grants managed access to. In `standalone` there is no managed path at all — the tenant's key or nothing. This env is still read by the Advanced-settings "Test connection" diagnostic. |
+| `AI_MODEL` | No | Model id every AI call uses (e.g. a Gemini model name). **No default is compiled in**: when unset, AI features fail closed with a 503 rather than silently pinning whichever model was current when the code was written. Required for any AI feature to work, in every mode. |
+| `AI_MANAGED_API_KEY` | No | Deployment-provided AI key. Used only where `profile.hasManagedAi` is true (`saas`), and only for tenants the deployment grants managed access to; an entitled tenant on a deployment that never provisioned this key gets the feature OFF, not a runtime credential error. Absent in `standalone` by construction rather than disabled by a flag. Usage on this key meters under `ai_translate`/`ai_assist`; usage on a tenant's own key meters under `ai_translate_byo`/`ai_assist_byo` and never counts against a deployment allowance. |
| `APP_MODE` | No | `standalone` (default) or `saas` — controls tenant resolution |
| `APP_NAME` | No | Custom branding name |
| `PRIMARY_COLOR` | No | Custom branding color |
diff --git a/app/routes/settings-advanced.tsx b/app/routes/settings-advanced.tsx
index b6d302e70..18b0ddc49 100644
--- a/app/routes/settings-advanced.tsx
+++ b/app/routes/settings-advanced.tsx
@@ -55,8 +55,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const secretsBody = secretsRes?.ok ? ((await secretsRes.json()) as Record) : {};
const secrets = (secretsBody.data ?? {}) as Record;
- // Gemini is bring-your-own-key: "configured" reflects the tenant's own bound
- // key in encrypted secrets (no GET /api/ai/status route — derive from presence).
+ // "Configured" reflects the tenant's OWN bound key in encrypted secrets — the
+ // only credential this panel manages (no GET /api/ai/status route — derive
+ // from presence). A deployment may also provide a key for tenants it grants
+ // managed access to; that is resolved server-side and is not shown here.
const geminiConfigured = !!secrets.GEMINI_API_KEY;
const testResults = await parseTestResults(testResultsRes);
diff --git a/docs/developers/02_deploy.md b/docs/developers/02_deploy.md
index 6fdf5aae8..7562afe46 100644
--- a/docs/developers/02_deploy.md
+++ b/docs/developers/02_deploy.md
@@ -58,7 +58,9 @@ For the manual flow, see the **Quick start** section in the [README](../../READM
| `SETUP_CODE` | First-run setup only — any value >= 6 characters; gates `/setup` (fail-closed if unset). |
| `RESEND_API_KEY` | Optional, only if you want outbound email. |
| `SENDER_EMAIL` | Required when `RESEND_API_KEY` is set. |
-| `GEMINI_API_KEY` | Optional — enables AI comment-assist. |
+| `GEMINI_API_KEY` | Optional — read by the Advanced-settings "Test connection" diagnostic. AI features themselves run on the tenant's own key stored via Settings → Advanced → AI (or, in `saas` mode only, `AI_MANAGED_API_KEY`). |
+| `AI_MODEL` | Required for any AI feature — the model id every AI call uses. There is no compiled-in default; unset means AI fails closed with a 503. |
+| `AI_MANAGED_API_KEY` | Optional, `saas` only — a deployment-provided AI key for tenants the deployment grants managed access to. A standalone deploy has no managed path and ignores it. |
| `TURNSTILE_SECRET_KEY` | Optional but recommended for the public booking page. |
Set them via `wrangler secret put SECRET_NAME` or through the Cloudflare dashboard.
diff --git a/docs/developers/03_api_reference.md b/docs/developers/03_api_reference.md
index 4961109d5..b55840d32 100644
--- a/docs/developers/03_api_reference.md
+++ b/docs/developers/03_api_reference.md
@@ -487,7 +487,7 @@ Rewrite a rough inspector note into a professional, objective comment using Gemi
}
```
-> Requires `GEMINI_API_KEY` to be set. Returns `500` if the key is missing or invalid.
+> Requires AI to be configured: a resolvable credential (the tenant's own key, or a deployment-provided one in `saas`) **and** `AI_MODEL`. Returns `503` when either is missing, and `500` when the model call itself fails.
---
@@ -513,7 +513,7 @@ If no defect-status items are recorded the response is:
{ "summary": "No significant defects observed during this inspection." }
```
-> Requires `GEMINI_API_KEY` to be set. Returns `403` if the inspection does not belong to the caller's tenant, `404` if no results exist.
+> Requires AI to be configured (a resolvable credential **and** `AI_MODEL`; `503` otherwise). Returns `403` if the inspection does not belong to the caller's tenant, `404` if no results exist.
---
diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts
index 755ba4bf4..3ea3f2194 100644
--- a/server/lib/middleware/di.ts
+++ b/server/lib/middleware/di.ts
@@ -161,9 +161,9 @@ export async function diMiddleware(c: Context, next: Next) {
case 'ai':
target.ai = new AIService(
c.env.DB,
- // Bring-your-own-key: the Gemini key comes solely from the
- // tenant's own bound key (Settings → Advanced → AI), never a
- // shared platform env key — applies to SaaS and standalone.
+ // The tenant's own bound key (Settings → Advanced → AI) —
+ // always wins, and still the ONLY credential reaching the
+ // service until managed access is granted (see buildAiMeter).
emailCfg.dbSecrets.geminiApiKey || '',
// Sprint 1 A-4: pass effective deployment mode so the
// service can return dev-mock suggestions when the
diff --git a/server/services/ai.service.ts b/server/services/ai.service.ts
index e90c14405..7c4267256 100644
--- a/server/services/ai.service.ts
+++ b/server/services/ai.service.ts
@@ -8,6 +8,19 @@ import type { AiUsageKind } from '../lib/usage/period';
/**
* Service to handle AI-powered features using Google Gemini.
*
+ * CREDENTIALS COME FROM ONE OF TWO SOURCES, not one. A tenant's own stored key
+ * (Settings → Advanced → AI) always wins and is unchanged by anything below.
+ * Where the deployment profile permits it (`hasManagedAi` — saas only), a
+ * deployment-provided key may serve tenants granted managed access instead;
+ * that grant is a boolean this service is handed, never a decision it makes.
+ * In standalone the managed path does not exist at all, so it remains the
+ * tenant's key or nothing, exactly as before. Selection lives in
+ * `lib/ai/resolve-provider.ts` — this class does not re-derive it.
+ *
+ * This paragraph replaces a "strictly bring-your-own-key" statement that the
+ * managed path contradicts; without the correction the next reader treats that
+ * path as a regression and deletes it.
+ *
* Sprint 1 A-4: when running in `standalone` mode without a Gemini API key,
* `suggestComment` returns dev-mock suggestions so local development can
* exercise the UI flow end-to-end. Production deploys (`saas` mode or
diff --git a/server/services/integrations.service.ts b/server/services/integrations.service.ts
index cf7660a2d..e6ec5c772 100644
--- a/server/services/integrations.service.ts
+++ b/server/services/integrations.service.ts
@@ -41,8 +41,10 @@ export class IntegrationsService {
const qbo = await this._safeGet(() =>
db.select().from(qboConnections).where(eq(qboConnections.tenantId, tenantId)).get(),
);
- // Gemini is bring-your-own-key (per-tenant). "Connected" reflects the
- // tenant's own bound key in encrypted secrets, never a platform env key.
+ // "Connected" here is specifically about the TENANT's own bound key in
+ // encrypted secrets — that is what this settings surface manages. It is
+ // not a statement that a tenant key is the only credential AI can run
+ // on: see lib/ai/resolve-provider.ts for the full source order.
// C-15: reads the CANONICAL `secrets_enc` store (ENV-name keys) —
// the legacy `tenant_configs.secrets` column is retired.
const dbSecrets = await this._safeGet(() =>
From 416842dad7880a2f13d2983dc1d150c77dba3894 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 00:35:40 +0800
Subject: [PATCH 081/111] fix(security): scope three by-id writes by tenant
instead of baselining them
The tenant-scoping gate flagged three writes added this batch: two in the
per-report sync fix, one in the QBO discrepancy path. Each is reachable only
after a tenant-scoped read, so each is safe today -- but that safety is an
argument about control flow, and arguments drift while a where-clause does not.
Baseline tightened 75 -> 71 in the same pass; four entries no longer hit.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
scripts/tenant-scoping-baseline.json | 4 ----
server/api/inspection-sync.ts | 10 ++++++++--
server/services/qbo/api-base.ts | 5 ++++-
3 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/scripts/tenant-scoping-baseline.json b/scripts/tenant-scoping-baseline.json
index f6554b9b0..2e55efc77 100644
--- a/scripts/tenant-scoping-baseline.json
+++ b/scripts/tenant-scoping-baseline.json
@@ -8,8 +8,6 @@
"server/api/evidence.ts::downloadAgreementPdf::.where(eq(schema.agreementRequests.id, envelopeId)).get();",
"server/api/evidence.ts::downloadCertPdf::.where(eq(schema.agreementRequests.id, envelopeId)).get();",
"server/api/evidence.ts::downloadEvidenceZip::.where(eq(schema.agreementRequests.id, envelopeId)).get();",
- "server/api/inspection-sync.ts::::.where(eq(inspectionResults.id, row.id));",
- "server/api/inspection-sync.ts::::.where(eq(inspections.id, id));",
"server/api/inspections/agreements.ts::::.where(eq(agreementRequests.id, env.requestId)).get();",
"server/api/inspections/agreements.ts::::.where(eq(agreements.id, envelope.agreementId)).get();",
"server/api/public-report.ts::resolveClientTenant::.from(inspections).where(eq(inspections.id, id)).get();",
@@ -40,7 +38,6 @@
"server/services/concierge.service.ts::approveByInspector::.where(eq(inspections.id, row.inspectionId))",
"server/services/concierge.service.ts::resolveToken::.where(eq(inspections.id, row.inspectionId))",
"server/services/email/transactional.ts::sendInvoiceRequest::const [insp] = await db.select().from(inspections).where(eq(inspections.id, inspectionId)).limit(1);",
- "server/services/event.service.ts::updateEventStatus::const ev = await d.select().from(inspectionEvents).where(eq(inspectionEvents.id, id)).get();",
"server/services/inspection/inspection-annotations.service.ts::InspectionAnnotationsService::.where(eq(inspectionResults.id, row.id));",
"server/services/inspection/inspection-annotations.service.ts::InspectionAnnotationsService::await db.update(inspectionResults).set({ data, lastSyncedAt: new Date() }).where(eq(inspectionResults.id, row.id));",
"server/services/inspection/inspection-cascade.ts::deleteInspectionCascade::await db.delete(inspections).where(eq(inspections.id, inspectionId));",
@@ -61,7 +58,6 @@
"server/services/notice-inbox.ts::getOwnedNotice::.where(and(eq(notifications.id, id), inArray(notifications.contactId, contactIds)))",
"server/services/portal-access.service.ts::issueToken::.where(eq(inspectionAccessTokens.id, existing.id))",
"server/services/portal-access.service.ts::resolveToken::await db.update(inspectionAccessTokens).set(setValues).where(eq(inspectionAccessTokens.id, legacy.id)).run();",
- "server/services/qbo/api-base.ts::logSyncError::}).where(eq(qboSyncErrors.id, existing.id));",
"server/services/qbo/customer-sync.ts::buildPayload::}).where(eq(qboEntityMap.id, existing.id));",
"server/services/qbo/invoice-sync.ts::getQBOCustomerIdForInvoice::}).where(eq(qboEntityMap.id, existing.id));",
"server/services/qbo/invoice-sync.ts::getQBOCustomerIdForInvoice::}).where(eq(qboEntityMap.id, mapped.id));",
diff --git a/server/api/inspection-sync.ts b/server/api/inspection-sync.ts
index 410db6ad4..be3bc790d 100644
--- a/server/api/inspection-sync.ts
+++ b/server/api/inspection-sync.ts
@@ -99,7 +99,10 @@ const syncRoutes = createApiRouter()
await db.update(inspectionResults)
.set({ data: data as unknown as object, lastSyncedAt: new Date() })
- .where(eq(inspectionResults.id, row.id));
+ .where(and(
+ eq(inspectionResults.tenantId, tenantId),
+ eq(inspectionResults.id, row.id),
+ ));
if (deletedKey && c.env.PHOTOS) {
await r2Delete(c.env.PHOTOS, deletedKey).catch(() => {});
@@ -198,7 +201,10 @@ const syncRoutes = createApiRouter()
await db.update(inspections)
.set({ templateSnapshot: tpl.schema, templateSnapshotVersion: tpl.version })
- .where(eq(inspections.id, id));
+ .where(and(
+ eq(inspections.tenantId, tenantId),
+ eq(inspections.id, id),
+ ));
auditFromContext(c, 'inspection.template_upgraded', 'inspection', {
entityId: id, metadata: { from: fromVersion, to: tpl.version },
diff --git a/server/services/qbo/api-base.ts b/server/services/qbo/api-base.ts
index 194f33862..f719f73a4 100644
--- a/server/services/qbo/api-base.ts
+++ b/server/services/qbo/api-base.ts
@@ -229,7 +229,10 @@ export class QBOServiceBase {
retries: existing.retries + 1,
errorMsg,
updatedAt: now,
- }).where(eq(qboSyncErrors.id, existing.id));
+ }).where(and(
+ eq(qboSyncErrors.tenantId, tenantId),
+ eq(qboSyncErrors.id, existing.id),
+ ));
} else {
await db.insert(qboSyncErrors).values({
id: crypto.randomUUID(),
From e0ef3a8f48c90647714a618950a769cdd93f7b95 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 00:46:51 +0800
Subject: [PATCH 082/111] refactor(events): give the visit lifecycle its own
status constant
The visits UI compared eight bare status strings, which lint:status-literals
catches as the path by which ghost values reach runtime. Baselining them was the
wrong option: a visit's lifecycle is a real second axis, not an exception. A
radon test is two visits against one inspection, so an event reaches completed
while its inspection is still confirmed, and results_received has no counterpart
on the order at all.
The drizzle enum now derives from EVENT_STATUSES rather than repeating it.
Same values, so db:check reports no drift: 88 hand = 88 generated.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
.../calendar/CalendarEventModal.tsx | 9 ++++---
.../inspector-portal/VisitsCard.tsx | 15 +++++------
app/lib/status.ts | 6 +++++
server/lib/db/schema/inspection/automation.ts | 3 ++-
server/lib/status/event-status.ts | 25 +++++++++++++++++++
5 files changed, 46 insertions(+), 12 deletions(-)
create mode 100644 server/lib/status/event-status.ts
diff --git a/app/components/calendar/CalendarEventModal.tsx b/app/components/calendar/CalendarEventModal.tsx
index cd52ba203..1ccaa340d 100644
--- a/app/components/calendar/CalendarEventModal.tsx
+++ b/app/components/calendar/CalendarEventModal.tsx
@@ -3,6 +3,7 @@ import { Modal } from "@core/shared-ui";
import { calendarItemHref, type CalendarEvent } from "~/components/calendar/calendar-helpers";
import { formatDate, formatDateTime } from "~/lib/format";
import { m } from "~/paraglide/messages";
+import { EVENT_STATUS } from "~/lib/status";
/**
* A status word the viewer can read.
@@ -15,10 +16,10 @@ import { m } from "~/paraglide/messages";
* Language follows the viewer; only date SHAPE follows the tenant.
*/
function statusLabel(status: string): string {
- if (status === "scheduled") return m.label_status_scheduled();
- if (status === "completed") return m.label_status_completed();
- if (status === "cancelled") return m.label_status_cancelled();
- if (status === "results_received") return m.calendar_event_status_results_received();
+ if (status === EVENT_STATUS.SCHEDULED) return m.label_status_scheduled();
+ if (status === EVENT_STATUS.COMPLETED) return m.label_status_completed();
+ if (status === EVENT_STATUS.CANCELLED) return m.label_status_cancelled();
+ if (status === EVENT_STATUS.RESULTS_RECEIVED) return m.calendar_event_status_results_received();
// Inspection lifecycle values (draft/in_progress/delivered/…) already have
// their own labels elsewhere; until this modal is taught them, the legacy
// rendering is better than a blank.
diff --git a/app/components/inspector-portal/VisitsCard.tsx b/app/components/inspector-portal/VisitsCard.tsx
index 521a43ba0..9ee05b957 100644
--- a/app/components/inspector-portal/VisitsCard.tsx
+++ b/app/components/inspector-portal/VisitsCard.tsx
@@ -6,6 +6,7 @@ import { ConfirmDialog } from "~/components/ConfirmDialog";
import { isAdminRole } from "~/lib/access";
import { AddVisitModal } from "./AddVisitModal";
import { m } from "~/paraglide/messages";
+import { EVENT_STATUS } from "~/lib/status";
import type { action } from "~/routes/inspector-portal";
export type VisitStatus = "scheduled" | "completed" | "results_received" | "cancelled";
@@ -55,22 +56,22 @@ export type VisitAction = "complete" | "results" | "cancel";
*/
export function visitActions(role: string, status: VisitStatus): VisitAction[] {
const admin = isAdminRole(role);
- if (status === "scheduled") return admin ? ["complete", "cancel"] : ["complete"];
- if (status === "completed") return admin ? ["results", "cancel"] : [];
+ if (status === EVENT_STATUS.SCHEDULED) return admin ? ["complete", "cancel"] : ["complete"];
+ if (status === EVENT_STATUS.COMPLETED) return admin ? ["results", "cancel"] : [];
// results_received and cancelled are terminal: there is nothing left to offer.
return [];
}
function statusLabel(status: VisitStatus): string {
- if (status === "completed") return m.label_status_completed();
- if (status === "results_received") return m.inspections_hub_visits_status_results();
- if (status === "cancelled") return m.label_status_cancelled();
+ if (status === EVENT_STATUS.COMPLETED) return m.label_status_completed();
+ if (status === EVENT_STATUS.RESULTS_RECEIVED) return m.inspections_hub_visits_status_results();
+ if (status === EVENT_STATUS.CANCELLED) return m.label_status_cancelled();
return m.label_status_scheduled();
}
function statusTone(status: VisitStatus): "sat" | "monitor" | "neutral" {
- if (status === "results_received") return "sat";
- if (status === "cancelled") return "neutral";
+ if (status === EVENT_STATUS.RESULTS_RECEIVED) return "sat";
+ if (status === EVENT_STATUS.CANCELLED) return "neutral";
return "monitor";
}
diff --git a/app/lib/status.ts b/app/lib/status.ts
index 4bc44eae2..159cb31c7 100644
--- a/app/lib/status.ts
+++ b/app/lib/status.ts
@@ -10,6 +10,12 @@ export {
isReportPublished,
} from '../../server/lib/status/report-status';
+// A visit's lifecycle, NOT the order's — see event-status.ts for why they are
+// separate axes rather than one shared enum.
+export {
+ EVENT_STATUS,
+} from '../../server/lib/status/event-status';
+
// ------------------------------------------------------------------
// Shared status display helpers (single source — no per-route copies)
// ------------------------------------------------------------------
diff --git a/server/lib/db/schema/inspection/automation.ts b/server/lib/db/schema/inspection/automation.ts
index 1538e66a8..3c9b9bf82 100644
--- a/server/lib/db/schema/inspection/automation.ts
+++ b/server/lib/db/schema/inspection/automation.ts
@@ -2,6 +2,7 @@ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqli
import { sql } from 'drizzle-orm';
import { tenants, users } from '../tenant';
import { inspections } from './core';
+import { EVENT_STATUSES } from '../../../status/event-status';
export const automations = sqliteTable('automations', {
id: text('id').primaryKey(),
@@ -231,7 +232,7 @@ export const inspectionEvents = sqliteTable('inspection_events', {
scheduledAt: integer('scheduled_at', { mode: 'timestamp_ms' }).notNull(),
durationMin: integer('duration_min').notNull(),
priceCents: integer('price_cents').notNull().default(0),
- status: text('status', { enum: ['scheduled', 'completed', 'results_received', 'cancelled'] }).notNull().default('scheduled'),
+ status: text('status', { enum: [...EVENT_STATUSES] }).notNull().default('scheduled'),
notes: text('notes'),
completedAt: integer('completed_at', { mode: 'timestamp_ms' }),
resultsReceivedAt: integer('results_received_at', { mode: 'timestamp_ms' }),
diff --git a/server/lib/status/event-status.ts b/server/lib/status/event-status.ts
new file mode 100644
index 000000000..19d311f8a
--- /dev/null
+++ b/server/lib/status/event-status.ts
@@ -0,0 +1,25 @@
+/**
+ * Single source of truth for the INSPECTION EVENT (visit) lifecycle axis.
+ *
+ * This is a DIFFERENT axis from `INSPECTION_STATUS`, which tracks the order as a
+ * whole. A radon test is two visits against one inspection — a drop-off and a
+ * pickup — so an event reaches `completed` while its inspection is still
+ * `confirmed`, and `results_received` has no counterpart on the order at all.
+ * Sharing one enum between the two would make every consumer decide which axis a
+ * bare `'completed'` belongs to, which is the ambiguity this file removes.
+ *
+ * Every consumer (drizzle enum, Zod enum, UI labels, action gating) MUST derive
+ * from these — no bare status string literals, enforced by `lint:status-literals`.
+ */
+export const EVENT_STATUSES = [
+ 'scheduled', 'completed', 'results_received', 'cancelled',
+] as const;
+
+export type EventStatus = typeof EVENT_STATUSES[number];
+
+export const EVENT_STATUS = {
+ SCHEDULED: 'scheduled',
+ COMPLETED: 'completed',
+ RESULTS_RECEIVED: 'results_received',
+ CANCELLED: 'cancelled',
+} as const satisfies Record;
From 7e5edb225749e84e9784509a103a5b7b566c6fe5 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 00:57:21 +0800
Subject: [PATCH 083/111] refactor: stop exporting six symbols nothing outside
their module uses
knip flagged them at the batch boundary. Each is referenced only inside its own
file, including by tests, so un-exporting is the honest fix rather than freezing
six entries into the dead-code baseline.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
server/lib/ai/resolve-provider.ts | 2 +-
server/lib/i18n/recipient-locale.ts | 2 +-
server/services/automation/notice-wording.ts | 2 +-
server/services/payment-ledger.service.ts | 6 +++---
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/server/lib/ai/resolve-provider.ts b/server/lib/ai/resolve-provider.ts
index ec9894b00..2f9478799 100644
--- a/server/lib/ai/resolve-provider.ts
+++ b/server/lib/ai/resolve-provider.ts
@@ -28,7 +28,7 @@ import { GeminiProvider } from './providers/gemini';
/** Where the credentials for a resolved call came from. Also selects the
* usage metric at the call site — platform-funded volume is metered apart
* from bring-your-own volume, the same split `policy.ts` documents for sends. */
-export type AiCredentialSource = 'managed' | 'byo';
+type AiCredentialSource = 'managed' | 'byo';
export interface ResolvedAi {
provider: AiProvider;
diff --git a/server/lib/i18n/recipient-locale.ts b/server/lib/i18n/recipient-locale.ts
index 10c8ce48e..e3a1d3445 100644
--- a/server/lib/i18n/recipient-locale.ts
+++ b/server/lib/i18n/recipient-locale.ts
@@ -29,7 +29,7 @@ import { resolveContactLocale, type ContactLocale } from './contact-locale';
* lands on the tenant default, so the caller must decide it from the role key,
* not from the shape of the id.
*/
-export interface RecipientRef {
+interface RecipientRef {
kind: 'user' | 'contact';
id: string;
}
diff --git a/server/services/automation/notice-wording.ts b/server/services/automation/notice-wording.ts
index 6875f2bee..36a252ff5 100644
--- a/server/services/automation/notice-wording.ts
+++ b/server/services/automation/notice-wording.ts
@@ -29,7 +29,7 @@ import type { automations, inspections } from '../../lib/db/schema';
* read here would be a silent mistranslation, not a type error, so the type
* system is asked to make it one.
*/
-export function noticeTitleFor(
+function noticeTitleFor(
event: string,
insp: typeof inspections.$inferSelect,
locale: ContactLocale,
diff --git a/server/services/payment-ledger.service.ts b/server/services/payment-ledger.service.ts
index b9dc7acd5..654d7ff49 100644
--- a/server/services/payment-ledger.service.ts
+++ b/server/services/payment-ledger.service.ts
@@ -20,9 +20,9 @@ import { Errors } from '../lib/errors';
/** Accepts the D1 drizzle instance in production and better-sqlite3 in tests. */
type AnyDb = DrizzleD1Database> | { [k: string]: unknown };
-export type PaymentKind = 'deposit' | 'balance' | 'adjustment' | 'refund';
-export type PaymentMethodKind = 'card' | 'check' | 'cash' | 'offline' | 'other';
-export type PaymentProvider = 'stripe' | 'qbo';
+type PaymentKind = 'deposit' | 'balance' | 'adjustment' | 'refund';
+type PaymentMethodKind = 'card' | 'check' | 'cash' | 'offline' | 'other';
+type PaymentProvider = 'stripe' | 'qbo';
export interface PaymentEntry {
/** The order the money is against. Resolved from the invoice when omitted. */
From b06afb97c974660256f710d750c6f4ef908664c8 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 01:38:21 +0800
Subject: [PATCH 084/111] fix(i18n): let the client take the server's locale
when there is no cookie
The server resolves users.locale > tenant > cookie > Accept-Language, so a
Spanish browser with no cookie is served Spanish. The client read only the
cookie, answered 'en', and hydration repainted the page in English. The login
page makes it reachable: it sits outside auth-layout, where the cookie stamp is
written, so a first visit has nothing to read.
The e2e spec that caught it races hydration and passed against the unfixed build
on a re-run, so it cannot serve as the guard. The resolver is extracted and
tested directly instead: removing the fix reddens exactly the one assertion.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
app/entry.client.tsx | 28 ++++++++++++++---------
app/lib/i18n/client-locale.test.ts | 36 ++++++++++++++++++++++++++++++
app/lib/i18n/client-locale.ts | 26 +++++++++++++++++++++
3 files changed, 79 insertions(+), 11 deletions(-)
create mode 100644 app/lib/i18n/client-locale.test.ts
create mode 100644 app/lib/i18n/client-locale.ts
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 99d4ff764..ed4c37bf0 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -1,11 +1,8 @@
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
-import {
- overwriteGetLocale,
- extractLocaleFromCookie,
- baseLocale,
-} from "~/paraglide/runtime";
+import { overwriteGetLocale } from "~/paraglide/runtime";
+import { resolveClientLocale } from "~/lib/i18n/client-locale";
// i18n — make the client-side getLocale() PURE before React hydrates.
//
@@ -21,12 +18,21 @@ import {
//
// Installing a side-effect-free resolver here — before hydrateRoot — means the
// self-init block never runs on the client. It mirrors the server (which resolves
-// the locale via the paraglide AsyncLocalStorage scope with no side effect) and
-// is forward-safe: while the framework is dormant there is no PARAGLIDE_LOCALE
-// cookie so this resolves to baseLocale ('en'); once a later rollout sets the
-// cookie it is read here automatically. The root loader keeps resolving the
-// locale for server-side; this only governs client render.
-overwriteGetLocale(() => extractLocaleFromCookie() ?? baseLocale);
+// the locale via the paraglide AsyncLocalStorage scope with no side effect).
+//
+// The cookie is not the only input, and treating it as one caused a real bug: the
+// server resolves `users.locale > tenant > cookie > Accept-Language`, so a Spanish
+// browser with NO cookie gets Spanish SSR — and then the client, seeing no cookie,
+// fell back to baseLocale and hydration silently repainted the page in English.
+// It only showed up intermittently, because whether you see it depends on catching
+// the page before or after hydration. The login page makes it reachable in
+// practice: it sits outside auth-layout, which is where the cookie stamp is
+// written, so there is nothing to read there on a first visit.
+//
+// `` is the server's already-resolved answer, rendered into the
+// document the client is hydrating. Reading it back is how the two agree without a
+// second resolution path that could disagree with the first.
+overwriteGetLocale(() => resolveClientLocale(document.documentElement.lang));
startTransition(() => {
hydrateRoot(
diff --git a/app/lib/i18n/client-locale.test.ts b/app/lib/i18n/client-locale.test.ts
new file mode 100644
index 000000000..41dad15ee
--- /dev/null
+++ b/app/lib/i18n/client-locale.test.ts
@@ -0,0 +1,36 @@
+// @vitest-environment happy-dom
+//
+// Guards the hydration seam. The e2e spec that first caught this
+// (`locale-activation.spec.ts`, "a Spanish browser with no cookie gets Spanish")
+// races hydration and therefore only fails sometimes — it passed against the
+// unfixed build on a re-run. These assertions are deterministic.
+import { describe, it, expect, afterEach } from "vitest";
+import { resolveClientLocale } from "~/lib/i18n/client-locale";
+
+function clearCookie() {
+ document.cookie = "PARAGLIDE_LOCALE=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+}
+
+afterEach(clearCookie);
+
+describe("resolveClientLocale", () => {
+ it("takes the server's when there is no cookie", () => {
+ clearCookie();
+ // The regression: this returned 'en' and hydration repainted a
+ // Spanish-rendered page in English.
+ expect(resolveClientLocale("es-419")).toBe("es-419");
+ });
+
+ it("falls back to the base locale when the server said nothing usable", () => {
+ clearCookie();
+ expect(resolveClientLocale("")).toBe("en");
+ expect(resolveClientLocale("not-a-tag")).toBe("en");
+ });
+
+ it("lets an explicit cookie beat the server's tag", () => {
+ // A viewer who picked English on a Spanish-configured workspace keeps
+ // English — the cookie is a stated preference, `` is a default.
+ document.cookie = "PARAGLIDE_LOCALE=en; path=/";
+ expect(resolveClientLocale("es-419")).toBe("en");
+ });
+});
diff --git a/app/lib/i18n/client-locale.ts b/app/lib/i18n/client-locale.ts
new file mode 100644
index 000000000..7d64c8e25
--- /dev/null
+++ b/app/lib/i18n/client-locale.ts
@@ -0,0 +1,26 @@
+import { extractLocaleFromCookie, baseLocale, isLocale, type Locale } from "~/paraglide/runtime";
+
+/**
+ * The client's locale answer, which must agree with the server's.
+ *
+ * The server resolves `users.locale > tenant > cookie > Accept-Language > en`, so
+ * a Spanish browser with NO cookie is served Spanish. A client resolver that reads
+ * only the cookie answers `en` for that same request, and hydration silently
+ * repaints the page in English — visible only if you look before hydration
+ * finishes, which is why it presents as an intermittent test failure rather than
+ * an obvious bug. The login page makes it reachable in practice: it sits outside
+ * `auth-layout`, where the cookie stamp is written, so a first visit has no cookie
+ * to read.
+ *
+ * `` is the server's already-resolved answer, rendered into the very
+ * document being hydrated. Reading it back is how the two agree without a second
+ * resolution path that could disagree with the first.
+ *
+ * Extracted from `entry.client.tsx` so it can be tested: importing that module
+ * hydrates the document as a side effect.
+ */
+export function resolveClientLocale(docLang: string): Locale {
+ const fromCookie = extractLocaleFromCookie();
+ if (fromCookie) return fromCookie;
+ return isLocale(docLang) ? docLang : baseLocale;
+}
From b56956f031d88683f04747866966c86de870eb70 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 06:58:47 +0800
Subject: [PATCH 085/111] feat(scheduling): add booking_conflict_policy tenant
config
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dispatch board and the reschedule endpoint both have to answer "this
inspector is already busy then — now what?", and until now nothing in the
schema could hold the answer: overlap detection existed
(findScheduleConflicts) but every caller was hard-wired to advisory.
Sibling of holidayInternalPolicy, which answers the same question for a
closed DAY rather than a busy instant. The two are deliberately
independent — a tenant may block holidays and still tolerate deliberate
double-booking, or the reverse — so this is a second column rather than a
widened enum on the first.
Default 'advisory' preserves today's behavior for every existing tenant;
'block' is opt-in. Appended at the END of tenant_configs per the D1
add-column-at-end rule, so the generated migration is a plain ADD COLUMN
and not a table rebuild.
Read/written through the existing admin tenant-config GET/PATCH allowlist,
and added to the workers-runtime inline DDL that inline-ddl-schema-sync
guards (verified red without it).
---
migrations/0038_nasty_patriot.sql | 1 +
migrations/meta/0038_snapshot.json | 10514 ++++++++++++++++++++++++++
migrations/meta/_journal.json | 7 +
scripts/file-size-baseline.json | 2 +-
server/api/admin/admin-settings.ts | 6 +
server/lib/db/schema/tenant/core.ts | 11 +
tests/helpers/inline-ddl.ts | 2 +-
7 files changed, 10541 insertions(+), 2 deletions(-)
create mode 100644 migrations/0038_nasty_patriot.sql
create mode 100644 migrations/meta/0038_snapshot.json
diff --git a/migrations/0038_nasty_patriot.sql b/migrations/0038_nasty_patriot.sql
new file mode 100644
index 000000000..baa9a11d7
--- /dev/null
+++ b/migrations/0038_nasty_patriot.sql
@@ -0,0 +1 @@
+ALTER TABLE `tenant_configs` ADD `booking_conflict_policy` text DEFAULT 'advisory' NOT NULL;
\ No newline at end of file
diff --git a/migrations/meta/0038_snapshot.json b/migrations/meta/0038_snapshot.json
new file mode 100644
index 000000000..3d5cb99fb
--- /dev/null
+++ b/migrations/meta/0038_snapshot.json
@@ -0,0 +1,10514 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "ad589f6b-092b-4147-a70c-2acc19b14137",
+ "prevId": "cd2c347a-92b2-477a-9127-3b745b7326d6",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "language_disclosure_version": {
+ "name": "language_disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "follow_up_delay_hours": {
+ "name": "follow_up_delay_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 72
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ },
+ "idx_message_templates_variant": {
+ "name": "idx_message_templates_variant",
+ "columns": [
+ "tenant_id",
+ "name",
+ "channel",
+ "locale"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "order_payments": {
+ "name": "order_payments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "invoice_id": {
+ "name": "invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recorded_by": {
+ "name": "recorded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refunds_id": {
+ "name": "refunds_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_order_payments_inspection": {
+ "name": "idx_order_payments_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_order_payments_invoice": {
+ "name": "idx_order_payments_invoice",
+ "columns": [
+ "tenant_id",
+ "invoice_id"
+ ],
+ "isUnique": false
+ },
+ "uq_order_payments_provider_ref": {
+ "name": "uq_order_payments_provider_ref",
+ "columns": [
+ "tenant_id",
+ "provider",
+ "provider_ref"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'us'"
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'12h'"
+ },
+ "booking_conflict_policy": {
+ "name": "booking_conflict_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index 7ce9f08f0..e4b3fa0e6 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -267,6 +267,13 @@
"when": 1785846007839,
"tag": "0037_many_luke_cage",
"breakpoints": true
+ },
+ {
+ "idx": 38,
+ "version": "6",
+ "when": 1785884007195,
+ "tag": "0038_nasty_patriot",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 356dd9ea2..e6caf0a00 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -10,7 +10,7 @@
"server/api/sms.ts": 843,
"app/components/portal/sections/ReportView.tsx": 813,
"app/routes/settings-communication.tsx": 777,
- "server/api/admin/admin-settings.ts": 748,
+ "server/api/admin/admin-settings.ts": 754,
"server/services/inspection.service.ts": 741,
"server/api/inspections/report-delivery.ts": 736,
"server/services/inspection/inspection-analytics.service.ts": 729,
diff --git a/server/api/admin/admin-settings.ts b/server/api/admin/admin-settings.ts
index ee1ad5a97..733f170dc 100644
--- a/server/api/admin/admin-settings.ts
+++ b/server/api/admin/admin-settings.ts
@@ -140,6 +140,7 @@ const TenantConfigGetResponseSchema = z.object({
holidayRegion: z.string().nullable().describe('Holiday catalog region (US / US-{ST}) or null when catalog is off.'),
holidayPublicPolicy: z.enum(['open', 'block', 'advisory']).describe('Public booking policy for catalog closed dates.'),
holidayInternalPolicy: z.enum(['advisory', 'block']).describe('Internal scheduling policy for catalog closed dates.'),
+ bookingConflictPolicy: z.enum(['advisory', 'block']).describe('Internal scheduling policy for double-booking an inspector: advisory warns, block refuses the write.'),
legalMode: z.enum(['hosted', 'custom']).describe('Privacy/Terms source: OI /legal pages or custom URLs.'),
customPrivacyUrl: z.string().nullable().describe('Custom Privacy Policy URL when legalMode=custom.'),
customTermsUrl: z.string().nullable().describe('Custom Terms URL when legalMode=custom.'),
@@ -196,6 +197,7 @@ const TenantConfigPatchSchema = z.object({
]).optional().describe('Holiday catalog region. null disables the catalog.'),
holidayPublicPolicy: z.enum(['open', 'block', 'advisory']).optional().describe('Public booking holiday policy.'),
holidayInternalPolicy: z.enum(['advisory', 'block']).optional().describe('Internal scheduling holiday policy.'),
+ bookingConflictPolicy: z.enum(['advisory', 'block']).optional().describe('Double-booking policy for internal scheduling: advisory warns, block refuses the write.'),
legalMode: z.enum(['hosted', 'custom']).optional().describe('Privacy/Terms source.'),
customPrivacyUrl: z.string().url().max(500).nullish().describe('Custom Privacy URL; required with customTermsUrl when legalMode=custom. null clears.'),
customTermsUrl: z.string().url().max(500).nullish().describe('Custom Terms URL; required with customPrivacyUrl when legalMode=custom. null clears.'),
@@ -487,6 +489,7 @@ const adminSettingsRoutes = createApiRouter()
? (config?.holidayPublicPolicy as 'open' | 'block' | 'advisory')
: 'open',
holidayInternalPolicy: config?.holidayInternalPolicy === 'block' ? 'block' : 'advisory',
+ bookingConflictPolicy: config?.bookingConflictPolicy === 'block' ? 'block' : 'advisory',
legalMode,
customPrivacyUrl,
customTermsUrl,
@@ -573,6 +576,9 @@ const adminSettingsRoutes = createApiRouter()
if (body.holidayInternalPolicy !== undefined) {
update.holidayInternalPolicy = body.holidayInternalPolicy;
}
+ if (body.bookingConflictPolicy !== undefined) {
+ update.bookingConflictPolicy = body.bookingConflictPolicy;
+ }
if (body.legalMode !== undefined) {
update.legalMode = body.legalMode;
}
diff --git a/server/lib/db/schema/tenant/core.ts b/server/lib/db/schema/tenant/core.ts
index fcbea1b2c..d34b4089b 100644
--- a/server/lib/db/schema/tenant/core.ts
+++ b/server/lib/db/schema/tenant/core.ts
@@ -253,6 +253,17 @@ export const tenantConfigs = sqliteTable('tenant_configs', {
// Appended at END of the table per the D1 add-column-at-end rule.
dateFormat: text('date_format', { enum: ['us', 'iso', 'eu'] }).notNull().default('us'),
timeFormat: text('time_format', { enum: ['12h', '24h'] }).notNull().default('12h'),
+ // How internal scheduling treats a DOUBLE-BOOKING — the same inspector
+ // already busy at the proposed instant. Sibling of `holidayInternalPolicy`,
+ // which answers the same question for a closed DAY; the two are independent
+ // (a tenant may block holidays but tolerate overlaps, or the reverse).
+ // `advisory` = warn and save (today's behavior everywhere, so it is the
+ // default); `block` = refuse the write. Read by the reschedule endpoint and
+ // by the dispatch board, which shows the conflict list either way.
+ // Appended at END of the table per the D1 add-column-at-end rule.
+ bookingConflictPolicy: text('booking_conflict_policy', {
+ enum: ['advisory', 'block'],
+ }).notNull().default('advisory'),
});
/**
diff --git a/tests/helpers/inline-ddl.ts b/tests/helpers/inline-ddl.ts
index 7c79a6470..d026e7228 100644
--- a/tests/helpers/inline-ddl.ts
+++ b/tests/helpers/inline-ddl.ts
@@ -21,7 +21,7 @@
* one sync assertion.
*/
export const TENANT_CONFIGS_TEST_DDL =
- 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, company_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, video_mode TEXT, sms_mode TEXT, sender_display_name TEXT, point_of_contact TEXT, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, secrets_enc TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, default_profile_id TEXT, attention_thresholds TEXT, inspection_prefs TEXT, is_estimates_shown INTEGER, is_repair_list_enabled INTEGER, is_customer_repair_export_enabled INTEGER, is_unpaid_blocked INTEGER, is_unsigned_agreement_blocked INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, is_concierge_review_required INTEGER, is_inspector_choice_allowed INTEGER, is_pdf_pipeline_enabled INTEGER, auto_sign_on_publish_default INTEGER, is_team_mode_default TEXT, is_apprentice_review_required INTEGER, is_guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, reinspection_statuses TEXT, is_collab_editing_enabled INTEGER NOT NULL DEFAULT 1, company_address TEXT, is_pdf_footer_shown INTEGER, is_pdf_page_numbers_shown INTEGER, is_pdf_license_shown INTEGER, sms_byo_provider TEXT, email_byo_provider TEXT, is_managed_eligible INTEGER NOT NULL DEFAULT 0, managed_provider TEXT NOT NULL DEFAULT \'twilio\', is_reserve_schedule_enabled INTEGER, reserve_term_years INTEGER, inflation_rate_bps INTEGER, default_timezone TEXT NOT NULL DEFAULT \'UTC\', booking_slot_mode TEXT NOT NULL DEFAULT \'fixed\', booking_slot_interval_min INTEGER NOT NULL DEFAULT 30, holiday_region TEXT, holiday_public_policy TEXT NOT NULL DEFAULT \'open\', holiday_internal_policy TEXT NOT NULL DEFAULT \'advisory\', default_locale TEXT NOT NULL DEFAULT \'en-US\', currency TEXT NOT NULL DEFAULT \'USD\', is_archive_revoking_access INTEGER NOT NULL DEFAULT 0, legal_mode TEXT NOT NULL DEFAULT \'hosted\', custom_privacy_url TEXT, custom_terms_url TEXT, privacy_body TEXT, terms_body TEXT, date_format TEXT NOT NULL DEFAULT \'us\', time_format TEXT NOT NULL DEFAULT \'12h\', updated_at INTEGER);';
+ 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, company_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, video_mode TEXT, sms_mode TEXT, sender_display_name TEXT, point_of_contact TEXT, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, secrets_enc TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, default_profile_id TEXT, attention_thresholds TEXT, inspection_prefs TEXT, is_estimates_shown INTEGER, is_repair_list_enabled INTEGER, is_customer_repair_export_enabled INTEGER, is_unpaid_blocked INTEGER, is_unsigned_agreement_blocked INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, is_concierge_review_required INTEGER, is_inspector_choice_allowed INTEGER, is_pdf_pipeline_enabled INTEGER, auto_sign_on_publish_default INTEGER, is_team_mode_default TEXT, is_apprentice_review_required INTEGER, is_guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, reinspection_statuses TEXT, is_collab_editing_enabled INTEGER NOT NULL DEFAULT 1, company_address TEXT, is_pdf_footer_shown INTEGER, is_pdf_page_numbers_shown INTEGER, is_pdf_license_shown INTEGER, sms_byo_provider TEXT, email_byo_provider TEXT, is_managed_eligible INTEGER NOT NULL DEFAULT 0, managed_provider TEXT NOT NULL DEFAULT \'twilio\', is_reserve_schedule_enabled INTEGER, reserve_term_years INTEGER, inflation_rate_bps INTEGER, default_timezone TEXT NOT NULL DEFAULT \'UTC\', booking_slot_mode TEXT NOT NULL DEFAULT \'fixed\', booking_slot_interval_min INTEGER NOT NULL DEFAULT 30, holiday_region TEXT, holiday_public_policy TEXT NOT NULL DEFAULT \'open\', holiday_internal_policy TEXT NOT NULL DEFAULT \'advisory\', default_locale TEXT NOT NULL DEFAULT \'en-US\', currency TEXT NOT NULL DEFAULT \'USD\', is_archive_revoking_access INTEGER NOT NULL DEFAULT 0, legal_mode TEXT NOT NULL DEFAULT \'hosted\', custom_privacy_url TEXT, custom_terms_url TEXT, privacy_body TEXT, terms_body TEXT, date_format TEXT NOT NULL DEFAULT \'us\', time_format TEXT NOT NULL DEFAULT \'12h\', booking_conflict_policy TEXT NOT NULL DEFAULT \'advisory\', updated_at INTEGER);';
export const INSPECTION_RESULTS_TEST_DDL =
'CREATE TABLE IF NOT EXISTS inspection_results (id TEXT PRIMARY KEY NOT NULL, tenant_id TEXT NOT NULL, inspection_id TEXT NOT NULL, data TEXT NOT NULL, ydoc_state BLOB, last_synced_at INTEGER NOT NULL, rating_system_id TEXT, rating_system_snapshot TEXT, report_id TEXT);';
From b240d5fb02574bd7795737237728d11ace0573a7 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 07:06:01 +0800
Subject: [PATCH 086/111] feat(scheduling): inspection schedule PATCH endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A dispatch drag moves an inspection to 10:30, not to a day. The generic
PATCH /:id takes a civil date and derives the instant from it, which is
right for a settings sheet and backwards for a board: the instant is what
conflict detection reads. Here the epoch millisecond is authoritative and
the civil `date` column is derived from it in the tenant timezone, so the
two cannot diverge.
Gated on requireCapability('scheduleOthers'), not a role tier. Deciding
where someone else's day goes is exactly the act that capability names,
and it is toggleable in both directions — an inspector granted the
override may dispatch, a manager whose override was revoked may not. The
tests assert HTTP status off app.request for all three actors, and both
regressions were verified red: dropping the guard turns the inspector 403
into a 200, and replacing it with requireRole('owner','manager') turns the
overridden inspector's 200 into a 403.
Reuses findScheduleConflicts for interval overlap, and honors the new
booking_conflict_policy: `block` refuses with 409 and writes nothing,
`advisory` applies the write and returns the overlaps in the payload so
the caller can warn without a second round trip. The closed-day rule the
create path enforces applies here too — a board that could sidestep
holidayInternalPolicy by dragging would make that setting a suggestion.
Assignment is intent, not a copy of columns: absent keys mean "leave it
alone", so the current roster supplies the other half of the full-replace
sync (sending only a lead used to be the way to silently drop helpers).
No field carries a zod .default() — .default() survives .partial(), so a
defaulted durationMin would overwrite a booked duration the caller never
sent. The test asserts the KEY is absent, not its value.
---
server/api/inspections.ts | 4 +
server/api/inspections/schedule.ts | 236 ++++++++++++
server/lib/audit.ts | 1 +
server/lib/mcp/openapi-snapshot.json | 30 ++
server/lib/validations/schedule.schema.ts | 52 +++
tests/unit/inspections/schedule-patch.spec.ts | 360 ++++++++++++++++++
6 files changed, 683 insertions(+)
create mode 100644 server/api/inspections/schedule.ts
create mode 100644 server/lib/validations/schedule.schema.ts
create mode 100644 tests/unit/inspections/schedule-patch.spec.ts
diff --git a/server/api/inspections.ts b/server/api/inspections.ts
index 47301b8bd..6ed4b1062 100644
--- a/server/api/inspections.ts
+++ b/server/api/inspections.ts
@@ -23,6 +23,7 @@ import { createApiRouter } from '../lib/openapi-router';
import templatesRoutes from './inspections/templates';
import hierarchyRoutes from './inspections/hierarchy';
import bulkRoutes from './inspections/bulk';
+import scheduleRoutes from './inspections/schedule';
import mediaRoutes from './inspections/media';
import mediaStudioRoutes from './inspections/media-studio';
import publishRoutes from './inspections/publish';
@@ -43,6 +44,9 @@ import inspectionReportRoutes from './inspections/reports';
export const inspectionsRoutes = createApiRouter()
.route('/', bulkRoutes)
+ // Dispatch Phase C — PATCH /:id/schedule, the instant-authoritative
+ // reschedule + reassign write behind requireCapability('scheduleOthers').
+ .route('/', scheduleRoutes)
.route('/', templatesRoutes)
.route('/', coreRoutes)
.route('/', resultsRoutes)
diff --git a/server/api/inspections/schedule.ts b/server/api/inspections/schedule.ts
new file mode 100644
index 000000000..303a3f6bb
--- /dev/null
+++ b/server/api/inspections/schedule.ts
@@ -0,0 +1,236 @@
+// PATCH /api/inspections/:id/schedule — move an inspection in time and across
+// people in ONE write.
+//
+// Why this is not the generic PATCH /{id}: that route takes a civil `date` and
+// derives the instant from it, which is the right shape for a settings sheet
+// but the wrong one for a dispatch board. A board drags to 10:30, not to a day,
+// and the instant is what conflict detection reads. Here the epoch millisecond
+// is AUTHORITATIVE and the civil `date` column is derived from it in the tenant
+// timezone, so the two can never diverge.
+//
+// The guard is `requireCapability('scheduleOthers')`, not a role test. Deciding
+// where someone else's day goes is exactly the act that capability names, and
+// it is TOGGLEABLE: an inspector granted the override may dispatch, and a
+// manager whose override was revoked may not. A role tier cannot express either.
+import { createRoute, z } from '@hono/zod-openapi';
+import { and, eq } from 'drizzle-orm';
+import { createApiRouter } from '../../lib/openapi-router';
+import { requireRole } from '../../lib/middleware/rbac';
+import { requireCapability } from '../../lib/middleware/require-capability';
+import { auditFromContext } from '../../lib/audit';
+import { Errors } from '../../lib/errors';
+import { inspections as inspectionTable, tenantConfigs, users } from '../../lib/db/schema';
+import { getInspectionRoster } from '../../lib/inspection/roster';
+import { syncInspectionAssignments } from '../../lib/db/assignment-links';
+import { findScheduleConflicts } from '../../lib/schedule-conflicts';
+import { resolveInternalHolidayEffect } from '../../lib/holidays/load-tenant-holidays';
+import { epochMsToWallClockHm, epochMsToWallClockYmd, resolveTenantTimeZone } from '../../lib/tz';
+import { withMcpMetadata } from '../../lib/route-metadata-standards';
+import { getDrizzle } from '../../lib/route-helpers';
+import {
+ ReschedulePatchSchema,
+ RescheduleResponseSchema,
+ ScheduleErrorSchema,
+} from '../../lib/validations/schedule.schema';
+
+const scheduleRoute = createRoute(withMcpMetadata({
+ method: 'patch',
+ path: '/{id}/schedule',
+ tags: ['inspections'],
+ summary: 'Reschedule and/or reassign one inspection',
+ description: 'Moves an inspection to a precise instant and optionally changes who works it. The epoch-millisecond start is authoritative; the civil date is derived from it in the tenant timezone. Returns 409 when the tenant booking_conflict_policy is "block" and the resulting assignment overlaps existing work.',
+ request: {
+ params: z.object({
+ id: z.string().trim().min(1).describe('Inspection id to reschedule.'),
+ }).describe('Path parameters.'),
+ body: {
+ content: { 'application/json': { schema: ReschedulePatchSchema } },
+ },
+ },
+ middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('scheduleOthers')] as const,
+ responses: {
+ 200: {
+ content: { 'application/json': { schema: RescheduleResponseSchema } },
+ description: 'Rescheduled; any advisory conflicts ride along in the payload',
+ },
+ 400: {
+ content: { 'application/json': { schema: ScheduleErrorSchema } },
+ description: 'Unknown inspector for this tenant, or a blocked company holiday',
+ },
+ 404: {
+ content: { 'application/json': { schema: ScheduleErrorSchema } },
+ description: 'No such inspection in this tenant',
+ },
+ 409: {
+ content: { 'application/json': { schema: ScheduleErrorSchema } },
+ description: 'Overlap detected and the tenant booking_conflict_policy is "block"',
+ },
+ },
+ operationId: 'rescheduleInspection',
+}, { scopes: ['write'], tier: 'extended', capability: 'scheduleOthers' }));
+
+const toMs = (v: unknown): number | null =>
+ v instanceof Date ? v.getTime() : v == null ? null : Number(v);
+
+const scheduleRoutes = createApiRouter()
+ .openapi(scheduleRoute, async (c) => {
+ const { id } = c.req.valid('param');
+ const body = c.req.valid('json');
+ const tenantId = c.get('tenantId');
+ const db = getDrizzle(c);
+
+ const row = await db.select({
+ date: inspectionTable.date,
+ scheduledStartMs: inspectionTable.scheduledStartMs,
+ scheduledEndMs: inspectionTable.scheduledEndMs,
+ durationMin: inspectionTable.durationMin,
+ })
+ .from(inspectionTable)
+ .where(and(eq(inspectionTable.id, id), eq(inspectionTable.tenantId, tenantId)))
+ .get();
+ if (!row) throw Errors.NotFound('Inspection not found');
+
+ const cfg = await db.select({
+ defaultTimezone: tenantConfigs.defaultTimezone,
+ bookingConflictPolicy: tenantConfigs.bookingConflictPolicy,
+ })
+ .from(tenantConfigs)
+ .where(eq(tenantConfigs.tenantId, tenantId))
+ .get();
+ const tz = resolveTenantTimeZone(cfg?.defaultTimezone);
+ const policy = cfg?.bookingConflictPolicy === 'block' ? 'block' : 'advisory';
+
+ const startMs = body.scheduledStartMs;
+ const civilDate = epochMsToWallClockYmd(startMs, tz);
+ const hm = epochMsToWallClockHm(startMs, tz);
+
+ // Same closed-day rule the create path enforces. A reschedule ONTO a
+ // blocked company holiday is the same act as booking one, and a board
+ // that could sidestep the policy by dragging would make the setting a
+ // suggestion.
+ const holiday = await resolveInternalHolidayEffect(c.env.DB, tenantId, civilDate);
+ if (holiday.effect === 'block') {
+ return c.json({
+ success: false as const,
+ error: {
+ code: 'HOLIDAY_BLOCKED',
+ message: holiday.name
+ ? `Cannot schedule on ${holiday.name} — company holidays are blocked.`
+ : 'Cannot schedule on a company closed day.',
+ },
+ }, 400);
+ }
+
+ // Assignment intent. Absent keys mean "leave it alone", so the current
+ // roster supplies the other half — syncInspectionAssignments is a FULL
+ // REPLACE, and passing only the lead would silently drop the helpers.
+ const touchesAssignment = body.leadInspectorId !== undefined || body.helperInspectorIds !== undefined;
+ const roster = await getInspectionRoster(db, tenantId, id);
+ const leadId = body.leadInspectorId !== undefined
+ ? body.leadInspectorId
+ : roster.lead?.id ?? null;
+ const helperIds = body.helperInspectorIds ?? roster.helpers.map((h) => h.id);
+
+ // An id naming a user is not the same as an id naming one of OUR users.
+ // Resolve every proposed assignee inside the caller's tenant before it
+ // reaches the link table, exactly as the generic PATCH resolves
+ // inspectorId (a UUID from another tenant is still a UUID).
+ if (touchesAssignment) {
+ for (const candidate of [leadId, ...helperIds]) {
+ if (!candidate) continue;
+ const member = await db.select({ id: users.id }).from(users)
+ .where(and(eq(users.id, candidate), eq(users.tenantId, tenantId)))
+ .get();
+ if (!member) {
+ return c.json({
+ success: false as const,
+ error: { code: 'INVALID_INSPECTOR', message: 'An assignee is not a member of this tenant' },
+ }, 400);
+ }
+ }
+ }
+
+ // Duration resolution, most specific first: what the caller sent, else
+ // the span the row already carried, else its stored durationMin. Nothing
+ // is invented — a row with no resolvable duration keeps a null end and
+ // conflict detection degrades to the hour bucket, which is what it
+ // already did for that row.
+ const oldStart = toMs(row.scheduledStartMs);
+ const oldEnd = toMs(row.scheduledEndMs);
+ const spanMin = oldStart != null && oldEnd != null ? Math.round((oldEnd - oldStart) / 60_000) : null;
+ const durationMin = body.durationMin ?? spanMin ?? row.durationMin ?? null;
+ const endMs = durationMin != null ? startMs + durationMin * 60_000 : null;
+
+ const conflicts: Array<{ inspectionId: string; propertyAddress: string; date: string; inspectorId: string }> = [];
+ const assignees = [leadId, ...helperIds].filter((v): v is string => Boolean(v));
+ for (const inspectorId of assignees) {
+ const found = await findScheduleConflicts(
+ db,
+ tenantId,
+ inspectorId,
+ `${civilDate}T${hm}`,
+ id,
+ { startMs, endMs },
+ );
+ for (const hit of found) conflicts.push({ ...hit, inspectorId });
+ }
+
+ if (policy === 'block' && conflicts.length > 0) {
+ return c.json({
+ success: false as const,
+ error: {
+ code: 'SCHEDULE_CONFLICT',
+ message: 'That slot overlaps existing work and this company blocks double-booking.',
+ conflicts,
+ },
+ }, 409);
+ }
+
+ // Rows whose `date` carried a time suffix keep one — the HH:MM busy
+ // checks read it via slice(11,16), and truncating here would blind them.
+ const dateValue = row.date.length > 10 ? `${civilDate}T${hm}` : civilDate;
+ const values: Record = {
+ date: dateValue,
+ scheduledStartMs: new Date(startMs),
+ scheduledEndMs: endMs != null ? new Date(endMs) : null,
+ };
+ if (durationMin != null) values.durationMin = durationMin;
+ // The legacy column is not a second authority, but it IS the fallback
+ // readers use when the link table has no row — so an explicit unassign
+ // has to clear it, or the board would keep showing the old inspector on
+ // a card it just dropped into the unassigned lane.
+ if (body.leadInspectorId !== undefined) values.inspectorId = leadId;
+
+ await db.update(inspectionTable).set(values)
+ .where(and(eq(inspectionTable.id, id), eq(inspectionTable.tenantId, tenantId)));
+
+ if (touchesAssignment) {
+ await syncInspectionAssignments(db, tenantId, id, {
+ leadInspectorId: leadId,
+ helperInspectorIds: helperIds,
+ });
+ }
+
+ auditFromContext(c, 'inspection.rescheduled', 'inspection', {
+ entityId: id,
+ metadata: {
+ from: { date: row.date, scheduledStartMs: oldStart },
+ to: { date: dateValue, scheduledStartMs: startMs },
+ ...(touchesAssignment ? { leadInspectorId: leadId } : {}),
+ conflicts: conflicts.length,
+ },
+ });
+
+ return c.json({
+ success: true as const,
+ data: {
+ date: dateValue,
+ scheduledStartMs: startMs,
+ scheduledEndMs: endMs,
+ durationMin,
+ conflicts,
+ },
+ }, 200);
+ });
+
+export default scheduleRoutes;
diff --git a/server/lib/audit.ts b/server/lib/audit.ts
index 1a2ca038e..8ee31844b 100644
--- a/server/lib/audit.ts
+++ b/server/lib/audit.ts
@@ -19,6 +19,7 @@ export type AuditAction =
| 'inspection.report_relocked'
| 'inspection.send_sms'
| 'inspection.send_text_fallback'
+ | 'inspection.rescheduled'
| 'inspection.bulk_assign'
| 'inspection.bulk_status'
| 'inspection.template_upgraded'
diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json
index 5a8c09fc7..4bad74ca2 100644
--- a/server/lib/mcp/openapi-snapshot.json
+++ b/server/lib/mcp/openapi-snapshot.json
@@ -16119,6 +16119,36 @@
"summary": "Request a password reset email",
"description": "Triggers a password reset email if the account exists. Always returns 200 even for unknown emails to avoid account enumeration."
},
+ {
+ "operationId": "rescheduleInspection",
+ "method": "PATCH",
+ "pathTemplate": "/api/inspections/{id}/schedule",
+ "scopes": [
+ "write"
+ ],
+ "tag": "inspections",
+ "tier": "extended",
+ "inputSchema": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Inspection id to reschedule.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Inspection id to reschedule."
+ }
+ }
+ ],
+ "body": {
+ "$ref": "#/components/schemas/ReschedulePatch"
+ }
+ },
+ "summary": "Reschedule and/or reassign one inspection",
+ "description": "Moves an inspection to a precise instant and optionally changes who works it. The epoch-millisecond start is authoritative; the civil date is derived from it in the tenant timezone. Returns 409 when the tenant booking_conflict_policy is \"block\" and the resulting assignment overlaps existing work."
+ },
{
"operationId": "resendTeamInvite",
"method": "POST",
diff --git a/server/lib/validations/schedule.schema.ts b/server/lib/validations/schedule.schema.ts
new file mode 100644
index 000000000..ee900afe9
--- /dev/null
+++ b/server/lib/validations/schedule.schema.ts
@@ -0,0 +1,52 @@
+import { z } from '@hono/zod-openapi';
+
+/**
+ * PATCH /api/inspections/:id/schedule — the one write that moves an inspection
+ * in TIME and across PEOPLE at once, which is what a dispatch drag is.
+ *
+ * No field carries `.default()`, deliberately. This is a partial write: an
+ * absent `durationMin` means "leave the booked duration alone", and a zod
+ * default would silently turn that silence into an overwrite of a value the
+ * caller never sent. Every optional is therefore distinguishable by KEY
+ * PRESENCE, and the handler branches on `!== undefined` / `in body` rather than
+ * on the value.
+ */
+export const ReschedulePatchSchema = z.object({
+ scheduledStartMs: z.number().int().positive()
+ .describe('New scheduled start, epoch milliseconds. Authoritative: the civil `date` column is DERIVED from it in the tenant timezone, so the two can never diverge the way a date-only PATCH allowed.'),
+ durationMin: z.number().int().min(5).max(1440).optional()
+ .describe('New duration in minutes. Omit to preserve the existing booked duration (the end moves with the start).'),
+ leadInspectorId: z.string().min(1).nullable().optional()
+ .describe('Reassign the lead inspector. null unassigns (the dispatch board drops the card back to the unassigned lane). Omit to leave assignment untouched.'),
+ helperInspectorIds: z.array(z.string().min(1)).max(20).optional()
+ .describe('Replace the helper list wholesale. Omit to keep the current helpers — this is NOT merged.'),
+}).openapi('ReschedulePatch');
+
+export const ScheduleConflictSchema = z.object({
+ inspectionId: z.string().describe('Colliding inspection id.'),
+ propertyAddress: z.string().describe('Colliding inspection address.'),
+ date: z.string().describe('Colliding inspection date.'),
+ inspectorId: z.string().describe('The assigned inspector the collision belongs to.'),
+}).openapi('ScheduleConflict');
+
+export const RescheduleResponseSchema = z.object({
+ success: z.boolean().describe('Whether the request succeeded.'),
+ data: z.object({
+ date: z.string().describe('Stored civil date after the write.'),
+ scheduledStartMs: z.number().describe('Stored scheduled start, epoch milliseconds.'),
+ scheduledEndMs: z.number().nullable().describe('Stored scheduled end, epoch milliseconds; null when no duration could be resolved.'),
+ durationMin: z.number().nullable().describe('Stored duration in minutes; null when unknown.'),
+ conflicts: z.array(ScheduleConflictSchema)
+ .describe('Overlaps detected for the resulting assignment. Non-empty here means the tenant policy is `advisory` and the write WAS applied; a `block` tenant gets 409 instead.'),
+ }).describe('Reschedule result.'),
+}).openapi('RescheduleResponse');
+
+export const ScheduleErrorSchema = z.object({
+ success: z.boolean().describe('Always false.'),
+ error: z.object({
+ code: z.string().describe('Machine-readable error code.'),
+ message: z.string().describe('Human-readable message.'),
+ conflicts: z.array(ScheduleConflictSchema).optional()
+ .describe('Present on SCHEDULE_CONFLICT so the caller can render the blocking overlaps without a second round trip.'),
+ }).describe('Error payload.'),
+}).openapi('ScheduleError');
diff --git a/tests/unit/inspections/schedule-patch.spec.ts b/tests/unit/inspections/schedule-patch.spec.ts
new file mode 100644
index 000000000..323e56bed
--- /dev/null
+++ b/tests/unit/inspections/schedule-patch.spec.ts
@@ -0,0 +1,360 @@
+/**
+ * PATCH /api/inspections/:id/schedule — the dispatch write.
+ *
+ * These are HTTP-level tests against `app.request`, not `createRoutesStub` and
+ * not direct handler calls, because the thing most worth pinning here is a
+ * MIDDLEWARE decision: the route is gated by `requireCapability('scheduleOthers')`
+ * rather than by a role tier. A test that bypasses middleware would return 200
+ * for every actor and prove nothing — so the assertions are status codes, and
+ * the interesting one is that an INSPECTOR with `{scheduleOthers: true}` gets
+ * the same 200 an owner does. A role check cannot produce that.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import { and, eq } from 'drizzle-orm';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { HonoConfig } from '../../../server/types/hono';
+import type { UserRole } from '../../../server/types/auth';
+import { AppError } from '../../../server/lib/errors';
+import { INSPECTION_STATUS } from '../../../server/lib/status/inspection-status';
+import { REPORT_STATUS } from '../../../server/lib/status/report-status';
+import { ReschedulePatchSchema } from '../../../server/lib/validations/schedule.schema';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+// eslint-disable-next-line import/order
+import { inspectionsRoutes } from '../../../server/api/inspections';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const OTHER_TENANT = '00000000-0000-0000-0000-0000000000ff';
+const ACTOR = '00000000-0000-0000-0000-000000000099';
+const LEAD_A = '00000000-0000-0000-0000-0000000000a1';
+const LEAD_B = '00000000-0000-0000-0000-0000000000b2';
+const HELPER = '00000000-0000-0000-0000-0000000000c3';
+const INSP_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+const OTHER_INSP = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
+
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+// 2026-06-01 09:00Z. The tenant timezone is UTC in these fixtures, so the
+// derived civil date is unambiguous and the assertions stay about scheduling,
+// not about zone math (which reschedule-dual-write.spec.ts already covers).
+const START_MS = Date.UTC(2026, 5, 1, 9, 0, 0);
+
+type Overrides = Record | null;
+
+function buildApp(
+ db: BetterSQLite3Database,
+ role: UserRole,
+ overrides: Overrides = null,
+) {
+ (mockDrizzle as ReturnType).mockReturnValue(db);
+ const app = new OpenAPIHono();
+
+ app.onError((err, c) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT);
+ c.set('userRole', role);
+ c.set('user', { sub: ACTOR, role, tenantId: TENANT });
+ c.set('sdb', {
+ getById: async () => ({ permissionOverrides: overrides }),
+ } as unknown as HonoConfig['Variables']['sdb']);
+ c.set('services', {} as unknown as HonoConfig['Variables']['services']);
+ await next();
+ });
+
+ app.route('/api/inspections', inspectionsRoutes);
+ return app;
+}
+
+function patch(
+ app: ReturnType,
+ body: Record,
+ id: string = INSP_ID,
+) {
+ return app.request(`/api/inspections/${id}/schedule`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }, FAKE_ENV);
+}
+
+async function seedInspection(
+ db: BetterSQLite3Database,
+ overrides: Partial = {},
+) {
+ await db.insert(schema.inspections).values({
+ id: INSP_ID,
+ tenantId: TENANT,
+ propertyAddress: '1 Main St',
+ date: '2026-06-01',
+ status: INSPECTION_STATUS.SCHEDULED,
+ reportStatus: REPORT_STATUS.IN_PROGRESS,
+ paymentStatus: 'unpaid',
+ price: 0,
+ paymentRequired: false,
+ agreementRequired: false,
+ createdAt: new Date(),
+ ...overrides,
+ });
+}
+
+async function readRow(db: BetterSQLite3Database, id = INSP_ID) {
+ return db.select({
+ date: schema.inspections.date,
+ inspectorId: schema.inspections.inspectorId,
+ scheduledStartMs: schema.inspections.scheduledStartMs,
+ scheduledEndMs: schema.inspections.scheduledEndMs,
+ durationMin: schema.inspections.durationMin,
+ }).from(schema.inspections).where(eq(schema.inspections.id, id)).get();
+}
+
+async function readAssignments(db: BetterSQLite3Database, id = INSP_ID) {
+ return db.select({
+ userId: schema.inspectionInspectors.userId,
+ role: schema.inspectionInspectors.role,
+ })
+ .from(schema.inspectionInspectors)
+ .where(and(
+ eq(schema.inspectionInspectors.inspectionId, id),
+ eq(schema.inspectionInspectors.tenantId, TENANT),
+ ))
+ .all();
+}
+
+describe('PATCH /api/inspections/:id/schedule', () => {
+ let db: BetterSQLite3Database;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db as BetterSQLite3Database;
+ await setupSchema(fixture.sqlite);
+ await db.insert(schema.tenants).values([
+ { id: TENANT, name: 'Acme', slug: 'acme', status: 'active', deploymentMode: 'shared', tier: 'free', createdAt: new Date() },
+ { id: OTHER_TENANT, name: 'Rival', slug: 'rival', status: 'active', deploymentMode: 'shared', tier: 'free', createdAt: new Date() },
+ ]);
+ await db.insert(schema.users).values([
+ { id: ACTOR, tenantId: TENANT, email: 'actor@example.com', passwordHash: 'h', createdAt: new Date() },
+ { id: LEAD_A, tenantId: TENANT, email: 'a@example.com', passwordHash: 'h', createdAt: new Date() },
+ { id: LEAD_B, tenantId: TENANT, email: 'b@example.com', passwordHash: 'h', createdAt: new Date() },
+ { id: HELPER, tenantId: TENANT, email: 'c@example.com', passwordHash: 'h', createdAt: new Date() },
+ ]);
+ await db.insert(schema.tenantConfigs).values({
+ tenantId: TENANT,
+ defaultTimezone: 'UTC',
+ updatedAt: new Date(),
+ });
+ });
+
+ // ── the capability gate ──────────────────────────────────────────────────
+
+ it('owner → 200', async () => {
+ await seedInspection(db);
+ const res = await patch(buildApp(db, 'owner'), { scheduledStartMs: START_MS });
+ expect(res.status).toBe(200);
+ });
+
+ it('inspector with no override → 403 (scheduleOthers is false by role default)', async () => {
+ await seedInspection(db);
+ const res = await patch(buildApp(db, 'inspector'), { scheduledStartMs: START_MS });
+ expect(res.status).toBe(403);
+ const body = await res.json() as { error?: { message?: string } };
+ expect(body.error?.message).toContain('scheduleOthers');
+ });
+
+ it('inspector WITH the scheduleOthers override → 200', async () => {
+ // The whole point of gating on a capability rather than a role tier:
+ // this actor's ROLE is unchanged and still fails an isAdminRole test,
+ // yet the toggle grants the action. If the route ever regresses to a
+ // role check, this is the case that goes red.
+ await seedInspection(db);
+ const res = await patch(buildApp(db, 'inspector', { scheduleOthers: true }), { scheduledStartMs: START_MS });
+ expect(res.status).toBe(200);
+ });
+
+ it('agent → 403 (outside the role gate entirely)', async () => {
+ await seedInspection(db);
+ const res = await patch(buildApp(db, 'agent'), { scheduledStartMs: START_MS });
+ expect(res.status).toBe(403);
+ });
+
+ // ── the write ────────────────────────────────────────────────────────────
+
+ it('writes the instant, derives the civil date, and moves the end with it', async () => {
+ await seedInspection(db, {
+ date: '2026-05-20',
+ scheduledStartMs: new Date(Date.UTC(2026, 4, 20, 14, 0, 0)),
+ scheduledEndMs: new Date(Date.UTC(2026, 4, 20, 17, 0, 0)),
+ });
+ const res = await patch(buildApp(db, 'owner'), { scheduledStartMs: START_MS });
+ expect(res.status).toBe(200);
+
+ const row = await readRow(db);
+ expect(row?.date).toBe('2026-06-01');
+ expect(row?.scheduledStartMs?.getTime()).toBe(START_MS);
+ // 3h span preserved, not recomputed from a default.
+ expect(row?.scheduledEndMs?.getTime()).toBe(START_MS + 180 * 60_000);
+ });
+
+ it('reassigns the lead and clears the legacy inspector column on unassign', async () => {
+ await seedInspection(db, { inspectorId: LEAD_A });
+ const app = buildApp(db, 'owner');
+
+ expect((await patch(app, { scheduledStartMs: START_MS, leadInspectorId: LEAD_B })).status).toBe(200);
+ expect(await readAssignments(db)).toEqual([{ userId: LEAD_B, role: 'lead' }]);
+ expect((await readRow(db))?.inspectorId).toBe(LEAD_B);
+
+ expect((await patch(app, { scheduledStartMs: START_MS, leadInspectorId: null })).status).toBe(200);
+ expect(await readAssignments(db)).toEqual([]);
+ // The link table is authoritative, but `inspections.inspector_id` is the
+ // fallback readers use when it is empty — leaving it set would put the
+ // card back on the old inspector's column right after it was dragged off.
+ expect((await readRow(db))?.inspectorId).toBeNull();
+ });
+
+ it('keeps the helpers when only the lead is sent', async () => {
+ await seedInspection(db);
+ // Adverse order on purpose: the helper row is inserted BEFORE the lead
+ // row, so a roster read that happened to trust insertion order would
+ // pick the wrong person and this would fail rather than pass by luck.
+ await db.insert(schema.inspectionInspectors).values([
+ { inspectionId: INSP_ID, userId: HELPER, tenantId: TENANT, role: 'helper', createdAt: new Date() },
+ { inspectionId: INSP_ID, userId: LEAD_A, tenantId: TENANT, role: 'lead', createdAt: new Date() },
+ ]);
+
+ const res = await patch(buildApp(db, 'owner'), { scheduledStartMs: START_MS, leadInspectorId: LEAD_B });
+ expect(res.status).toBe(200);
+
+ const rows = await readAssignments(db);
+ expect(rows.find((r) => r.role === 'lead')?.userId).toBe(LEAD_B);
+ expect(rows.filter((r) => r.role === 'helper').map((r) => r.userId)).toEqual([HELPER]);
+ });
+
+ it('rejects an assignee from another tenant with 400', async () => {
+ await seedInspection(db);
+ await db.insert(schema.users).values({
+ id: '00000000-0000-0000-0000-0000000000d4',
+ tenantId: OTHER_TENANT,
+ email: 'foreign@example.com',
+ passwordHash: 'h',
+ createdAt: new Date(),
+ });
+ const res = await patch(buildApp(db, 'owner'), {
+ scheduledStartMs: START_MS,
+ leadInspectorId: '00000000-0000-0000-0000-0000000000d4',
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it('404s an inspection belonging to another tenant', async () => {
+ await db.insert(schema.inspections).values({
+ id: OTHER_INSP,
+ tenantId: OTHER_TENANT,
+ propertyAddress: '9 Rival Rd',
+ date: '2026-06-01',
+ status: INSPECTION_STATUS.SCHEDULED,
+ reportStatus: REPORT_STATUS.IN_PROGRESS,
+ paymentStatus: 'unpaid',
+ price: 0,
+ paymentRequired: false,
+ agreementRequired: false,
+ createdAt: new Date(),
+ });
+ const res = await patch(buildApp(db, 'owner'), { scheduledStartMs: START_MS }, OTHER_INSP);
+ expect(res.status).toBe(404);
+ expect((await readRow(db, OTHER_INSP))?.date).toBe('2026-06-01');
+ });
+
+ // ── booking_conflict_policy ──────────────────────────────────────────────
+
+ async function seedOverlap() {
+ await db.insert(schema.inspections).values({
+ id: OTHER_INSP,
+ tenantId: TENANT,
+ propertyAddress: '2 Other St',
+ date: '2026-06-01',
+ status: INSPECTION_STATUS.SCHEDULED,
+ reportStatus: REPORT_STATUS.IN_PROGRESS,
+ paymentStatus: 'unpaid',
+ price: 0,
+ paymentRequired: false,
+ agreementRequired: false,
+ createdAt: new Date(),
+ scheduledStartMs: new Date(START_MS),
+ scheduledEndMs: new Date(START_MS + 60 * 60_000),
+ });
+ await db.insert(schema.inspectionInspectors).values({
+ inspectionId: OTHER_INSP, userId: LEAD_A, tenantId: TENANT, role: 'lead', createdAt: new Date(),
+ });
+ }
+
+ it('advisory policy → 200 and the overlap rides along in the payload', async () => {
+ await seedInspection(db, { date: '2026-05-20' });
+ await seedOverlap();
+ const res = await patch(buildApp(db, 'owner'), {
+ scheduledStartMs: START_MS, durationMin: 60, leadInspectorId: LEAD_A,
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json() as { data: { conflicts: Array<{ inspectionId: string; inspectorId: string }> } };
+ expect(body.data.conflicts).toEqual([{
+ inspectionId: OTHER_INSP,
+ propertyAddress: '2 Other St',
+ date: '2026-06-01',
+ inspectorId: LEAD_A,
+ }]);
+ expect((await readRow(db))?.date).toBe('2026-06-01');
+ });
+
+ it('block policy → 409 and nothing is written', async () => {
+ await db.update(schema.tenantConfigs)
+ .set({ bookingConflictPolicy: 'block' })
+ .where(eq(schema.tenantConfigs.tenantId, TENANT));
+ await seedInspection(db, { date: '2026-05-20' });
+ await seedOverlap();
+
+ const res = await patch(buildApp(db, 'owner'), {
+ scheduledStartMs: START_MS, durationMin: 60, leadInspectorId: LEAD_A,
+ });
+ expect(res.status).toBe(409);
+ const body = await res.json() as { error: { code: string; conflicts: unknown[] } };
+ expect(body.error.code).toBe('SCHEDULE_CONFLICT');
+ expect(body.error.conflicts).toHaveLength(1);
+
+ const row = await readRow(db);
+ expect(row?.date).toBe('2026-05-20');
+ expect(row?.scheduledStartMs).toBeNull();
+ expect(await readAssignments(db)).toEqual([]);
+ });
+
+ // ── the partial-write trap ───────────────────────────────────────────────
+
+ it('leaves durationMin ABSENT when the caller omits it', () => {
+ // zod `.default()` survives `.partial()`, so a schema that defaulted
+ // durationMin would hand the handler a value the caller never sent and
+ // the handler would dutifully write it over the booked duration. The
+ // assertion is on the KEY, not on its value — a value assertion passes
+ // against exactly the bug it is meant to catch.
+ const parsed = ReschedulePatchSchema.parse({ scheduledStartMs: START_MS });
+ expect('durationMin' in parsed).toBe(false);
+ expect('leadInspectorId' in parsed).toBe(false);
+ expect('helperInspectorIds' in parsed).toBe(false);
+ });
+
+ it('preserves the stored durationMin across a duration-less reschedule', async () => {
+ await seedInspection(db, { date: '2026-05-20', durationMin: 240 });
+ const res = await patch(buildApp(db, 'owner'), { scheduledStartMs: START_MS });
+ expect(res.status).toBe(200);
+ const row = await readRow(db);
+ expect(row?.durationMin).toBe(240);
+ expect(row?.scheduledEndMs?.getTime()).toBe(START_MS + 240 * 60_000);
+ });
+});
From d4cb244f8564de90f8f818fa488f74c92e02162b Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 07:27:18 +0800
Subject: [PATCH 087/111] feat(dispatch): dispatch route loader
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The board needs four things about one day — who the columns are, every
item on it, which inspections nobody owns, and what this company wants
done about a double-booking. Assembling those in the browser would be
three sequential loader fetches for a view whose whole point is that a day
arrives at a glance, so GET /api/calendar/dispatch returns them together.
It lives beside the items feed because it IS the items feed: the same
listCalendarItems call, one day wide. The one thing layered on top is the
scheduled instant — the feed reports inspections as all-day, which is all
a month grid needs and not enough to place a card on a time axis. That
lookup is keyed by DATE, not by inArray(ids): a busy day can exceed D1's
100-bind-param ceiling, while a date predicate costs two binds at any
volume, and every column is projected explicitly so the 100-column result
cap is never in play.
"Unassigned" is read as the absence of a userId on the item, which the
items service already resolves through the link table with the legacy
inspector_id column as its fallback. Re-deriving that rule here would be a
second place for it to drift — the test pins it by seeding a legacy row
whose assignment lives only on the column, which a link-table-only filter
would wrongly sweep into the lane (verified red).
Gated on requireCapability('scheduleOthers'), matching the write it feeds:
reading the whole team's day and rearranging it are the same privilege.
The 403 is proven by dropping the guard and watching the inspector case
turn 200. The route module exports a loader and no component on purpose —
the board UI is the next task, and this lands the data contract on its own.
---
app/routes.ts | 4 +
app/routes/calendar-dispatch.tsx | 82 ++++++++
server/api/calendar-items.ts | 146 ++++++++++++-
server/lib/mcp/openapi-snapshot.json | 28 +++
.../lib/validations/calendar-items.schema.ts | 30 +++
tests/unit/calendar/dispatch-board.spec.ts | 191 ++++++++++++++++++
6 files changed, 477 insertions(+), 4 deletions(-)
create mode 100644 app/routes/calendar-dispatch.tsx
create mode 100644 tests/unit/calendar/dispatch-board.spec.ts
diff --git a/app/routes.ts b/app/routes.ts
index 7a8033f6a..dbf2b8273 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -130,6 +130,10 @@ export default [
// reproducing this layout's container by hand, which is now removed.
route("inspections/:id", "routes/inspector-portal.tsx"),
route("calendar", "routes/calendar.tsx"),
+ // Day-centric dispatch board. Static `dispatch` sits under the calendar
+ // path but is its own route, not a mode of /calendar: the audience is
+ // narrower (scheduleOthers, enforced server-side) and so is the data.
+ route("calendar/dispatch", "routes/calendar-dispatch.tsx"),
route("contacts", "routes/contacts.tsx"),
// IA-18 (#111) — contact detail (record + inspection history + stats).
route("contacts/:id", "routes/contact-detail.tsx"),
diff --git a/app/routes/calendar-dispatch.tsx b/app/routes/calendar-dispatch.tsx
new file mode 100644
index 000000000..f929704b6
--- /dev/null
+++ b/app/routes/calendar-dispatch.tsx
@@ -0,0 +1,82 @@
+/**
+ * /calendar/dispatch — the dispatch board's DATA half.
+ *
+ * This module deliberately exports a loader and no component yet: the board UI
+ * (DispatchBoard, the unassigned lane, drag-drop) is the next task, and landing
+ * the data contract first means it can be reviewed on its own terms. Adding the
+ * default export is that task's first step.
+ *
+ * The gate is a redirect, not an error page. Whether the actor may dispatch is
+ * decided on the server — `GET /api/calendar/dispatch` mounts
+ * requireCapability('scheduleOthers'), the same guard as the reschedule write —
+ * and this loader simply honors its answer. That ordering matters: the page can
+ * never offer an action the API would refuse, because it never learns about the
+ * day at all unless the API already said yes.
+ */
+import { redirect } from "react-router";
+import type { Route } from "./+types/calendar-dispatch";
+import { requireToken } from "~/lib/session.server";
+import { createApi } from "~/lib/api-client.server";
+
+interface DispatchInspector {
+ id: string;
+ name: string | null;
+ email: string;
+ role: string;
+}
+
+interface DispatchItem {
+ id: string;
+ kind: string;
+ title: string;
+ start: string;
+ end: string;
+ civilDate: string;
+ startTime?: string;
+ endTime?: string;
+ allDay: boolean;
+ color?: string;
+ inspectionId?: string;
+ userId?: string;
+ meta?: Record;
+}
+
+interface DispatchPayload {
+ date: string;
+ conflictPolicy: "advisory" | "block";
+ inspectors: DispatchInspector[];
+ items: DispatchItem[];
+ unassigned: DispatchItem[];
+}
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const token = await requireToken(context, request);
+ const api = createApi(context, { token });
+
+ const requestedDate = new URL(request.url).searchParams.get("date");
+ // No client-side default for the day: today depends on the TENANT timezone,
+ // which the server resolves. Sending a browser-derived date would put a
+ // west-coast owner on tomorrow's board every evening.
+ const query: { date?: string } = {};
+ if (requestedDate) query.date = requestedDate;
+
+ const res = await api.calendar.dispatch
+ .$get({ query })
+ .catch(() => null);
+
+ // 403 = no scheduleOthers. Inspectors who followed a link land on their own
+ // calendar rather than a dead end.
+ if (res?.status === 403) throw redirect("/calendar");
+ if (!res?.ok) {
+ return {
+ failed: true as const,
+ board: null,
+ };
+ }
+
+ const body = (await res.json()) as { data?: DispatchPayload };
+ const board = body.data ?? null;
+ if (!board) return { failed: true as const, board: null };
+
+ return { failed: false as const, board };
+}
diff --git a/server/api/calendar-items.ts b/server/api/calendar-items.ts
index 1b7f9426a..d64baa948 100644
--- a/server/api/calendar-items.ts
+++ b/server/api/calendar-items.ts
@@ -1,17 +1,21 @@
import { createRoute } from '@hono/zod-openapi';
-import { eq } from 'drizzle-orm';
+import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';
import { requireRole } from '../lib/middleware/rbac';
+import { requireCapability } from '../lib/middleware/require-capability';
import { createApiRouter } from '../lib/openapi-router';
-import { tenantConfigs, users } from '../lib/db/schema';
-import { resolveTenantTimeZone } from '../lib/tz';
+import { inspections, tenantConfigs, users } from '../lib/db/schema';
+import { epochMsToWallClockHm, epochMsToWallClockYmd, resolveTenantTimeZone } from '../lib/tz';
import { withMcpMetadata } from '../lib/route-metadata-standards';
import {
CalendarItemsErrorSchema,
CalendarItemsResponseSchema,
+ DispatchBoardQuerySchema,
+ DispatchBoardResponseSchema,
ListCalendarItemsQuerySchema,
} from '../lib/validations/calendar-items.schema';
-import { listCalendarItems } from '../services/calendar-items.service';
+import { listCalendarItems, type CalendarItem } from '../services/calendar-items.service';
+import { getDrizzle } from '../lib/route-helpers';
import { isAdminRole } from '../lib/auth/roles';
/** Viewer's calendar display tz: their own override, else the tenant default. */
@@ -55,6 +59,44 @@ const listItemsRoute = createRoute(withMcpMetadata({
security: [{ bearerAuth: [] }],
}, { scopes: ['read'], tier: 'primary' }));
+/**
+ * GET /api/calendar/dispatch — one round trip for the whole board.
+ *
+ * It lives beside the items feed because it IS the items feed: the same
+ * `listCalendarItems` call, one day wide, plus the roster the columns are keyed
+ * by and the tenant's conflict policy. Assembling those three in the browser
+ * would mean three sequential loader fetches for a view whose whole point is
+ * that it renders a day at a glance.
+ *
+ * Gated on `requireCapability('scheduleOthers')`, matching the write it feeds
+ * (PATCH /api/inspections/:id/schedule). Reading the whole team's day and
+ * rearranging it are the same privilege, and the capability is toggleable in
+ * both directions where a role tier is not.
+ */
+const dispatchRoute = createRoute(withMcpMetadata({
+ method: 'get',
+ path: '/dispatch',
+ operationId: 'getDispatchBoard',
+ tags: ['calendar'],
+ summary: 'Dispatch board feed for one day',
+ description: 'Returns the inspector roster, every calendar item on the given civil date, the unassigned subset, and the tenant booking_conflict_policy in a single response.',
+ middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('scheduleOthers')] as const,
+ request: { query: DispatchBoardQuerySchema },
+ responses: {
+ 200: {
+ content: { 'application/json': { schema: DispatchBoardResponseSchema } },
+ description: 'Board payload for the requested day',
+ },
+ 403: {
+ content: { 'application/json': { schema: CalendarItemsErrorSchema } },
+ description: 'The caller lacks the scheduleOthers capability',
+ },
+ },
+ security: [{ bearerAuth: [] }],
+}, { scopes: ['read'], tier: 'extended', capability: 'scheduleOthers' }));
+
+const SCHEDULING_ROLES = ['owner', 'manager', 'inspector'] as const;
+
function errorResponse(message: string, code: 'FORBIDDEN') {
return {
success: false as const,
@@ -97,6 +139,102 @@ const calendarItemsRoutes = createApiRouter()
success: true as const,
data: { items },
}, 200);
+ })
+ .openapi(dispatchRoute, async (c) => {
+ const tenantId = c.get('tenantId');
+ const db = getDrizzle(c);
+
+ const cfg = await db.select({
+ defaultTimezone: tenantConfigs.defaultTimezone,
+ bookingConflictPolicy: tenantConfigs.bookingConflictPolicy,
+ })
+ .from(tenantConfigs)
+ .where(eq(tenantConfigs.tenantId, tenantId))
+ .get();
+ const tenantTz = resolveTenantTimeZone(cfg?.defaultTimezone);
+ const conflictPolicy: 'advisory' | 'block' =
+ cfg?.bookingConflictPolicy === 'block' ? 'block' : 'advisory';
+
+ // The board is a TENANT-timezone artifact, not a viewer one: two people
+ // dispatching the same company must be looking at the same day and the
+ // same column positions, or a drag means different things to each.
+ const date = c.req.valid('query').date ?? epochMsToWallClockYmd(Date.now(), tenantTz);
+
+ const roster = await db.select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ })
+ .from(users)
+ .where(and(
+ eq(users.tenantId, tenantId),
+ isNull(users.deletedAt),
+ inArray(users.role, [...SCHEDULING_ROLES]),
+ ))
+ .orderBy(asc(users.name), asc(users.email))
+ .all();
+
+ const items = await listCalendarItems(c.env.DB, tenantId, {
+ start: date,
+ end: date,
+ effectiveTz: tenantTz,
+ });
+
+ // The items feed reports inspections as all-day, which is all a month
+ // grid needs. A board places cards on a time axis, so the precise
+ // instant is fetched here and layered on. Keyed by DATE rather than by
+ // `inArray(ids)` on purpose — a busy day can exceed D1's 100-bind-param
+ // ceiling, and the date predicate costs two binds regardless of volume.
+ // Every column is projected explicitly for the same reason the 100-column
+ // result cap exists: `select()` on this table would spend most of it.
+ const timedRows = await db.select({
+ id: inspections.id,
+ scheduledStartMs: inspections.scheduledStartMs,
+ scheduledEndMs: inspections.scheduledEndMs,
+ durationMin: inspections.durationMin,
+ })
+ .from(inspections)
+ .where(and(
+ eq(inspections.tenantId, tenantId),
+ sql`date(${inspections.date}) = ${date}`,
+ ))
+ .all();
+
+ const timed = new Map(timedRows.map((r) => [r.id, r]));
+ const toMs = (v: unknown): number | null =>
+ v instanceof Date ? v.getTime() : v == null ? null : Number(v);
+
+ const boardItems: CalendarItem[] = items.map((item) => {
+ if (item.kind !== 'inspection') return item;
+ const row = timed.get(item.id);
+ const startMs = toMs(row?.scheduledStartMs);
+ if (startMs == null) return item;
+ const endMs = toMs(row?.scheduledEndMs);
+ return {
+ ...item,
+ allDay: false,
+ startTime: epochMsToWallClockHm(startMs, tenantTz),
+ ...(endMs != null ? { endTime: epochMsToWallClockHm(endMs, tenantTz) } : {}),
+ meta: {
+ ...item.meta,
+ scheduledStartMs: startMs,
+ scheduledEndMs: endMs,
+ durationMin: row?.durationMin ?? null,
+ },
+ };
+ });
+
+ // "Unassigned" is the absence of a userId on the item, which
+ // listCalendarItems already resolves through the link table with the
+ // legacy inspector_id column as fallback. Re-deriving that rule here
+ // would be a second place for it to be applied differently.
+ const unassigned = boardItems.filter((i) => i.kind === 'inspection' && !i.userId);
+
+ return c.json({
+ success: true as const,
+ data: { date, conflictPolicy, inspectors: roster, items: boardItems, unassigned },
+ }, 200);
});
export default calendarItemsRoutes;
diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json
index 4bad74ca2..d8d77a29f 100644
--- a/server/lib/mcp/openapi-snapshot.json
+++ b/server/lib/mcp/openapi-snapshot.json
@@ -7070,6 +7070,34 @@
"summary": "Contact detail: record + inspection history + stats",
"description": "Returns the contact record, its inspection history (newest first; clients match via clientContactId or legacy clientEmail, agents via referredByAgentId or sellingAgentId), and aggregate stats (inspection count + total paid-invoice revenue in cents)."
},
+ {
+ "operationId": "getDispatchBoard",
+ "method": "GET",
+ "pathTemplate": "/api/calendar/dispatch",
+ "scopes": [
+ "read"
+ ],
+ "tag": "calendar",
+ "tier": "extended",
+ "inputSchema": {
+ "parameters": [
+ {
+ "name": "date",
+ "in": "query",
+ "required": false,
+ "description": "Civil date the board shows. Omit for today IN THE TENANT TIMEZONE — the server resolves it, because the caller does not know the zone before this call returns.",
+ "schema": {
+ "type": "string",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
+ "description": "Civil date the board shows. Omit for today IN THE TENANT TIMEZONE — the server resolves it, because the caller does not know the zone before this call returns."
+ }
+ }
+ ],
+ "body": null
+ },
+ "summary": "Dispatch board feed for one day",
+ "description": "Returns the inspector roster, every calendar item on the given civil date, the unassigned subset, and the tenant booking_conflict_policy in a single response."
+ },
{
"operationId": "getEmailTemplate",
"method": "GET",
diff --git a/server/lib/validations/calendar-items.schema.ts b/server/lib/validations/calendar-items.schema.ts
index 95118d840..c1477ad72 100644
--- a/server/lib/validations/calendar-items.schema.ts
+++ b/server/lib/validations/calendar-items.schema.ts
@@ -68,6 +68,36 @@ export const CalendarItemsResponseSchema = z.object({
}),
});
+/**
+ * Dispatch board feed — the same items, one day, plus the two things a board
+ * needs that a calendar does not: WHO the columns are, and what the tenant
+ * wants done about a double-booking.
+ */
+export const DispatchBoardQuerySchema = z.object({
+ date: CivilDateSchema.optional()
+ .describe('Civil date the board shows. Omit for today IN THE TENANT TIMEZONE — the server resolves it, because the caller does not know the zone before this call returns.'),
+});
+
+const DispatchInspectorSchema = z.object({
+ id: z.string().describe('User id — the column key and the leadInspectorId a drag sends.'),
+ name: z.string().nullable().describe('Display name; null falls back to the email.'),
+ email: z.string().describe('Login email.'),
+ role: z.string().describe('Tenant role.'),
+});
+
+export const DispatchBoardResponseSchema = z.object({
+ success: z.literal(true),
+ data: z.object({
+ date: CivilDateSchema.describe('The civil date actually rendered (echoes the query, or today in the tenant timezone).'),
+ conflictPolicy: z.enum(['advisory', 'block'])
+ .describe('Tenant booking_conflict_policy. `block` means the reschedule endpoint will refuse an overlapping drop with 409, so the board warns BEFORE the round trip.'),
+ inspectors: z.array(DispatchInspectorSchema).describe('One board column each, sorted by display name.'),
+ items: z.array(CalendarItemSchema).describe('Every calendar item on that day, for all inspectors.'),
+ unassigned: z.array(CalendarItemSchema)
+ .describe('The subset of `items` that are inspections with nobody on them — the unassigned lane. A SUBSET, not a disjoint list: an item here also appears in `items`.'),
+ }),
+});
+
export const CalendarItemsErrorSchema = z.object({
success: z.literal(false),
error: z.object({
diff --git a/tests/unit/calendar/dispatch-board.spec.ts b/tests/unit/calendar/dispatch-board.spec.ts
new file mode 100644
index 000000000..7301efce7
--- /dev/null
+++ b/tests/unit/calendar/dispatch-board.spec.ts
@@ -0,0 +1,191 @@
+/**
+ * GET /api/calendar/dispatch — the board feed.
+ *
+ * HTTP-level against `app.request` for the same reason the reschedule spec is:
+ * the gate is middleware. `requireCapability('scheduleOthers')` is what decides
+ * who may see the whole team's day, and a test that called the handler directly
+ * would answer 200 for everybody.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import { eq } from 'drizzle-orm';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { HonoConfig } from '../../../server/types/hono';
+import type { UserRole } from '../../../server/types/auth';
+import { AppError } from '../../../server/lib/errors';
+import { INSPECTION_STATUS } from '../../../server/lib/status/inspection-status';
+import { REPORT_STATUS } from '../../../server/lib/status/report-status';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+// eslint-disable-next-line import/order
+import calendarRoutes from '../../../server/api/calendar';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const ACTOR = '00000000-0000-0000-0000-000000000099';
+const ZOE = '00000000-0000-0000-0000-0000000000a1';
+const ADAM = '00000000-0000-0000-0000-0000000000b2';
+const DAY = '2026-06-01';
+const START_MS = Date.UTC(2026, 5, 1, 9, 0, 0);
+
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+interface BoardPayload {
+ date: string;
+ conflictPolicy: string;
+ inspectors: Array<{ id: string; name: string | null }>;
+ items: Array<{ id: string; kind: string; allDay: boolean; startTime?: string; userId?: string; meta?: Record }>;
+ unassigned: Array<{ id: string }>;
+}
+
+function buildApp(
+ db: BetterSQLite3Database,
+ role: UserRole,
+ overrides: Record | null = null,
+) {
+ (mockDrizzle as ReturnType).mockReturnValue(db);
+ const app = new OpenAPIHono();
+
+ app.onError((err, c) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT);
+ c.set('userRole', role);
+ c.set('user', { sub: ACTOR, role, tenantId: TENANT });
+ c.set('sdb', {
+ getById: async () => ({ permissionOverrides: overrides }),
+ } as unknown as HonoConfig['Variables']['sdb']);
+ await next();
+ });
+
+ app.route('/api/calendar', calendarRoutes);
+ return app;
+}
+
+async function seedInspection(
+ db: BetterSQLite3Database,
+ id: string,
+ overrides: Partial = {},
+) {
+ await db.insert(schema.inspections).values({
+ id,
+ tenantId: TENANT,
+ propertyAddress: `${id} St`,
+ date: DAY,
+ status: INSPECTION_STATUS.SCHEDULED,
+ reportStatus: REPORT_STATUS.IN_PROGRESS,
+ paymentStatus: 'unpaid',
+ price: 0,
+ paymentRequired: false,
+ agreementRequired: false,
+ createdAt: new Date(),
+ ...overrides,
+ });
+}
+
+describe('GET /api/calendar/dispatch', () => {
+ let db: BetterSQLite3Database;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db as BetterSQLite3Database;
+ await setupSchema(fixture.sqlite);
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ // Adverse insert order: Zoe first, Adam second. A response that happened
+ // to come back in insertion order would fail the sort assertion rather
+ // than pass by accident.
+ await db.insert(schema.users).values([
+ { id: ZOE, tenantId: TENANT, email: 'zoe@example.com', name: 'Zoe', role: 'inspector', passwordHash: 'h', createdAt: new Date() },
+ { id: ADAM, tenantId: TENANT, email: 'adam@example.com', name: 'Adam', role: 'inspector', passwordHash: 'h', createdAt: new Date() },
+ { id: ACTOR, tenantId: TENANT, email: 'owner@example.com', name: 'Owner', role: 'owner', passwordHash: 'h', createdAt: new Date() },
+ ]);
+ await db.insert(schema.tenantConfigs).values({
+ tenantId: TENANT, defaultTimezone: 'UTC', updatedAt: new Date(),
+ });
+ });
+
+ const get = (app: ReturnType, date = DAY) =>
+ app.request(`/api/calendar/dispatch?date=${date}`, {}, FAKE_ENV);
+
+ it('inspector without the scheduleOthers override → 403', async () => {
+ const res = await get(buildApp(db, 'inspector'));
+ expect(res.status).toBe(403);
+ });
+
+ it('inspector WITH the scheduleOthers override → 200', async () => {
+ const res = await get(buildApp(db, 'inspector', { scheduleOthers: true }));
+ expect(res.status).toBe(200);
+ });
+
+ it('owner → 200 with the roster sorted by display name', async () => {
+ const res = await get(buildApp(db, 'owner'));
+ expect(res.status).toBe(200);
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.inspectors.map((i) => i.name)).toEqual(['Adam', 'Owner', 'Zoe']);
+ expect(body.data.date).toBe(DAY);
+ expect(body.data.conflictPolicy).toBe('advisory');
+ });
+
+ it('echoes the tenant booking_conflict_policy', async () => {
+ await db.update(schema.tenantConfigs)
+ .set({ bookingConflictPolicy: 'block' })
+ .where(eq(schema.tenantConfigs.tenantId, TENANT));
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.conflictPolicy).toBe('block');
+ });
+
+ it('layers the scheduled instant onto inspection items', async () => {
+ await seedInspection(db, 'timed-1', {
+ scheduledStartMs: new Date(START_MS),
+ scheduledEndMs: new Date(START_MS + 90 * 60_000),
+ durationMin: 90,
+ });
+ await db.insert(schema.inspectionInspectors).values({
+ inspectionId: 'timed-1', userId: ADAM, tenantId: TENANT, role: 'lead', createdAt: new Date(),
+ });
+
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ const item = body.data.items.find((i) => i.id === 'timed-1');
+ expect(item?.allDay).toBe(false);
+ expect(item?.startTime).toBe('09:00');
+ expect(item?.meta?.scheduledStartMs).toBe(START_MS);
+ expect(item?.meta?.durationMin).toBe(90);
+ });
+
+ it('leaves an inspection with no instant all-day', async () => {
+ await seedInspection(db, 'untimed-1');
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.items.find((i) => i.id === 'untimed-1')?.allDay).toBe(true);
+ });
+
+ it('puts only the nobody-assigned inspections in the unassigned lane', async () => {
+ await seedInspection(db, 'assigned-1');
+ await db.insert(schema.inspectionInspectors).values({
+ inspectionId: 'assigned-1', userId: ZOE, tenantId: TENANT, role: 'lead', createdAt: new Date(),
+ });
+ await seedInspection(db, 'orphan-1');
+ // Legacy rows carry the assignment on the column instead of the link
+ // table; those are NOT unassigned, and a filter that only looked at the
+ // link table would wrongly sweep them into the lane.
+ await seedInspection(db, 'legacy-1', { inspectorId: ZOE });
+
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.unassigned.map((i) => i.id)).toEqual(['orphan-1']);
+ expect(body.data.items.map((i) => i.id).sort()).toEqual(['assigned-1', 'legacy-1', 'orphan-1']);
+ });
+});
From e61f57c164d9cb1f58b23d4dd4fcf56005c9c44e Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 07:44:20 +0800
Subject: [PATCH 088/111] feat(invoices): record an offline payment against an
invoice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds POST /api/invoices/{id}/payments — the smallest real thing the payment
ledger makes possible: an inspector takes $200 cash at the door and says so.
One appended ledger row, no provider charge, and the paired
GET /api/invoices/{id}/payments so a surface can show the rows rather than a
single total.
occurred_at is the date the money MOVED and is REQUIRED on the wire. Tuesday's
cash gets recorded on Thursday; defaulting it to now() would leave every
reporting period quietly wrong with nothing to notice. A future date is refused,
with a five-minute tolerance for client clock skew only.
Overpayment is measured against what is still OUTSTANDING and refused unless
the caller confirms it: real overpayments happen, but the same input is far more
often a decimal-point typo. card is not an accepted method here — those arrive
from the provider with a reference, and a hand-entered one would have no
reconcilable counterpart.
Gated on the financial capability, the same gate the rest of the billing surface
wears, asserted over HTTP rather than by a unit call. recorded_by comes from the
session, never the body.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
scripts/file-size-baseline.json | 2 +
server/api/invoices.ts | 121 +++++++++
server/lib/mcp/openapi-snapshot.json | 58 +++++
server/lib/validations/invoice.schema.ts | 62 +++++
server/services/invoice.service.ts | 100 ++++++-
tests/unit/invoices/offline-payment.spec.ts | 275 ++++++++++++++++++++
6 files changed, 616 insertions(+), 2 deletions(-)
create mode 100644 tests/unit/invoices/offline-payment.spec.ts
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index e6caf0a00..d9405c52e 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -52,6 +52,7 @@
"server/api/admin/admin-config.ts": 472,
"server/lib/compliance/erasure-orchestrator.ts": 472,
"server/portal/integration.routes.ts": 472,
+ "server/api/invoices.ts": 464,
"app/components/inspection/PeopleEditor.tsx": 457,
"app/components/editor/CostItemsPanel.tsx": 449,
"app/routes/settings-schedule.tsx": 437,
@@ -61,6 +62,7 @@
"server/lib/middleware/di.ts": 432,
"app/routes/public/portal-inspection.tsx": 430,
"server/api/inspections/results.ts": 430,
+ "server/services/invoice.service.ts": 429,
"app/hooks/useStructureEdit.ts": 424,
"app/routes/templates.tsx": 414,
"app/routes/calendar.tsx": 410,
diff --git a/server/api/invoices.ts b/server/api/invoices.ts
index 2a9b58c1b..9e84200b7 100644
--- a/server/api/invoices.ts
+++ b/server/api/invoices.ts
@@ -7,9 +7,12 @@ import {
CreateInvoiceSchema,
InvoiceResponseSchema,
MarkInvoicePaidSchema,
+ PaymentLedgerRowSchema,
+ RecordOfflinePaymentSchema,
RequestPaymentSchema,
RequestPaymentResponseSchema,
} from '../lib/validations/invoice.schema';
+import { safeISODate } from '../lib/date';
import { withMcpMetadata } from "../lib/route-metadata-standards";
import { normalizePaymentMethod } from '../lib/payment-method';
import { inspections, inspectionServices, tenantConfigs } from '../lib/db/schema';
@@ -134,6 +137,62 @@ const requestPaymentRoute = createRoute(withMcpMetadata({
description: 'Resolves or creates the inspection invoice (money authority chain), marks it sent, and emails the client a link to the public payment page.',
}, { scopes: ['write'], tier: 'extended' }));
+/**
+ * Offline payment recording — `POST /api/invoices/{id}/payments`.
+ *
+ * The smallest real thing the payment ledger makes possible: an inspector takes
+ * $200 cash at the door and says so. It appends ONE ledger row and calls no
+ * payment provider, because the money already moved outside every system we
+ * integrate with.
+ *
+ * Capability-gated on `financial`, the same gate the rest of the billing
+ * surface wears, and `recorded_by` is the authenticated user rather than
+ * anything in the body — an unattributed money entry is worthless in a dispute.
+ */
+const recordOfflinePaymentRoute = createRoute(withMcpMetadata({
+ method: 'post', path: '/{id}/payments',
+ tags: ['invoices'], summary: 'Record an offline payment against an invoice',
+ middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('financial')],
+ request: {
+ params: z.object({ id: INVOICE_ID.describe('Invoice the money was received against.') }).describe('Path params for the record-payment endpoint.'),
+ body: { content: { 'application/json': { schema: RecordOfflinePaymentSchema } } },
+ },
+ responses: {
+ 201: {
+ content: { 'application/json': { schema: z.object({
+ success: z.literal(true).describe('Always true; failures arrive as an error status.'),
+ data: PaymentLedgerRowSchema.describe('The ledger row that was appended.'),
+ }) } },
+ description: 'Payment recorded',
+ },
+ 404: { description: 'Invoice not found in this tenant' },
+ 409: { description: 'Invoice is void' },
+ 422: { description: 'Amount exceeds the outstanding balance and was not confirmed' },
+ },
+ security: [{ bearerAuth: [] }],
+ operationId: 'recordInvoiceOfflinePayment',
+ description: 'Appends one payment-ledger row for money received outside the system (cash, cheque, other offline method). The date the money moved is supplied by the caller, never defaulted to now, and the recording user is taken from the session.',
+}, { scopes: ['write'], tier: 'extended', capability: 'financial' }));
+
+const listInvoicePaymentsRoute = createRoute(withMcpMetadata({
+ method: 'get', path: '/{id}/payments',
+ tags: ['invoices'], summary: 'List the payment ledger for an invoice',
+ middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('financial')],
+ request: { params: z.object({ id: INVOICE_ID.describe('Invoice whose ledger rows to return.') }).describe('Path params for the payment-ledger endpoint.') },
+ responses: {
+ 200: {
+ content: { 'application/json': { schema: z.object({
+ success: z.literal(true).describe('Always true; failures arrive as an error status.'),
+ data: z.array(PaymentLedgerRowSchema).describe('Ledger rows for this invoice, oldest movement first.'),
+ }) } },
+ description: 'Success',
+ },
+ },
+ security: [{ bearerAuth: [] }],
+ operationId: 'listInvoicePayments',
+ description: 'Returns every payment-ledger row recorded against one invoice, ordered by when the money moved, with the recording user resolved. Once an invoice can hold several payments a single total no longer answers a dispute.',
+}, { scopes: ['read'], tier: 'extended', capability: 'financial' }));
+
const invoiceRoutes = createApiRouter()
.openapi(listInvoicesRoute, async (c) => {
const rows = await c.var.services.invoice.listInvoices(c.get('tenantId'));
@@ -206,6 +265,68 @@ const invoiceRoutes = createApiRouter()
}
return c.json({ success: true }, 200);
})
+ .openapi(recordOfflinePaymentRoute, async (c) => {
+ const id = c.req.valid('param').id as string;
+ const tenantId = c.get('tenantId');
+ const body = c.req.valid('json');
+ // The recorder is the SESSION, never the body. A money entry nobody is
+ // named on cannot be defended when the payment is later disputed.
+ const recordedBy = c.get('user')?.sub as string;
+
+ const appended = await c.var.services.invoice.recordOfflinePayment(tenantId, id, {
+ amountCents: body.amountCents,
+ method: body.method,
+ // Parsed once, here at the boundary — the schema has already refused
+ // an unparseable or future instant.
+ occurredAt: new Date(body.occurredAt),
+ note: body.note ?? null,
+ allowOverpayment: body.allowOverpayment,
+ recordedBy,
+ });
+
+ // A payment that closes the invoice must also close the report's payment
+ // gate, exactly as mark-paid does. A PARTIAL one must not: the gate asks
+ // whether the invoice is settled, not whether any money arrived.
+ const inv = await c.var.services.invoice.findById(tenantId, id);
+ if (inv?.paidAt && inv.inspectionId) {
+ await c.var.services.inspection.markPaymentReceived(tenantId, inv.inspectionId);
+ }
+ // QuickBooks is a book of record, not a payment provider — the "no
+ // provider call" rule is about not charging anyone, and cash that never
+ // reaches the books is exactly the revenue this feature exists to stop
+ // losing. What is pushed is the ROW that was appended (its amount and
+ // its id as the idempotency key), never the invoice total.
+ if (c.env.QBO_CLIENT_ID) {
+ c.executionCtx.waitUntil(
+ c.var.services.qbo.recordPayment(
+ tenantId, id, appended.amountCents / 100, qboPaymentKey(appended.id),
+ ),
+ );
+ }
+
+ return c.json({
+ success: true as const,
+ data: {
+ id: appended.id,
+ kind: appended.kind,
+ amountCents: appended.amountCents,
+ method: body.method,
+ provider: null,
+ note: body.note ?? null,
+ occurredAt: safeISODate(appended.occurredAt),
+ recordedBy,
+ // Resolved by the ledger LIST, which the surface reloads right
+ // after; re-reading the user row here would buy one label.
+ recordedByName: null,
+ refundsId: null,
+ },
+ }, 201);
+ })
+ .openapi(listInvoicePaymentsRoute, async (c) => {
+ const id = c.req.valid('param').id as string;
+ const rows = await c.var.services.invoice.listPayments(c.get('tenantId'), id);
+ return c.json({ success: true as const, data: rows }, 200);
+ })
.openapi(deleteInvoiceRoute, async (c) => {
const id = c.req.valid('param').id as string;
const tenantId = c.get('tenantId');
diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json
index d8d77a29f..6b5417ca6 100644
--- a/server/lib/mcp/openapi-snapshot.json
+++ b/server/lib/mcp/openapi-snapshot.json
@@ -10606,6 +10606,34 @@
"summary": "Recent \"Test connection\" outcomes for every integration",
"description": "Returns the retained \"Test connection\" history for the active tenant (≤5 per integration, newest first). Backs the persisted \"Last tested …\" status shown next to each Test connection button."
},
+ {
+ "operationId": "listInvoicePayments",
+ "method": "GET",
+ "pathTemplate": "/api/invoices/{id}/payments",
+ "scopes": [
+ "read"
+ ],
+ "tag": "invoices",
+ "tier": "extended",
+ "inputSchema": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Invoice whose ledger rows to return.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Invoice whose ledger rows to return."
+ }
+ }
+ ],
+ "body": null
+ },
+ "summary": "List the payment ledger for an invoice",
+ "description": "Returns every payment-ledger row recorded against one invoice, ordered by when the money moved, with the recording user resolved. Once an invoice can hold several payments a single total no longer answers a dispute."
+ },
{
"operationId": "listInvoices",
"method": "GET",
@@ -15378,6 +15406,36 @@
"summary": "Save tenant integration API secrets",
"description": "Save integration secrets. Masked values (containing bullet characters) are skipped — they indicate unchanged fields."
},
+ {
+ "operationId": "recordInvoiceOfflinePayment",
+ "method": "POST",
+ "pathTemplate": "/api/invoices/{id}/payments",
+ "scopes": [
+ "write"
+ ],
+ "tag": "invoices",
+ "tier": "extended",
+ "inputSchema": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Invoice the money was received against.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Invoice the money was received against."
+ }
+ }
+ ],
+ "body": {
+ "$ref": "#/components/schemas/RecordOfflinePayment"
+ }
+ },
+ "summary": "Record an offline payment against an invoice",
+ "description": "Appends one payment-ledger row for money received outside the system (cash, cheque, other offline method). The date the money moved is supplied by the caller, never defaulted to now, and the recording user is taken from the session."
+ },
{
"operationId": "redeemAgentMagicLogin",
"method": "GET",
diff --git a/server/lib/validations/invoice.schema.ts b/server/lib/validations/invoice.schema.ts
index 566fbb7c2..165ac1472 100644
--- a/server/lib/validations/invoice.schema.ts
+++ b/server/lib/validations/invoice.schema.ts
@@ -38,6 +38,68 @@ export const MarkInvoicePaidSchema = z.object({
.describe('How the invoice was paid: card (online) or an offline method recorded by the inspector — check, cash, offline, or other.'),
}).openapi('MarkInvoicePaid');
+/**
+ * A payment `occurred_at` is the instant the money MOVED, never the instant the
+ * row was written: an inspector takes $200 cash on Tuesday and records it on
+ * Thursday, and every reporting period is quietly wrong if the two are
+ * conflated. It is therefore REQUIRED on the wire — a default would make the
+ * field invisible, which is the same defect wearing a nicer face.
+ *
+ * The tolerance is for CLIENT CLOCK SKEW, not for future-dating: the browser
+ * turns the date picker's local calendar day into an absolute instant, and a
+ * workstation a minute fast must not have its perfectly ordinary "today"
+ * rejected. Anything genuinely ahead of now is refused.
+ */
+const FUTURE_CLOCK_SKEW_MS = 5 * 60 * 1000;
+
+const OCCURRED_AT = z.string().datetime({ offset: true })
+ .refine((v) => Date.parse(v) <= Date.now() + FUTURE_CLOCK_SKEW_MS, {
+ message: 'occurredAt must not be in the future',
+ })
+ .describe('ISO-8601 instant the money actually moved. Required, and must not be in the future.');
+
+/**
+ * Body of POST /api/invoices/{id}/payments — an inspector recording money that
+ * already moved outside the system (cash at the door, a cheque in the post).
+ *
+ * `card` is deliberately NOT an accepted method here. A card payment arrives
+ * from the provider carrying a reference, and a hand-entered one would create a
+ * payment with no reconcilable counterpart on the processor's side.
+ */
+export const RecordOfflinePaymentSchema = z.object({
+ amountCents: z.number().int().positive()
+ .describe('Amount received on this occasion, in integer cents. Always positive; direction lives in the ledger kind.'),
+ method: z.enum(['check', 'cash', 'offline', 'other'])
+ .describe('How the money was received. Card is excluded: those come from the provider with a reference.'),
+ occurredAt: OCCURRED_AT,
+ note: z.string().trim().max(500).optional().nullable()
+ .describe('Optional note kept with the ledger row, for example a cheque number.'),
+ // Overpayment is real (a client rounds up) but it is far more often a
+ // decimal-point typo — 20000 for 200.00. Refusing outright is wrong and
+ // accepting silently is wrong; an explicit confirm is the honest middle.
+ allowOverpayment: z.boolean().optional().default(false)
+ .describe('Confirms an amount larger than the outstanding balance, which is usually a decimal typo.'),
+}).openapi('RecordOfflinePayment');
+
+/**
+ * One ledger row as the staff invoice surface reads it. `recordedByName` is
+ * resolved server-side because "who took this money" is the question a disputed
+ * payment turns on, and a bare user id cannot answer it.
+ */
+export const PaymentLedgerRowSchema = z.object({
+ id: z.string().describe('Ledger row id; the correction endpoint takes this as its target.'),
+ kind: z.enum(['deposit', 'balance', 'adjustment', 'refund'])
+ .describe('Direction and nature of the movement. Refund subtracts; everything else adds.'),
+ amountCents: z.number().int().describe('Amount that moved on this occasion, in integer cents. Always positive.'),
+ method: z.enum(['card', 'check', 'cash', 'offline', 'other']).describe('How the money moved for this row.'),
+ provider: z.string().nullable().describe('Payment provider that reported the row, or null for money recorded by hand.'),
+ note: z.string().nullable().describe('Free-text note stored with the row, including a correction reason.'),
+ occurredAt: z.string().describe('ISO-8601 instant the money moved, as entered by whoever recorded it.'),
+ recordedBy: z.string().nullable().describe('User id that recorded the row, or null when a provider webhook wrote it.'),
+ recordedByName: z.string().nullable().describe('Display name of the recording user, resolved for the ledger list.'),
+ refundsId: z.string().nullable().describe('For a correction or refund, the ledger row id it reverses.'),
+}).openapi('PaymentLedgerRow');
+
export const InvoiceResponseSchema = z.object({
id: z.string().trim().min(1).describe('TODO describe id field for the OpenInspection MCP integration'),
tenantId: z.string().trim().min(1).describe('TODO describe tenantId field for the OpenInspection MCP integration'),
diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts
index 4fd427dcf..d91fd3455 100644
--- a/server/services/invoice.service.ts
+++ b/server/services/invoice.service.ts
@@ -1,7 +1,8 @@
import { drizzle } from 'drizzle-orm/d1';
-import { eq, and, desc, sql, isNotNull, isNull } from 'drizzle-orm';
+import { eq, and, asc, desc, sql, isNotNull, isNull } from 'drizzle-orm';
import { invoices } from '../lib/db/schema/invoice';
-import { inspections, tenantConfigs } from '../lib/db/schema';
+import { orderPayments } from '../lib/db/schema/order-payment';
+import { inspections, tenantConfigs, users } from '../lib/db/schema';
import { Errors } from '../lib/errors';
import { safeISODate } from '../lib/date';
import { AutomationService } from './automation.service';
@@ -188,6 +189,101 @@ export class InvoiceService {
return null;
}
+ /**
+ * Record money that already moved OUTSIDE the system — cash at the door, a
+ * cheque in the post. Appends exactly one ledger row; the invoice's derived
+ * columns are then recomputed by the ledger's single writer, never here.
+ *
+ * `occurredAt` is the caller's, not `now()`. The whole reason this endpoint
+ * exists rather than another `markPaid` is that the inspector records
+ * Tuesday's cash on Thursday, and a reporting period keyed on the write
+ * time is quietly wrong every month.
+ *
+ * Overpayment is refused unless the caller confirms it: it is real (a client
+ * rounds up) but far more often a decimal-point typo.
+ */
+ async recordOfflinePayment(tenantId: string, id: string, input: {
+ amountCents: number;
+ method: 'check' | 'cash' | 'offline' | 'other';
+ occurredAt: Date;
+ note?: string | null;
+ allowOverpayment?: boolean;
+ recordedBy: string;
+ }): Promise {
+ const db = this.getDrizzle();
+ const existing = await db.select().from(invoices)
+ .where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))).get();
+ if (!existing) throw Errors.NotFound('Invoice not found');
+ if (existing.voidedAt) throw Errors.Conflict('This invoice is void; it cannot take a payment.');
+
+ // An invoice paid before the ledger existed has no rows at all, so the
+ // outstanding figure below would read as the full total and every
+ // further payment would look like an overpayment. Give it the one row
+ // its own record implies first.
+ await seedLedgerFromInvoiceRecord(db, tenantId, id);
+ const outstanding = existing.amountCents - await getNetReceivedCents(db, tenantId, id);
+ if (!input.allowOverpayment && input.amountCents > outstanding) {
+ throw Errors.UnprocessableEntity(
+ `This payment exceeds the outstanding balance on this invoice (${Math.max(outstanding, 0)} cents remaining). Confirm the overpayment if the amount is right.`,
+ );
+ }
+
+ const appended = await recordPayment(db, tenantId, {
+ invoiceId: id,
+ inspectionId: existing.inspectionId,
+ // A receipt against the invoice. `deposit` is reserved for money
+ // taken at booking time, before any invoice exists to point at.
+ kind: 'balance',
+ amountCents: input.amountCents,
+ method: input.method,
+ // No provider and no provider_ref: this money moved outside every
+ // system we integrate with, so there is nothing to reconcile against.
+ provider: null,
+ providerRef: null,
+ recordedBy: input.recordedBy,
+ note: input.note ?? null,
+ occurredAt: input.occurredAt,
+ });
+ // `recordPayment` answers null only for a provider redelivery, and an
+ // offline row carries no provider. Narrow rather than assert, so a
+ // future change to that contract surfaces here instead of as a null
+ // body on a 201.
+ if (!appended) throw Errors.Conflict('This payment was already recorded.');
+ return appended;
+ }
+
+ /**
+ * Every ledger row for one invoice, oldest movement first, with the
+ * recording user's name resolved.
+ *
+ * Ordered by `occurred_at`, not `created_at`: the list is a record of when
+ * money moved, and Thursday's data entry of Tuesday's cash belongs before
+ * Wednesday's cheque. `created_at` breaks ties so the order is total.
+ */
+ async listPayments(tenantId: string, id: string) {
+ const db = this.getDrizzle();
+ // Explicit column projection — a `select()` across this join runs at
+ // D1's 100-column result cap for no benefit.
+ const rows = await db.select({
+ id: orderPayments.id,
+ kind: orderPayments.kind,
+ amountCents: orderPayments.amountCents,
+ method: orderPayments.method,
+ provider: orderPayments.provider,
+ note: orderPayments.note,
+ occurredAt: orderPayments.occurredAt,
+ recordedBy: orderPayments.recordedBy,
+ recordedByName: users.name,
+ refundsId: orderPayments.refundsId,
+ })
+ .from(orderPayments)
+ .leftJoin(users, eq(users.id, orderPayments.recordedBy))
+ .where(and(eq(orderPayments.tenantId, tenantId), eq(orderPayments.invoiceId, id)))
+ .orderBy(asc(orderPayments.occurredAt), asc(orderPayments.createdAt))
+ .all();
+ return rows.map(r => ({ ...r, occurredAt: safeISODate(r.occurredAt) }));
+ }
+
/**
* Record that an invoice is partially paid. `amountPaidCents` is the
* CUMULATIVE amount RECEIVED, in integer cents; remaining is derived by the
diff --git a/tests/unit/invoices/offline-payment.spec.ts b/tests/unit/invoices/offline-payment.spec.ts
new file mode 100644
index 000000000..9cd8faa2d
--- /dev/null
+++ b/tests/unit/invoices/offline-payment.spec.ts
@@ -0,0 +1,275 @@
+/**
+ * POST /api/invoices/{id}/payments — recording money that already moved.
+ *
+ * What these specs actually guard:
+ *
+ * 1. `occurred_at` is the INSPECTOR'S date, not `now()`. Tuesday's cash gets
+ * recorded on Thursday, and a test that passes when the field is ignored
+ * would be testing nothing — so the stored value is compared against
+ * Tuesday AND against the row's own `created_at`.
+ * 2. The date is REQUIRED on the wire. A default is how the field becomes
+ * invisible, which is the same defect wearing a nicer face.
+ * 3. Overpayment is refused, then allowed on an explicit confirm. A hard block
+ * is wrong (clients round up) and silent acceptance is wrong (it is usually
+ * a decimal-point typo).
+ * 4. The capability gate is asserted at HTTP level against the REAL mounted
+ * route. A capability declared but never mounted is a defect this repo has
+ * already found once, and a unit call on the service could not see it.
+ * 5. The ledger list is ordered by when money MOVED. The fixtures are seeded in
+ * a deliberately adverse order — the LATER movement recorded FIRST — so an
+ * implementation that orders by insertion cannot pass by accident.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import { and, eq } from 'drizzle-orm';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import invoiceRoutes from '../../../server/api/invoices';
+import { InvoiceService } from '../../../server/services/invoice.service';
+import { AppError } from '../../../server/lib/errors';
+import type { HonoConfig } from '../../../server/types/hono';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const USER_ID = '00000000-0000-0000-0000-000000000300';
+const INSP_ID = '550e8400-e29b-41d4-a716-446655440000';
+const INV_ID = 'inv-aaaaaaaa-0000-0000-0000-000000000001';
+
+/** Fixed instants, so "which date was stored" is assertable rather than luck. */
+const TUESDAY = new Date('2026-03-03T09:00:00.000Z');
+const WEDNESDAY = new Date('2026-03-04T09:00:00.000Z');
+const THURSDAY = new Date('2026-03-05T09:00:00.000Z');
+
+let db: BetterSQLite3Database;
+let markPaymentReceived: ReturnType;
+let qboRecordPayment: ReturnType;
+
+function buildApp(role = 'manager') {
+ const app = new OpenAPIHono();
+ markPaymentReceived = vi.fn().mockResolvedValue(undefined);
+ qboRecordPayment = vi.fn().mockResolvedValue(undefined);
+ app.use('*', async (c, next) => {
+ c.set('userRole', role as never);
+ c.set('tenantId', TENANT);
+ c.set('user', { sub: USER_ID } as never);
+ c.set('services', {
+ invoice: new InvoiceService({} as D1Database),
+ inspection: { markPaymentReceived } as never,
+ qbo: { recordPayment: qboRecordPayment } as never,
+ } as never);
+ await next();
+ });
+ app.route('/api/invoices', invoiceRoutes);
+ // Mirror the production onError AppError→status mapping (server/index.ts).
+ app.onError((err, c) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status as never);
+ }
+ throw err;
+ });
+ return app;
+}
+
+const ENV = { DB: {} } as never;
+const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never;
+
+function postPayment(body: unknown, role = 'manager') {
+ const req = new Request(`https://acme.example.com/api/invoices/${INV_ID}/payments`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
+ });
+ return buildApp(role).fetch(req, ENV, CTX);
+}
+
+function getPayments(role = 'manager') {
+ const req = new Request(`https://acme.example.com/api/invoices/${INV_ID}/payments`, { method: 'GET' });
+ return buildApp(role).fetch(req, ENV, CTX);
+}
+
+async function ledgerRows() {
+ return db.select().from(schema.orderPayments)
+ .where(and(eq(schema.orderPayments.tenantId, TENANT), eq(schema.orderPayments.invoiceId, INV_ID)))
+ .all();
+}
+
+async function getInvoice() {
+ const row = await db.select().from(schema.invoices).where(eq(schema.invoices.id, INV_ID)).get();
+ if (!row) throw new Error('invoice not seeded');
+ return row;
+}
+
+beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.users).values({
+ id: USER_ID, tenantId: TENANT, email: 'dana@acme.example.com',
+ passwordHash: 'x', name: 'Dana Inspector', role: 'manager', createdAt: new Date(),
+ });
+ await db.insert(schema.inspections).values({
+ id: INSP_ID, tenantId: TENANT, propertyAddress: '1 Oak St',
+ date: '2026-03-01', createdAt: new Date(),
+ });
+ await db.insert(schema.invoices).values({
+ id: INV_ID, tenantId: TENANT, inspectionId: INSP_ID, amountCents: 45000,
+ lineItems: [{ description: 'Inspection', amountCents: 45000 }],
+ sentAt: new Date(), createdAt: new Date(), currency: 'USD',
+ });
+});
+
+describe('POST /api/invoices/{id}/payments — recording', () => {
+ it('appends a ledger row with the acting user as recorder', async () => {
+ const res = await postPayment({
+ amountCents: 20000, method: 'cash', occurredAt: TUESDAY.toISOString(), note: 'at the door',
+ });
+ expect(res.status).toBe(201);
+
+ const rows = await ledgerRows();
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ kind: 'balance', method: 'cash', amountCents: 20000,
+ provider: null, providerRef: null, recordedBy: USER_ID, note: 'at the door',
+ });
+ });
+
+ it('stores the date the money MOVED, not the time the row was written', async () => {
+ // The whole reason this endpoint exists rather than another mark-paid:
+ // Tuesday's cash gets recorded on Thursday. If the handler defaulted to
+ // now(), occurredAt would equal createdAt and every reporting period
+ // would be quietly wrong.
+ const res = await postPayment({ amountCents: 20000, method: 'cash', occurredAt: TUESDAY.toISOString() });
+ expect(res.status).toBe(201);
+
+ const [row] = await ledgerRows();
+ expect(row.occurredAt?.getTime()).toBe(TUESDAY.getTime());
+ // And it is genuinely a DIFFERENT instant from the write — an assertion
+ // that only Tuesday's value could satisfy.
+ expect(row.occurredAt?.getTime()).not.toBe(row.createdAt?.getTime());
+ expect(row.createdAt!.getTime()).toBeGreaterThan(row.occurredAt!.getTime());
+ });
+
+ it('requires the date — it is never defaulted away to now()', async () => {
+ const res = await postPayment({ amountCents: 20000, method: 'cash' });
+ expect(res.status).toBe(400);
+ expect(await ledgerRows()).toHaveLength(0);
+ });
+
+ it('rejects a future occurred_at', async () => {
+ const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
+ const res = await postPayment({ amountCents: 100, method: 'cash', occurredAt: tomorrow.toISOString() });
+ expect(res.status).toBe(400);
+ expect(await ledgerRows()).toHaveLength(0);
+ });
+
+ it('refuses the card method on this endpoint', async () => {
+ // Card payments come from the provider with a reference. Letting an
+ // inspector hand-enter one creates a payment with no reconcilable
+ // counterpart on the processor's side.
+ const res = await postPayment({ amountCents: 100, method: 'card', occurredAt: TUESDAY.toISOString() });
+ expect(res.status).toBe(400);
+ expect(await ledgerRows()).toHaveLength(0);
+ });
+
+ it('rejects an amount that would overpay the invoice', async () => {
+ const res = await postPayment({ amountCents: 99999, method: 'cash', occurredAt: TUESDAY.toISOString() });
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error?: { message?: string } };
+ expect(body.error?.message).toMatch(/exceeds/i);
+ expect(await ledgerRows()).toHaveLength(0);
+ });
+
+ it('allows overpayment when explicitly confirmed', async () => {
+ const res = await postPayment({
+ amountCents: 99999, method: 'cash', occurredAt: TUESDAY.toISOString(), allowOverpayment: true,
+ });
+ expect(res.status).toBe(201);
+ expect(await ledgerRows()).toHaveLength(1);
+ });
+
+ it('measures the overpayment against what is still outstanding, not the total', async () => {
+ await postPayment({ amountCents: 20000, method: 'cash', occurredAt: TUESDAY.toISOString() });
+ // 25000 remains; 30000 must now be refused even though it is under the
+ // 45000 invoice total.
+ const res = await postPayment({ amountCents: 30000, method: 'check', occurredAt: WEDNESDAY.toISOString() });
+ expect(res.status).toBe(422);
+ expect(await ledgerRows()).toHaveLength(1);
+ });
+
+ it('leaves a part-paid invoice partial, and closes the report gate only when settled', async () => {
+ await postPayment({ amountCents: 20000, method: 'cash', occurredAt: TUESDAY.toISOString() });
+ let inv = await getInvoice();
+ expect(inv.amountPaidCents).toBe(20000);
+ expect(inv.paidAt).toBeNull();
+ expect(inv.partialPaidAt).not.toBeNull();
+ expect(markPaymentReceived).not.toHaveBeenCalled();
+
+ await postPayment({ amountCents: 25000, method: 'check', occurredAt: WEDNESDAY.toISOString() });
+ inv = await getInvoice();
+ expect(inv.amountPaidCents).toBe(45000);
+ expect(inv.paidAt).not.toBeNull();
+ expect(markPaymentReceived).toHaveBeenCalledWith(TENANT, INSP_ID);
+ });
+
+ it('404s for an invoice in another tenant', async () => {
+ await db.insert(schema.tenants).values({
+ id: 'tenant-two', name: 'Beta', slug: 'beta', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.update(schema.invoices).set({ tenantId: 'tenant-two' }).where(eq(schema.invoices.id, INV_ID));
+ const res = await postPayment({ amountCents: 100, method: 'cash', occurredAt: TUESDAY.toISOString() });
+ expect(res.status).toBe(404);
+ });
+});
+
+describe('POST /api/invoices/{id}/payments — the capability gate', () => {
+ it('403s an inspector without the financial capability', async () => {
+ // Asserted over HTTP against the REAL mounted route: a route that
+ // declares a capability but never mounts the guard still answers 200,
+ // and only the status code can tell the two apart.
+ const res = await postPayment(
+ { amountCents: 100, method: 'cash', occurredAt: TUESDAY.toISOString() }, 'inspector',
+ );
+ expect(res.status).toBe(403);
+ expect(await ledgerRows()).toHaveLength(0);
+ });
+
+ it('403s an agent on the ledger read', async () => {
+ const res = await getPayments('agent');
+ expect(res.status).toBe(403);
+ });
+});
+
+describe('GET /api/invoices/{id}/payments', () => {
+ it('returns the rows ordered by when the money moved, with the recorder named', async () => {
+ // Adverse order: THURSDAY's cheque is recorded FIRST, so an
+ // implementation ordering by insertion would put it at the top.
+ await postPayment({ amountCents: 10000, method: 'check', occurredAt: THURSDAY.toISOString() });
+ await postPayment({ amountCents: 20000, method: 'cash', occurredAt: TUESDAY.toISOString() });
+
+ const res = await getPayments();
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: Array> };
+ expect(body.data).toHaveLength(2);
+ expect(body.data[0]).toMatchObject({ amountCents: 20000, method: 'cash', recordedByName: 'Dana Inspector' });
+ expect(body.data[1]).toMatchObject({ amountCents: 10000, method: 'check' });
+ expect(String(body.data[0].occurredAt)).toContain('2026-03-03');
+ });
+
+ it('never returns another tenant\'s rows', async () => {
+ await postPayment({ amountCents: 20000, method: 'cash', occurredAt: TUESDAY.toISOString() });
+ await db.update(schema.orderPayments).set({ tenantId: 'someone-else' })
+ .where(eq(schema.orderPayments.invoiceId, INV_ID));
+
+ const res = await getPayments();
+ const body = (await res.json()) as { data: unknown[] };
+ expect(body.data).toHaveLength(0);
+ });
+});
From 9b1ed39f6b64a34d11842765c5811b4958650f9a Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 07:51:41 +0800
Subject: [PATCH 089/111] feat(invoices): correct a mistyped payment without
editing the ledger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds POST /api/invoices/{id}/payments/{paymentId}/corrections. The original row
survives untouched and the correction is a second row, because an append-only
ledger is only reconcilable if nothing in it is ever rewritten. Without this
shipping alongside the recording endpoint, the first typo becomes a manual
database edit.
The correcting row is a refund-kind row carrying refunds_id, NOT a signed
adjustment — the choice a future reader will want to reverse, so the reasoning
sits at the code: kind carries direction in this table and adjustment is
additive in the recompute, so a downward correction as an adjustment would have
to smuggle a negative into amount_cents, which is the one thing the schema
forbids.
It inherits the ORIGINAL row's occurred_at. The money never moved on the day
the typo was spotted, so the correction belongs to the period the mistake landed
in. Correcting upward is refused: more money than was recorded is another
payment, and recording it as one keeps both facts true.
The request body is strict. A correction is exactly the shape where a forgiving
parser does real damage, so a key the endpoint does not accept is a 400 rather
than a silent no-op on a money edit. Lowering a payment can take an invoice back
out of paid, so the report's payment gate is re-synced.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
scripts/file-size-baseline.json | 4 +-
server/api/invoices.ts | 69 +++++++++++++
server/lib/mcp/openapi-snapshot.json | 41 ++++++++
server/lib/validations/invoice.schema.ts | 19 ++++
server/services/invoice.service.ts | 81 +++++++++++++++
tests/unit/invoices/offline-payment.spec.ts | 109 ++++++++++++++++++++
6 files changed, 321 insertions(+), 2 deletions(-)
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index d9405c52e..1ffbe362e 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -33,6 +33,7 @@
"server/api/inspections/core.ts": 560,
"app/routes/settings-profile.tsx": 548,
"server/api/calendar.ts": 547,
+ "server/api/invoices.ts": 533,
"server/services/inspection/inspection-photo.service.ts": 531,
"app/components/NewInspectionWizard.tsx": 530,
"server/api/inspections/media-studio.ts": 530,
@@ -42,6 +43,7 @@
"server/api/inspections/publish.ts": 520,
"server/api/bookings/agreement.ts": 519,
"app/components/settings/ManagedComplianceWizard.tsx": 514,
+ "server/services/invoice.service.ts": 510,
"server/api/repair-builder.ts": 504,
"app/routes/inspection-edit/action.server.ts": 501,
"server/services/inspection-request.service.ts": 501,
@@ -52,7 +54,6 @@
"server/api/admin/admin-config.ts": 472,
"server/lib/compliance/erasure-orchestrator.ts": 472,
"server/portal/integration.routes.ts": 472,
- "server/api/invoices.ts": 464,
"app/components/inspection/PeopleEditor.tsx": 457,
"app/components/editor/CostItemsPanel.tsx": 449,
"app/routes/settings-schedule.tsx": 437,
@@ -62,7 +63,6 @@
"server/lib/middleware/di.ts": 432,
"app/routes/public/portal-inspection.tsx": 430,
"server/api/inspections/results.ts": 430,
- "server/services/invoice.service.ts": 429,
"app/hooks/useStructureEdit.ts": 424,
"app/routes/templates.tsx": 414,
"app/routes/calendar.tsx": 410,
diff --git a/server/api/invoices.ts b/server/api/invoices.ts
index 9e84200b7..f1ab69d2b 100644
--- a/server/api/invoices.ts
+++ b/server/api/invoices.ts
@@ -6,6 +6,7 @@ import { requireCapability } from '../lib/middleware/require-capability';
import {
CreateInvoiceSchema,
InvoiceResponseSchema,
+ CorrectPaymentSchema,
MarkInvoicePaidSchema,
PaymentLedgerRowSchema,
RecordOfflinePaymentSchema,
@@ -174,6 +175,41 @@ const recordOfflinePaymentRoute = createRoute(withMcpMetadata({
description: 'Appends one payment-ledger row for money received outside the system (cash, cheque, other offline method). The date the money moved is supplied by the caller, never defaulted to now, and the recording user is taken from the session.',
}, { scopes: ['write'], tier: 'extended', capability: 'financial' }));
+/**
+ * The correction path — `POST /api/invoices/{id}/payments/{paymentId}/corrections`.
+ *
+ * Append-only means a typo is fixed by a new row, so this has to ship in the
+ * same release as the recording endpoint: without it the first mistake becomes
+ * a manual database edit.
+ */
+const correctPaymentRoute = createRoute(withMcpMetadata({
+ method: 'post', path: '/{id}/payments/{paymentId}/corrections',
+ tags: ['invoices'], summary: 'Correct a mistyped payment on an invoice',
+ middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('financial')],
+ request: {
+ params: z.object({
+ id: INVOICE_ID.describe('Invoice the mistyped payment was recorded against.'),
+ paymentId: z.string().trim().min(1).describe('Ledger row id of the payment being corrected.'),
+ }).describe('Path params for the payment-correction endpoint.'),
+ body: { content: { 'application/json': { schema: CorrectPaymentSchema } } },
+ },
+ responses: {
+ 201: {
+ content: { 'application/json': { schema: z.object({
+ success: z.literal(true).describe('Always true; failures arrive as an error status.'),
+ data: PaymentLedgerRowSchema.describe('The correcting ledger row that was appended.'),
+ }) } },
+ description: 'Correction recorded',
+ },
+ 404: { description: 'Payment not found on this invoice in this tenant' },
+ 409: { description: 'Payment has already been corrected' },
+ 422: { description: 'Correction does not lower the recorded amount' },
+ },
+ security: [{ bearerAuth: [] }],
+ operationId: 'correctInvoicePayment',
+ description: 'Corrects a mistyped payment by appending a reversing ledger row rather than editing the original, which survives. The correcting row inherits the date the money moved, so the correction lands in the period the mistake did.',
+}, { scopes: ['write'], tier: 'extended', capability: 'financial' }));
+
const listInvoicePaymentsRoute = createRoute(withMcpMetadata({
method: 'get', path: '/{id}/payments',
tags: ['invoices'], summary: 'List the payment ledger for an invoice',
@@ -322,6 +358,39 @@ const invoiceRoutes = createApiRouter()
},
}, 201);
})
+ .openapi(correctPaymentRoute, async (c) => {
+ const { id, paymentId } = c.req.valid('param') as { id: string; paymentId: string };
+ const tenantId = c.get('tenantId');
+ const { correctedAmountCents, reason } = c.req.valid('json');
+ const recordedBy = c.get('user')?.sub as string;
+
+ // The service also re-syncs the report's payment gate: a correction can
+ // take an invoice back OUT of paid, which is precisely the state the old
+ // column model could not express.
+ const appended = await c.var.services.invoice.correctPayment(tenantId, id, paymentId, {
+ correctedAmountCents, reason, recordedBy,
+ });
+ // Deliberately NOT pushed to QuickBooks. A reversal there is not a
+ // negative payment — it is an operation on the payment already booked,
+ // and inventing a negative amount would post nonsense to somebody's
+ // books. Reconciling corrections belongs to the QBO sync work, not here.
+
+ return c.json({
+ success: true as const,
+ data: {
+ id: appended.id,
+ kind: appended.kind,
+ amountCents: appended.amountCents,
+ method: appended.method,
+ provider: null,
+ note: appended.note,
+ occurredAt: safeISODate(appended.occurredAt),
+ recordedBy,
+ recordedByName: null,
+ refundsId: appended.refundsId,
+ },
+ }, 201);
+ })
.openapi(listInvoicePaymentsRoute, async (c) => {
const id = c.req.valid('param').id as string;
const rows = await c.var.services.invoice.listPayments(c.get('tenantId'), id);
diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json
index 6b5417ca6..ad62e9a7f 100644
--- a/server/lib/mcp/openapi-snapshot.json
+++ b/server/lib/mcp/openapi-snapshot.json
@@ -1057,6 +1057,47 @@
"summary": "Confirm SMS opt-in (double opt-in) — records a granted consent event",
"description": "Records a granted SMS consent event (captured_via=optin_link) for the contact encoded in the token. Idempotent — confirming twice simply appends a second granted event."
},
+ {
+ "operationId": "correctInvoicePayment",
+ "method": "POST",
+ "pathTemplate": "/api/invoices/{id}/payments/{paymentId}/corrections",
+ "scopes": [
+ "write"
+ ],
+ "tag": "invoices",
+ "tier": "extended",
+ "inputSchema": {
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Invoice the mistyped payment was recorded against.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Invoice the mistyped payment was recorded against."
+ }
+ },
+ {
+ "name": "paymentId",
+ "in": "path",
+ "required": true,
+ "description": "Ledger row id of the payment being corrected.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Ledger row id of the payment being corrected."
+ }
+ }
+ ],
+ "body": {
+ "$ref": "#/components/schemas/CorrectPayment"
+ }
+ },
+ "summary": "Correct a mistyped payment on an invoice",
+ "description": "Corrects a mistyped payment by appending a reversing ledger row rather than editing the original, which survives. The correcting row inherits the date the money moved, so the correction lands in the period the mistake did."
+ },
{
"operationId": "countsInspection",
"method": "GET",
diff --git a/server/lib/validations/invoice.schema.ts b/server/lib/validations/invoice.schema.ts
index 165ac1472..8674ac8b4 100644
--- a/server/lib/validations/invoice.schema.ts
+++ b/server/lib/validations/invoice.schema.ts
@@ -81,6 +81,25 @@ export const RecordOfflinePaymentSchema = z.object({
.describe('Confirms an amount larger than the outstanding balance, which is usually a decimal typo.'),
}).openapi('RecordOfflinePayment');
+/**
+ * Body of POST /api/invoices/{id}/payments/{paymentId}/corrections.
+ *
+ * The ledger is append-only, so a mistyped amount is fixed by a NEW row and
+ * never by editing the old one. Without this path the first typo becomes a
+ * manual database edit.
+ *
+ * `.strict()` on purpose: a correction is the shape where a forgiving parser
+ * does real damage. Anything the caller did not send must be ABSENT, not
+ * quietly filled in — an unknown key here means the caller believes it is
+ * changing something it is not.
+ */
+export const CorrectPaymentSchema = z.object({
+ correctedAmountCents: z.number().int().nonnegative()
+ .describe('What the payment should have been, in integer cents. Must be lower than the recorded amount.'),
+ reason: z.string().trim().min(1).max(500)
+ .describe('Why the original figure was wrong. Stored on the correcting row so the pair explains itself.'),
+}).strict().openapi('CorrectPayment');
+
/**
* One ledger row as the staff invoice surface reads it. `recordedByName` is
* resolved server-side because "who took this money" is the question a disputed
diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts
index d91fd3455..ad204c42a 100644
--- a/server/services/invoice.service.ts
+++ b/server/services/invoice.service.ts
@@ -252,6 +252,87 @@ export class InvoiceService {
return appended;
}
+ /**
+ * Correct a mistyped payment. The original row SURVIVES; the correction is
+ * a second row, because an append-only ledger is only reconcilable if
+ * nothing in it is ever rewritten.
+ *
+ * The correcting row is a `refund`-kind row carrying `refundsId`, NOT a
+ * signed `adjustment`. This is the choice a future reader will want to
+ * reverse, so: `kind` carries direction in this table and `adjustment` is
+ * ADDITIVE in the recompute, so a downward correction expressed as an
+ * adjustment would have to smuggle a negative into `amount_cents` — the
+ * exact thing the schema forbids, because an unfiltered SUM over a signed
+ * column is a wrong total nobody notices. `refund` already means "money
+ * going the other way" and `refunds_id` already means "the row this
+ * reverses". Reusing them beats inventing a second mechanism that means
+ * the same thing.
+ *
+ * It also inherits the ORIGINAL row's `occurred_at`: the money never moved
+ * on the day the typo was spotted, so the correction belongs to the period
+ * the mistake landed in, not to the day of data entry.
+ *
+ * Upward corrections are refused. More money arriving than was recorded is
+ * not a correction, it is another payment, and recording it as one keeps
+ * both facts true.
+ */
+ async correctPayment(tenantId: string, id: string, paymentId: string, input: {
+ correctedAmountCents: number;
+ reason: string;
+ recordedBy: string;
+ }) {
+ const db = this.getDrizzle();
+ const original = await db.select().from(orderPayments)
+ .where(and(
+ eq(orderPayments.tenantId, tenantId),
+ eq(orderPayments.id, paymentId),
+ eq(orderPayments.invoiceId, id),
+ ))
+ .get();
+ if (!original) throw Errors.NotFound('Payment not found on this invoice');
+ if (original.kind === 'refund') {
+ throw Errors.UnprocessableEntity('A refund cannot be corrected. Record the money that actually moved instead.');
+ }
+
+ const alreadyCorrected = await db.select({ id: orderPayments.id }).from(orderPayments)
+ .where(and(eq(orderPayments.tenantId, tenantId), eq(orderPayments.refundsId, paymentId)))
+ .get();
+ if (alreadyCorrected) {
+ throw Errors.Conflict('This payment has already been corrected.');
+ }
+
+ const delta = original.amountCents - input.correctedAmountCents;
+ if (delta <= 0) {
+ throw Errors.UnprocessableEntity(
+ 'A correction can only lower a recorded payment. If more money arrived than was recorded, record the extra as its own payment.',
+ );
+ }
+
+ const appended = await recordPayment(db, tenantId, {
+ invoiceId: id,
+ inspectionId: original.inspectionId,
+ kind: 'refund',
+ amountCents: delta,
+ method: original.method,
+ provider: null,
+ providerRef: null,
+ recordedBy: input.recordedBy,
+ refundsId: original.id,
+ note: `Correction: ${input.reason}`,
+ occurredAt: original.occurredAt,
+ });
+ if (!appended) throw Errors.Conflict('This correction was already recorded.');
+
+ // Lowering a payment can take the invoice back out of paid, and a
+ // report left publicly unlocked with no backing payment is the whole
+ // point of that gate existing.
+ await this.syncInspectionPaymentGate(original.inspectionId, tenantId);
+
+ // The caller renders this row, so it gets the fields the ledger row
+ // actually carries rather than a plausible-looking guess.
+ return { ...appended, method: original.method, note: `Correction: ${input.reason}`, refundsId: original.id };
+ }
+
/**
* Every ledger row for one invoice, oldest movement first, with the
* recording user's name resolved.
diff --git a/tests/unit/invoices/offline-payment.spec.ts b/tests/unit/invoices/offline-payment.spec.ts
index 9cd8faa2d..d04209a49 100644
--- a/tests/unit/invoices/offline-payment.spec.ts
+++ b/tests/unit/invoices/offline-payment.spec.ts
@@ -247,6 +247,115 @@ describe('POST /api/invoices/{id}/payments — the capability gate', () => {
});
});
+describe('POST /api/invoices/{id}/payments/{paymentId}/corrections', () => {
+ async function recordAndGetId(amountCents: number, occurredAt = TUESDAY) {
+ const res = await postPayment({ amountCents, method: 'cash', occurredAt: occurredAt.toISOString(), note: 'at the door' });
+ expect(res.status).toBe(201);
+ return ((await res.json()) as { data: { id: string } }).data.id;
+ }
+
+ function postCorrection(paymentId: string, body: unknown, role = 'manager') {
+ const req = new Request(`https://acme.example.com/api/invoices/${INV_ID}/payments/${paymentId}/corrections`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
+ });
+ return buildApp(role).fetch(req, ENV, CTX);
+ }
+
+ it('corrects a mistyped payment with a reversing row, leaving the original', async () => {
+ const origId = await recordAndGetId(20000);
+ const res = await postCorrection(origId, { correctedAmountCents: 2000, reason: 'decimal typo' });
+ expect(res.status).toBe(201);
+
+ const rows = await ledgerRows();
+ expect(rows).toHaveLength(2); // the original survives
+ expect((await getInvoice()).amountPaidCents).toBe(2000); // net is the corrected figure
+ const correction = rows.find(r => r.id !== origId)!;
+ expect(correction.note).toContain('decimal typo');
+ // A refund-kind row with refundsId, NOT a signed adjustment: `kind`
+ // carries direction in this table and amount_cents is always positive.
+ expect(correction.kind).toBe('refund');
+ expect(correction.amountCents).toBe(18000);
+ expect(correction.refundsId).toBe(origId);
+ expect(correction.recordedBy).toBe(USER_ID);
+ });
+
+ it('does not touch a single field of the original row', async () => {
+ // A correction is exactly the shape where a forgiving parser does real
+ // damage — a body that omits a field must leave it ABSENT, never
+ // silently rewritten. Assert the original as a whole, not one column.
+ const origId = await recordAndGetId(20000);
+ const before = (await ledgerRows()).find(r => r.id === origId)!;
+
+ await postCorrection(origId, { correctedAmountCents: 2000, reason: 'decimal typo' });
+
+ const after = (await ledgerRows()).find(r => r.id === origId)!;
+ expect(after).toEqual(before);
+ });
+
+ it('rejects a body carrying keys the endpoint does not accept', async () => {
+ // The caller believes it is changing `method`; nothing would. Better a
+ // 400 than a silent no-op on a money edit.
+ const origId = await recordAndGetId(20000);
+ const res = await postCorrection(origId, { correctedAmountCents: 2000, reason: 'typo', method: 'check' });
+ expect(res.status).toBe(400);
+ expect(await ledgerRows()).toHaveLength(1);
+ });
+
+ it('books the correction in the period the mistake landed in, not the day it was spotted', async () => {
+ const origId = await recordAndGetId(20000, TUESDAY);
+ await postCorrection(origId, { correctedAmountCents: 2000, reason: 'decimal typo' });
+
+ const correction = (await ledgerRows()).find(r => r.id !== origId)!;
+ expect(correction.occurredAt?.getTime()).toBe(TUESDAY.getTime());
+ expect(correction.occurredAt?.getTime()).not.toBe(correction.createdAt?.getTime());
+ });
+
+ it('refuses to correct upward — extra money is another payment, not a correction', async () => {
+ const origId = await recordAndGetId(20000);
+ const res = await postCorrection(origId, { correctedAmountCents: 30000, reason: 'undercounted' });
+ expect(res.status).toBe(422);
+ expect(await ledgerRows()).toHaveLength(1);
+ });
+
+ it('refuses to correct the same payment twice', async () => {
+ const origId = await recordAndGetId(20000);
+ expect((await postCorrection(origId, { correctedAmountCents: 2000, reason: 'typo' })).status).toBe(201);
+ const second = await postCorrection(origId, { correctedAmountCents: 1000, reason: 'typo again' });
+ expect(second.status).toBe(409);
+ expect(await ledgerRows()).toHaveLength(2);
+ });
+
+ it('404s a payment id from another invoice', async () => {
+ const origId = await recordAndGetId(20000);
+ await db.update(schema.orderPayments).set({ invoiceId: 'inv-somewhere-else' })
+ .where(eq(schema.orderPayments.id, origId));
+ const res = await postCorrection(origId, { correctedAmountCents: 2000, reason: 'typo' });
+ expect(res.status).toBe(404);
+ });
+
+ it('403s an inspector without the financial capability', async () => {
+ const origId = await recordAndGetId(20000);
+ const res = await postCorrection(origId, { correctedAmountCents: 2000, reason: 'typo' }, 'inspector');
+ expect(res.status).toBe(403);
+ expect(await ledgerRows()).toHaveLength(1);
+ });
+
+ it('clears the report payment gate when the correction unsettles the invoice', async () => {
+ // Paid in full, then corrected downward: the report must not stay
+ // publicly unlocked with no backing payment.
+ await postPayment({ amountCents: 45000, method: 'cash', occurredAt: TUESDAY.toISOString() });
+ const origId = (await ledgerRows())[0].id;
+ await db.update(schema.inspections).set({ paymentStatus: 'paid' })
+ .where(eq(schema.inspections.id, INSP_ID));
+
+ await postCorrection(origId, { correctedAmountCents: 5000, reason: 'decimal typo' });
+
+ const insp = await db.select().from(schema.inspections).where(eq(schema.inspections.id, INSP_ID)).get();
+ expect(insp?.paymentStatus).toBe('unpaid');
+ expect((await getInvoice()).paidAt).toBeNull();
+ });
+});
+
describe('GET /api/invoices/{id}/payments', () => {
it('returns the rows ordered by when the money moved, with the recorder named', async () => {
// Adverse order: THURSDAY's cheque is recorded FIRST, so an
From ba20f1d0aeac1dc83d7fb5e1b51a6f526f958c41 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 08:24:43 +0800
Subject: [PATCH 090/111] feat(invoices): show the payment ledger and record
against it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The staff invoice list gains a Payments surface per row. Deliberately the STAFF
list: recording money is capability-gated on financial and attributed to the
acting user, and the client portal and checkout have no such actor. Those two
also deliberately quote the full invoice total, which is a settled
payment-collection decision this change does not touch.
A form, not a modal chain — amount, method, date and note visible at once. The
date is pre-filled with today and stays VISIBLE and EDITABLE, and the browser
converts the chosen calendar day into an absolute instant because only the
browser knows which zone that day belongs to. Nothing on this path defaults to
now().
The rows are listed, not just a total: amount, method, the date the money moved,
and who wrote it down — which is what makes a disputed payment answerable. A
correction appears directly under the payment it corrects, negative, with the
original still standing. Remaining balance is the prominent figure, derived from
the rows against the invoice total and formatted in the invoice's own currency
snapshot.
Walked through in Chrome in both themes: two payments and a correction recorded
end to end, an overpayment refused and then confirmed. DOM sweep found no
overflow inside the modal in either theme and no horizontal body scroll; every
foreground/background pair measures at least 4.76:1, after moving the balance
block's labels off ih-fg-3, which measured 4.34:1 on the muted panel in light
mode. 32 strings added to both catalogues.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
.../invoices/PaymentsModal.test.tsx | 162 ++++++++++
app/components/invoices/PaymentsModal.tsx | 305 ++++++++++++++++++
app/routes/invoices.tsx | 119 ++++++-
messages/en/misc.json | 31 ++
messages/es-419/misc.json | 31 ++
scripts/file-size-baseline.json | 3 +-
server/services/invoice.service.ts | 5 +-
7 files changed, 651 insertions(+), 5 deletions(-)
create mode 100644 app/components/invoices/PaymentsModal.test.tsx
create mode 100644 app/components/invoices/PaymentsModal.tsx
diff --git a/app/components/invoices/PaymentsModal.test.tsx b/app/components/invoices/PaymentsModal.test.tsx
new file mode 100644
index 000000000..733d8fd0b
--- /dev/null
+++ b/app/components/invoices/PaymentsModal.test.tsx
@@ -0,0 +1,162 @@
+// @vitest-environment happy-dom
+/**
+ * The staff payment surface.
+ *
+ * The one thing worth a test here is the thing the plan says will be
+ * "simplified" away: the DATE. It is visible, editable, pre-filled with today
+ * rather than assumed to be today, and what gets submitted for a past date is
+ * that past day — not the moment the form was posted. A surface that quietly
+ * stamped now() would pass every other assertion on this page.
+ *
+ * The balance is the second: it is derived from the ROWS against the invoice
+ * total, refunds subtracting, so a correction moves it without anything reading
+ * the cached column.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { render, fireEvent } from "@testing-library/react";
+import { PaymentsModal, type PaymentRow } from "./PaymentsModal";
+
+const INVOICE = { id: "inv-1", clientName: "Dana Reyes", amountCents: 45000, currency: "USD" };
+
+const CASH: PaymentRow = {
+ id: "pay-1", kind: "balance", amountCents: 20000, method: "cash", provider: null,
+ note: "at the door", occurredAt: "2026-03-03T09:00:00.000Z",
+ recordedBy: "u-1", recordedByName: "Dana Reyes", refundsId: null,
+};
+
+const CORRECTION: PaymentRow = {
+ id: "pay-2", kind: "refund", amountCents: 18000, method: "cash", provider: null,
+ note: "Correction: decimal typo", occurredAt: "2026-03-03T09:00:00.000Z",
+ recordedBy: "u-1", recordedByName: "Dana Reyes", refundsId: "pay-1",
+};
+
+function mockFetcher(data?: unknown) {
+ return { state: "idle" as const, data, submit: vi.fn(), load: vi.fn(), Form: () => null };
+}
+
+function renderModal(payments: PaymentRow[], fetcher = mockFetcher()) {
+ const utils = render(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ {}} />,
+ );
+ return { ...utils, fetcher };
+}
+
+/** The browser's own calendar day, the same way the component computes it. */
+function todayLocal(): string {
+ const now = new Date();
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+}
+
+describe("PaymentsModal — the date", () => {
+ it("shows the date field, pre-filled with today and editable", () => {
+ const { container } = renderModal([]);
+ const date = container.querySelector('input[type="date"]') as HTMLInputElement;
+ expect(date).toBeTruthy();
+ expect(date.value).toBe(todayLocal());
+ expect(date.disabled).toBe(false);
+ expect(date.readOnly).toBe(false);
+ });
+
+ it("submits the day the money moved, not the moment the form was posted", () => {
+ // Tuesday's cash, recorded today. If the surface defaulted to now(), the
+ // submitted instant would land on today's date and this would fail.
+ const { container, getByText, fetcher } = renderModal([]);
+ fireEvent.change(container.querySelector('input[type="number"]')!, { target: { value: "200" } });
+ fireEvent.change(container.querySelector('input[type="date"]')!, { target: { value: "2026-03-03" } });
+ fireEvent.click(getByText("Record payment"));
+
+ expect(fetcher.submit).toHaveBeenCalledTimes(1);
+ const sent = fetcher.submit.mock.calls[0][0] as Record;
+ expect(sent.intent).toBe("record-payment");
+ expect(sent.amount).toBe("200");
+ // A full instant on the wire, and it is Tuesday's — in the browser's own
+ // zone, which is the only place that mapping can honestly be made.
+ const submitted = new Date(sent.occurredAt);
+ expect(Number.isNaN(submitted.getTime())).toBe(false);
+ expect(submitted.getFullYear()).toBe(2026);
+ expect(submitted.getMonth()).toBe(2);
+ expect(submitted.getDate()).toBe(3);
+ // …and emphatically not today's, which is what a defaulted field would send.
+ expect(`${submitted.getFullYear()}-03-03`).not.toBe(todayLocal());
+ });
+
+ it("will not offer a future day to pick", () => {
+ const { container } = renderModal([]);
+ const date = container.querySelector('input[type="date"]') as HTMLInputElement;
+ expect(date.getAttribute("max")).toBe(todayLocal());
+ });
+});
+
+describe("PaymentsModal — the ledger and the balance", () => {
+ it("makes the remaining balance the prominent figure", () => {
+ const { container } = renderModal([CASH]);
+ expect(container.textContent).toContain("$250.00"); // 45000 − 20000 remaining
+ expect(container.textContent).toContain("$450.00"); // invoice total, secondary
+ expect(container.textContent).toContain("$200.00"); // received
+ });
+
+ it("subtracts a correction from the balance without reading a cached total", () => {
+ const { container } = renderModal([CASH, CORRECTION]);
+ // 20000 received, 18000 corrected away → 2000 net, 43000 still owed.
+ expect(container.textContent).toContain("$430.00");
+ expect(container.textContent).toContain("$20.00");
+ });
+
+ it("keeps the original visible with the correction below it", () => {
+ const { container } = renderModal([CASH, CORRECTION]);
+ const items = [...container.querySelectorAll("li")];
+ expect(items).toHaveLength(2);
+ expect(items[0].textContent).toContain("$200.00");
+ expect(items[0].textContent).toContain("at the door");
+ expect(items[1].textContent).toContain("$180.00");
+ expect(items[1].textContent).toContain("decimal typo");
+ });
+
+ it("names who recorded each row — the question a dispute turns on", () => {
+ const { container } = renderModal([CASH]);
+ expect(container.textContent).toContain("Recorded by Dana Reyes");
+ expect(container.textContent).toContain("Cash");
+ });
+
+ it("offers the correction control on exactly the row that can take one", () => {
+ // Not on a correction (it reverses, it is not reversed), not on a
+ // provider row (that money is reconciled elsewhere), and not on a row
+ // that already carries a correction — that click would only earn a 409.
+ const provider: PaymentRow = { ...CASH, id: "pay-3", provider: "stripe", method: "card", recordedByName: null };
+ const cheque: PaymentRow = { ...CASH, id: "pay-4", amountCents: 10000, method: "check", note: "Cheque 4471" };
+
+ const corrected = renderModal([CASH, CORRECTION, provider]);
+ expect([...corrected.container.querySelectorAll("button")].filter((b) => b.textContent === "Correct")).toHaveLength(0);
+
+ const open = renderModal([CASH, CORRECTION, provider, cheque]);
+ const controls = [...open.container.querySelectorAll("li")]
+ .filter((li) => [...li.querySelectorAll("button")].some((b) => b.textContent === "Correct"));
+ expect(controls).toHaveLength(1);
+ expect(controls[0].textContent).toContain("Cheque 4471");
+ });
+
+ it("says so plainly when nothing has been recorded", () => {
+ const { container } = renderModal([]);
+ expect(container.textContent).toContain("No payments recorded yet.");
+ });
+});
+
+describe("PaymentsModal — overpayment", () => {
+ it("offers a deliberate confirm only after the endpoint refuses one", () => {
+ const clean = renderModal([]);
+ expect([...clean.container.querySelectorAll("button")].some((b) => b.textContent === "Record it anyway")).toBe(false);
+
+ const refused = renderModal([], mockFetcher({
+ intent: "record-payment", ok: false,
+ error: "This payment exceeds the outstanding balance on this invoice (25000 cents remaining).",
+ }));
+ const anyway = [...refused.container.querySelectorAll("button")].find((b) => b.textContent === "Record it anyway");
+ expect(anyway).toBeTruthy();
+
+ fireEvent.click(anyway!);
+ const sent = refused.fetcher.submit.mock.calls[0][0] as Record;
+ expect(sent.allowOverpayment).toBe("1");
+ });
+});
diff --git a/app/components/invoices/PaymentsModal.tsx b/app/components/invoices/PaymentsModal.tsx
new file mode 100644
index 000000000..0702eff5a
--- /dev/null
+++ b/app/components/invoices/PaymentsModal.tsx
@@ -0,0 +1,305 @@
+import { useState } from "react";
+import type { useFetcher } from "react-router";
+import { Modal, Button, Input, Select, Banner } from "@core/shared-ui";
+import { formatCurrency, formatDate } from "~/lib/format";
+import { m } from "~/paraglide/messages";
+
+/**
+ * The staff payment surface for one invoice.
+ *
+ * Deliberately the STAFF list, not the client portal or checkout. Recording
+ * money is capability-gated on `financial` and attributed to the acting user;
+ * the client-facing surfaces have no such actor and must never offer the form.
+ * Those two also deliberately quote the full invoice total, which is a settled
+ * payment-collection decision this surface does not touch.
+ *
+ * A form, not a modal chain: amount, method, date and an optional note are all
+ * visible at once, because a chain hides the field that matters most.
+ */
+
+export type PaymentRow = {
+ id: string;
+ kind: "deposit" | "balance" | "adjustment" | "refund";
+ amountCents: number;
+ method: string;
+ provider: string | null;
+ note: string | null;
+ /** ISO-8601 instant the money MOVED, not when the row was written. */
+ occurredAt: string;
+ recordedBy: string | null;
+ recordedByName: string | null;
+ refundsId: string | null;
+};
+
+/** Only the invoice fields this surface reads; the page passes its own row. */
+type PaymentsInvoice = {
+ id: string;
+ clientName: string | null;
+ amountCents: number;
+ currency: string;
+};
+
+type ActionData = { intent?: unknown; ok?: boolean; error?: string | null } | undefined;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Fetcher = ReturnType>;
+
+interface Props {
+ invoice: PaymentsInvoice | null;
+ payments: PaymentRow[];
+ loading: boolean;
+ fetcher: Fetcher;
+ locale: string;
+ onClose: () => void;
+}
+
+function methodLabel(method: string): string {
+ const labels: Record = {
+ card: m.invoices_method_label_card(),
+ check: m.invoices_method_label_check(),
+ cash: m.invoices_method_label_cash(),
+ offline: m.invoices_method_label_offline(),
+ other: m.invoices_method_label_other(),
+ };
+ return labels[method] ?? method;
+}
+
+function payMethodOptions() {
+ return [
+ { value: "cash", label: m.invoices_pay_method_cash() },
+ { value: "check", label: m.invoices_pay_method_check() },
+ { value: "offline", label: m.invoices_pay_method_offline() },
+ { value: "other", label: m.invoices_pay_method_other() },
+ ];
+}
+
+/** Today as the browser's own calendar day — the value a date input expects. */
+function todayLocal(): string {
+ const now = new Date();
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+}
+
+/**
+ * The picker gives a calendar DAY; the ledger stores an INSTANT. The conversion
+ * has to happen in the browser, because only the browser knows which zone that
+ * day belongs to. Local midnight of a day that has already begun is always in
+ * the past, so "today" can never trip the endpoint's no-future rule.
+ */
+function civilDayToInstant(day: string): string {
+ return new Date(`${day}T00:00:00`).toISOString();
+}
+
+export function PaymentsModal({ invoice, payments, loading, fetcher, locale, onClose }: Props) {
+ const [amount, setAmount] = useState("");
+ const [method, setMethod] = useState("cash");
+ const [occurredOn, setOccurredOn] = useState(todayLocal());
+ const [note, setNote] = useState("");
+ const [correcting, setCorrecting] = useState(null);
+ const [correctedAmount, setCorrectedAmount] = useState("");
+ const [reason, setReason] = useState("");
+
+ if (!invoice) return null;
+ const currency = invoice.currency;
+ const data = fetcher.data as ActionData;
+ const busy = fetcher.state !== "idle";
+
+ // Receipts add, refunds subtract — the same one rule the ledger applies. A
+ // correction is a refund-kind row, so it lands here without a special case.
+ const receivedCents = payments.reduce(
+ (sum, p) => sum + (p.kind === "refund" ? -p.amountCents : p.amountCents),
+ 0,
+ );
+ // The inspector's question is "how much is still owed", not "how much has
+ // been paid" — so this is the figure that gets the size.
+ const remainingCents = invoice.amountCents - receivedCents;
+
+ // The endpoint refuses an overpayment until it is confirmed, because the same
+ // input is far more often a decimal-point typo than a client rounding up.
+ const overpaymentRefused =
+ data?.intent === "record-payment" && data.ok === false && /exceeds/i.test(data.error ?? "");
+
+ function submitPayment(allowOverpayment: boolean) {
+ fetcher.submit(
+ {
+ intent: "record-payment",
+ id: invoice!.id,
+ amount,
+ method,
+ occurredAt: occurredOn ? civilDayToInstant(occurredOn) : "",
+ note,
+ allowOverpayment: allowOverpayment ? "1" : "",
+ },
+ { method: "post" },
+ );
+ }
+
+ function submitCorrection(paymentId: string) {
+ fetcher.submit(
+ { intent: "correct-payment", id: invoice!.id, paymentId, amount: correctedAmount, reason },
+ { method: "post" },
+ );
+ setCorrecting(null);
+ setCorrectedAmount("");
+ setReason("");
+ }
+
+ return (
+ {m.common_close()}}
+ >
+
+ {/* Step 3 — the remaining balance is the prominent figure, formatted
+ through the shared money formatter with the INVOICE's own currency
+ snapshot rather than the tenant's live setting. */}
+
+ {/* fg-2, not fg-3: on the MUTED panel rather than the card, fg-3
+ measures 4.34:1 in light mode — under AA for text this size. */}
+
+ {m.invoices_payments_remaining()}
+
+
+ {formatCurrency(remainingCents, { locale, currency })}
+
+
+ {m.invoices_payments_total_label()} {formatCurrency(invoice.amountCents, { locale, currency })}
+ {" · "}
+ {m.invoices_payments_received_label()} {formatCurrency(receivedCents, { locale, currency })}
+
+
+
+ {data?.ok === false && data.error &&
{data.error} }
+ {overpaymentRefused && (
+
submitPayment(true)} disabled={busy}>
+ {m.invoices_payments_record_anyway()}
+
+ )}
+
+ {/* Step 2 — the rows, not just a total. Once an invoice can hold several
+ payments, "paid $250" stops being the whole story, and a disputed
+ payment is only answerable if amount, method, date and recorder are
+ all on the page. */}
+
+
+ {m.invoices_payments_ledger_title()}
+
+ {loading && payments.length === 0 ? (
+ {m.common_loading()}
+ ) : payments.length === 0 ? (
+ {m.invoices_payments_empty()}
+ ) : (
+
+ {payments.map((p) => {
+ const isCorrection = p.kind === "refund";
+ // A payment can be corrected once. Offering the control on a row
+ // that already carries a correction would only earn a 409, and
+ // the correction is right there on the page saying so.
+ const corrected = payments.some((other) => other.refundsId === p.id);
+ return (
+
+
+
+ {isCorrection ? "−" : ""}
+ {formatCurrency(p.amountCents, { locale, currency })}
+
+
+ {methodLabel(p.method)} · {formatDate(p.occurredAt, { locale })}
+
+
+
+
+ {p.recordedByName
+ ? m.invoices_payments_recorded_by({ name: p.recordedByName })
+ : m.invoices_payments_recorded_automatically()}
+
+ {!isCorrection && !p.provider && !corrected && (
+ setCorrecting(correcting === p.id ? null : p.id)}
+ className="text-[12px] font-bold text-ih-fg-2 hover:underline"
+ >
+ {m.invoices_payments_correct()}
+
+ )}
+
+ {/* A long note must not break the row — it wraps and the
+ row grows, rather than pushing the amount off the end. */}
+ {p.note && (
+ {p.note}
+ )}
+ {correcting === p.id && (
+
+
{m.invoices_payments_correct_hint()}
+
setCorrectedAmount(e.target.value)}
+ />
+
setReason(e.target.value)}
+ />
+
+ setCorrecting(null)}>{m.common_cancel()}
+ submitCorrection(p.id)}>
+ {m.invoices_payments_correct_submit()}
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {/* Step 1 — the date is VISIBLE and EDITABLE, pre-filled with today
+ rather than assumed to be today. Tuesday's cash is recorded on
+ Thursday, and a hidden date makes every reporting period wrong. */}
+
+
+
+ );
+}
diff --git a/app/routes/invoices.tsx b/app/routes/invoices.tsx
index 8faf42254..fc721b20b 100644
--- a/app/routes/invoices.tsx
+++ b/app/routes/invoices.tsx
@@ -10,6 +10,7 @@ import { useDisplayLocale, useDisplayCurrency } from "~/hooks/useSessionContext"
import { m } from "~/paraglide/messages";
import { LoadFailedNotice } from "~/components/LoadFailedNotice";
import { NewInvoiceModal, type InspectionOption } from "~/components/invoices/NewInvoiceModal";
+import { PaymentsModal, type PaymentRow } from "~/components/invoices/PaymentsModal";
export function meta() {
return [{ title: m.invoices_meta_title() }];
@@ -32,25 +33,45 @@ type InvoiceRow = {
export async function loader({ request, context }: Route.LoaderArgs) {
const token = await requireToken(context, request);
+ // `?payments=` asks for one invoice's ledger as well as the list.
+ // A query parameter rather than a second route: the modal needs the refreshed
+ // BALANCE alongside the rows, and the balance lives on the invoice list.
+ // `payments` is always present (empty by default) so the page's data shape
+ // never becomes a union.
+ const paymentsFor = new URL(request.url).searchParams.get("payments");
try {
const api = createApi(context, { token });
- const [invRes, inspRes] = await Promise.all([
+ const [invRes, inspRes, payRes] = await Promise.all([
api.invoices.index.$get(),
api.inspections.index.$get({ query: { limit: "20" } }).catch(() => null),
+ paymentsFor
+ ? api.invoices[":id"].payments.$get({ param: { id: paymentsFor } }).catch(() => null)
+ : Promise.resolve(null),
]);
const body = invRes.ok ? ((await invRes.json()) as Record) : { data: [] };
const inspBody = inspRes?.ok ? ((await inspRes.json()) as { data?: unknown[] }) : { data: [] };
+ const payBody = payRes?.ok ? ((await payRes.json()) as { data?: unknown[] }) : { data: [] };
const inspections = ((inspBody.data ?? []) as Array>).map((i) => ({
id: String(i.id ?? ""),
propertyAddress: (i.propertyAddress as string | null) ?? null,
clientName: (i.clientName as string | null) ?? null,
date: (i.date as string | null) ?? null,
}));
- return { invoices: (body.data ?? []) as InvoiceRow[], inspections, loadFailed: false };
+ return {
+ invoices: (body.data ?? []) as InvoiceRow[],
+ inspections,
+ payments: (payBody.data ?? []) as PaymentRow[],
+ loadFailed: false,
+ };
} catch {
// IA-118 — an empty ledger says nothing is outstanding. That is a claim
// about money owed to the business, and a failed fetch must not make it.
- return { invoices: [] as InvoiceRow[], inspections: [] as InspectionOption[], loadFailed: true };
+ return {
+ invoices: [] as InvoiceRow[],
+ inspections: [] as InspectionOption[],
+ payments: [] as PaymentRow[],
+ loadFailed: true,
+ };
}
}
@@ -88,6 +109,59 @@ export async function action({ request, context }: Route.ActionArgs) {
return { intent, ok: true, error: null };
}
+ // The offline-payment path. `occurredAt` arrives as a full ISO instant that
+ // the BROWSER built from the date picker, because only the browser knows
+ // which zone the chosen calendar day belongs to. It is never defaulted here:
+ // an absent date is an error, not a licence to stamp now().
+ if (intent === "record-payment") {
+ const id = String(fd.get("id") || "");
+ const amountDollars = Number(String(fd.get("amount") || ""));
+ const method = String(fd.get("method") || "cash") as "check" | "cash" | "offline" | "other";
+ const occurredAt = String(fd.get("occurredAt") || "");
+ const note = String(fd.get("note") || "").trim() || null;
+ const allowOverpayment = fd.get("allowOverpayment") === "1";
+ if (!Number.isFinite(amountDollars) || amountDollars <= 0) {
+ return { intent, ok: false, error: m.invoices_payments_error_amount() };
+ }
+ if (!occurredAt) {
+ return { intent, ok: false, error: m.invoices_payments_error_date() };
+ }
+ const api = createApi(context, { token });
+ const res = await api.invoices[":id"].payments.$post({
+ param: { id },
+ json: { amountCents: Math.round(amountDollars * 100), method, occurredAt, note, allowOverpayment },
+ });
+ if (!res.ok) {
+ const err = (await res.json().catch(() => null)) as { error?: { message?: string } } | null;
+ return { intent, ok: false, error: err?.error?.message ?? m.invoices_payments_error_record() };
+ }
+ return { intent, ok: true, error: null };
+ }
+
+ // Append-only: a typo is corrected by a new row, never by editing the old one.
+ if (intent === "correct-payment") {
+ const id = String(fd.get("id") || "");
+ const paymentId = String(fd.get("paymentId") || "");
+ const amountDollars = Number(String(fd.get("amount") || ""));
+ const reason = String(fd.get("reason") || "").trim();
+ if (!Number.isFinite(amountDollars) || amountDollars < 0) {
+ return { intent, ok: false, error: m.invoices_payments_error_amount() };
+ }
+ if (!reason) {
+ return { intent, ok: false, error: m.invoices_payments_error_reason() };
+ }
+ const api = createApi(context, { token });
+ const res = await api.invoices[":id"].payments[":paymentId"].corrections.$post({
+ param: { id, paymentId },
+ json: { correctedAmountCents: Math.round(amountDollars * 100), reason },
+ });
+ if (!res.ok) {
+ const err = (await res.json().catch(() => null)) as { error?: { message?: string } } | null;
+ return { intent, ok: false, error: err?.error?.message ?? m.invoices_payments_error_correct() };
+ }
+ return { intent, ok: true, error: null };
+ }
+
// IA-123 — DELETE /api/invoices/{id} does NOT delete. The service comment is
// explicit: it voids, and "the row is preserved for the audit trail". So the
// intent is named for what happens, and the confirm copy says the same.
@@ -159,6 +233,18 @@ export default function InvoicesPage() {
const [pickerFor, setPickerFor] = useState(null);
const [newOpen, setNewOpen] = useState(false);
+ // The ledger is loaded through its own fetcher so opening the modal does not
+ // navigate. React Router revalidates an active fetcher load after any action
+ // on this route, so recording or correcting a payment refreshes both the rows
+ // and the balance without a second request written by hand.
+ const ledgerFetcher = useFetcher();
+ const paymentFetcher = useFetcher();
+ const [paymentsFor, setPaymentsFor] = useState(null);
+ function openPayments(invoice: InvoiceRow) {
+ setPaymentsFor(invoice);
+ ledgerFetcher.load(`/invoices?payments=${encodeURIComponent(invoice.id)}`);
+ }
+
const total = invoices.length;
const paid = invoices.filter((i) => i.status === "paid").length;
const unpaid = invoices.filter((i) => i.status !== "paid").length;
@@ -208,6 +294,18 @@ export default function InvoicesPage() {
setNewOpen(false)} inspections={inspections} />
+ setPaymentsFor(null)}
+ />
+
{/* IA-123 — says what voiding actually does. The row survives for the
audit trail; what changes is that the invoice stops counting and stops
gating the report. Calling it "delete" would promise a disappearance
@@ -308,6 +406,19 @@ export default function InvoicesPage() {
const isPaid = invoice.status === "paid";
const busy = submittingId === invoice.id;
+ // Present on EVERY row, paid included. "Mark paid" answers one
+ // question ("is it settled?"); this answers the one a dispute
+ // actually turns on — which payments arrived, when, by what
+ // means, and who wrote them down.
+ const payments = (
+ openPayments(invoice)}
+ className="px-3 h-7 rounded-md border border-ih-border bg-ih-bg-card text-[12px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors"
+ >
+ {m.invoices_payments_button()}
+
+ );
+
// The one control for the one destination (IA-122). Rendered on
// every row that HAS an inspection, paid or not — previously
// only paid rows got a button, so the invoice actually needing
@@ -342,6 +453,7 @@ export default function InvoicesPage() {
return (
{viewInspection}
+ {payments}
{voidAction}
);
@@ -373,6 +485,7 @@ export default function InvoicesPage() {
return (
{viewInspection}
+ {payments}
setPickerFor(invoice.id)}
className="px-3 h-7 rounded-md border border-ih-border bg-ih-bg-card text-[12px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors"
diff --git a/messages/en/misc.json b/messages/en/misc.json
index fb41f7e36..cad6c834b 100644
--- a/messages/en/misc.json
+++ b/messages/en/misc.json
@@ -219,6 +219,37 @@
"invoices_void_title": "Void this invoice?",
"invoices_void_confirm": "It stops counting toward revenue and stops holding back the report. The record is kept for your audit trail rather than deleted, and this cannot be undone here.",
"invoices_action_error_void": "Could not void this invoice. Nothing was changed — try again.",
+ "invoices_payments_button": "Payments",
+ "invoices_payments_title": "Payments",
+ "invoices_payments_remaining": "Remaining",
+ "invoices_payments_total_label": "Invoice total",
+ "invoices_payments_received_label": "Received",
+ "invoices_payments_ledger_title": "Recorded payments",
+ "invoices_payments_empty": "No payments recorded yet.",
+ "invoices_payments_recorded_by": "Recorded by {name}",
+ "invoices_payments_recorded_automatically": "Recorded automatically",
+ "invoices_payments_correction_label": "Correction",
+ "invoices_payments_record_title": "Record a payment",
+ "invoices_payments_amount_label": "Amount",
+ "invoices_payments_method_label": "Method",
+ "invoices_payments_date_label": "Date received",
+ "invoices_payments_note_label": "Note",
+ "invoices_payments_note_placeholder": "Cheque number, reference…",
+ "invoices_payments_submit": "Record payment",
+ "invoices_payments_submitting": "Recording…",
+ "invoices_payments_record_anyway": "Record it anyway",
+ "invoices_payments_correct": "Correct",
+ "invoices_payments_correct_title": "Correct this payment",
+ "invoices_payments_correct_hint": "The original stays on the record; the correction is added below it.",
+ "invoices_payments_corrected_amount_label": "Corrected amount",
+ "invoices_payments_reason_label": "Reason",
+ "invoices_payments_reason_placeholder": "Decimal typo",
+ "invoices_payments_correct_submit": "Save correction",
+ "invoices_payments_error_amount": "Enter an amount greater than zero.",
+ "invoices_payments_error_date": "Enter the date the money was received.",
+ "invoices_payments_error_reason": "Say why the original figure was wrong.",
+ "invoices_payments_error_record": "Could not record that payment. Nothing was changed — try again.",
+ "invoices_payments_error_correct": "Could not correct that payment. Nothing was changed — try again.",
"load_failed_generic": "This could not be loaded, so it may be incomplete. Reload before treating it as empty.",
"load_failed_named": "{what} could not be loaded, so this may be incomplete. Reload before treating it as empty."
}
diff --git a/messages/es-419/misc.json b/messages/es-419/misc.json
index 0c9329cb6..679bc9058 100644
--- a/messages/es-419/misc.json
+++ b/messages/es-419/misc.json
@@ -219,6 +219,37 @@
"invoices_void_title": "¿Anular esta factura?",
"invoices_void_confirm": "Deja de contar para los ingresos y deja de retener el informe. El registro se conserva para su pista de auditoría en lugar de eliminarse, y esto no se puede deshacer aquí.",
"invoices_action_error_void": "No se pudo anular esta factura. No se cambió nada — inténtelo de nuevo.",
+ "invoices_payments_button": "Pagos",
+ "invoices_payments_title": "Pagos",
+ "invoices_payments_remaining": "Saldo pendiente",
+ "invoices_payments_total_label": "Total de la factura",
+ "invoices_payments_received_label": "Recibido",
+ "invoices_payments_ledger_title": "Pagos registrados",
+ "invoices_payments_empty": "Todavía no hay pagos registrados.",
+ "invoices_payments_recorded_by": "Registrado por {name}",
+ "invoices_payments_recorded_automatically": "Registrado automáticamente",
+ "invoices_payments_correction_label": "Corrección",
+ "invoices_payments_record_title": "Registrar un pago",
+ "invoices_payments_amount_label": "Monto",
+ "invoices_payments_method_label": "Medio de pago",
+ "invoices_payments_date_label": "Fecha de recepción",
+ "invoices_payments_note_label": "Nota",
+ "invoices_payments_note_placeholder": "Número de cheque, referencia…",
+ "invoices_payments_submit": "Registrar el pago",
+ "invoices_payments_submitting": "Registrando…",
+ "invoices_payments_record_anyway": "Registrarlo de todos modos",
+ "invoices_payments_correct": "Corregir",
+ "invoices_payments_correct_title": "Corregir este pago",
+ "invoices_payments_correct_hint": "El original se conserva en el registro; la corrección se agrega debajo.",
+ "invoices_payments_corrected_amount_label": "Monto corregido",
+ "invoices_payments_reason_label": "Motivo",
+ "invoices_payments_reason_placeholder": "Error de coma decimal",
+ "invoices_payments_correct_submit": "Guardar la corrección",
+ "invoices_payments_error_amount": "Ingrese un monto mayor que cero.",
+ "invoices_payments_error_date": "Ingrese la fecha en que se recibió el dinero.",
+ "invoices_payments_error_reason": "Indique por qué la cifra original era incorrecta.",
+ "invoices_payments_error_record": "No se pudo registrar ese pago. No se cambió nada — inténtelo de nuevo.",
+ "invoices_payments_error_correct": "No se pudo corregir ese pago. No se cambió nada — inténtelo de nuevo.",
"load_failed_generic": "Esto no se pudo cargar, así que puede estar incompleto. Vuelva a cargar antes de darlo por vacío.",
"load_failed_named": "No se pudo cargar {what}, así que esto puede estar incompleto. Vuelva a cargar antes de darlo por vacío."
}
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 1ffbe362e..50948bd53 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -43,7 +43,8 @@
"server/api/inspections/publish.ts": 520,
"server/api/bookings/agreement.ts": 519,
"app/components/settings/ManagedComplianceWizard.tsx": 514,
- "server/services/invoice.service.ts": 510,
+ "server/services/invoice.service.ts": 513,
+ "app/routes/invoices.tsx": 510,
"server/api/repair-builder.ts": 504,
"app/routes/inspection-edit/action.server.ts": 501,
"server/services/inspection-request.service.ts": 501,
diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts
index ad204c42a..e59f8a421 100644
--- a/server/services/invoice.service.ts
+++ b/server/services/invoice.service.ts
@@ -223,8 +223,11 @@ export class InvoiceService {
await seedLedgerFromInvoiceRecord(db, tenantId, id);
const outstanding = existing.amountCents - await getNetReceivedCents(db, tenantId, id);
if (!input.allowOverpayment && input.amountCents > outstanding) {
+ // No figure in the message: it would have to be raw minor units,
+ // and the surface asking the question is already showing the
+ // remaining balance formatted in the invoice's own currency.
throw Errors.UnprocessableEntity(
- `This payment exceeds the outstanding balance on this invoice (${Math.max(outstanding, 0)} cents remaining). Confirm the overpayment if the amount is right.`,
+ 'This payment exceeds the outstanding balance on this invoice. Confirm the overpayment if the amount is right.',
);
}
From 44b7d12ff7a1366b9e4c4a6bd5f5b1e0c413b82b Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 08:41:24 +0800
Subject: [PATCH 091/111] fix(gates): stop the capability gate firing on prose,
and un-export an internal schema
The capability scan matched requireCapability('X') inside comments, so a comment
EXPLAINING a route's gating counted as a mount and was attributed to whichever
route began above it -- calendar-items.ts reported line 34 for a fact about the
route at line 76. Comments are now blanked with equal-length spaces before
matching, which keeps the line arithmetic valid. Falsified: removing the real
declaration makes it name line 76 correctly.
A gate that fires on prose is one people learn to bypass, so this was worth
fixing at the scanner rather than by rewording the comment.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
scripts/check-capability-declarations.mjs | 16 +++++++++++++++-
server/lib/validations/schedule.schema.ts | 2 +-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/scripts/check-capability-declarations.mjs b/scripts/check-capability-declarations.mjs
index 9a97de7e5..824fca373 100644
--- a/scripts/check-capability-declarations.mjs
+++ b/scripts/check-capability-declarations.mjs
@@ -38,9 +38,23 @@ function walkFiles(dir, out = []) {
const OPEN = 'createRoute(withMcpMetadata(';
const failures = [];
+/**
+ * Blank out comment bodies, preserving length and newlines.
+ *
+ * The scan is textual, so a comment EXPLAINING a route's gating — "Gated on
+ * `requireCapability('scheduleOthers')`, matching the write it feeds" — counted
+ * as a mount, and got attributed to whichever route began above it. That is a
+ * false positive on prose, and a gate that fires on prose is one people learn to
+ * bypass. Replacing with spaces rather than deleting keeps every byte offset
+ * valid, which the line-number arithmetic below depends on.
+ */
+function blankComments(src) {
+ return src.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, ' '));
+}
+
for (const file of walkFiles(SCAN_DIR)) {
const rel = relative(ROOT, file).replace(/\\/g, '/');
- const source = readFileSync(file, 'utf8');
+ const source = blankComments(readFileSync(file, 'utf8'));
const starts = [];
for (let i = source.indexOf(OPEN); i !== -1; i = source.indexOf(OPEN, i + 1)) starts.push(i);
for (let w = 0; w < starts.length; w++) {
diff --git a/server/lib/validations/schedule.schema.ts b/server/lib/validations/schedule.schema.ts
index ee900afe9..298e120be 100644
--- a/server/lib/validations/schedule.schema.ts
+++ b/server/lib/validations/schedule.schema.ts
@@ -22,7 +22,7 @@ export const ReschedulePatchSchema = z.object({
.describe('Replace the helper list wholesale. Omit to keep the current helpers — this is NOT merged.'),
}).openapi('ReschedulePatch');
-export const ScheduleConflictSchema = z.object({
+const ScheduleConflictSchema = z.object({
inspectionId: z.string().describe('Colliding inspection id.'),
propertyAddress: z.string().describe('Colliding inspection address.'),
date: z.string().describe('Colliding inspection date.'),
From c5e2f75f754a2d93a054b6770379a55dd219c16b Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 09:39:18 +0800
Subject: [PATCH 092/111] feat(dispatch): read-only dispatch board
One column per schedulable person on a shared 07:00-19:00 axis, the
unassigned lane pinned left, and company closures greyed across the whole
board rather than repeated per column.
Placement is arithmetic over the wall-clock HH:MM strings the server already
resolved in the TENANT timezone - no Date math in the view, so two dispatchers
in different zones see one card in one place. Out-of-axis work is clamped into
view and says so rather than vanishing: a 06:00 job is exactly what a
dispatcher needs to see.
Cards already carry the identifiers a drop handler needs, so drag-drop lands
as behavior rather than as a re-layout.
Measured in Chrome, both themes: the hour gutter and the lane heading moved a
token darker (3.07:1 and 4.34:1 respectively). app/components/dispatch and the
route join the lint:tz scope - it is the same surface the gate exists for.
---
.../dispatch/DispatchBoard.test.tsx | 157 ++++++++++++
app/components/dispatch/DispatchBoard.tsx | 225 ++++++++++++++++++
app/components/dispatch/UnassignedLane.tsx | 87 +++++++
app/components/dispatch/dispatch-helpers.ts | 184 ++++++++++++++
app/routes/calendar-dispatch.tsx | 93 +++++---
messages/en/calendar.json | 19 +-
messages/es-419/calendar.json | 19 +-
scripts/check-tz-safety.mjs | 2 +
8 files changed, 749 insertions(+), 37 deletions(-)
create mode 100644 app/components/dispatch/DispatchBoard.test.tsx
create mode 100644 app/components/dispatch/DispatchBoard.tsx
create mode 100644 app/components/dispatch/UnassignedLane.tsx
create mode 100644 app/components/dispatch/dispatch-helpers.ts
diff --git a/app/components/dispatch/DispatchBoard.test.tsx b/app/components/dispatch/DispatchBoard.test.tsx
new file mode 100644
index 000000000..047d1331b
--- /dev/null
+++ b/app/components/dispatch/DispatchBoard.test.tsx
@@ -0,0 +1,157 @@
+// @vitest-environment happy-dom
+/**
+ * A dispatch board is a claim about WHERE work is: which person owns it, and
+ * what hour it sits at. Both halves are silent when wrong — a card in the wrong
+ * column still looks like a card, and a job at 06:00 that the axis cannot show
+ * simply is not there.
+ *
+ * So the assertions here are about placement, not about pixels being pretty:
+ * a card lands under its owner and nowhere else, an unowned inspection lands in
+ * the lane, a company holiday belongs to the whole board rather than to a
+ * person, and an out-of-axis job is clamped into view rather than dropped.
+ */
+import { describe, it, expect } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import { createRoutesStub } from "react-router";
+
+import { DispatchBoard } from "./DispatchBoard";
+import {
+ BOARD_START_HOUR,
+ HOUR_HEIGHT_PX,
+ bucketColumn,
+ cardGeometry,
+ closureItems,
+ shiftCivilDate,
+ type DispatchItem,
+ type DispatchPayload,
+} from "./dispatch-helpers";
+
+function item(over: Partial & { id: string }): DispatchItem {
+ return {
+ kind: "inspection",
+ title: "Job",
+ start: "2027-03-15",
+ end: "2027-03-15",
+ civilDate: "2027-03-15",
+ allDay: false,
+ ...over,
+ };
+}
+
+const ROSTER = [
+ { id: "u-ada", name: "Ada", email: "ada@example.com", role: "inspector" },
+ { id: "u-bo", name: null, email: "bo@example.com", role: "manager" },
+];
+
+const BOARD: DispatchPayload = {
+ date: "2027-03-15",
+ conflictPolicy: "block",
+ inspectors: ROSTER,
+ items: [
+ item({ id: "i-1", title: "Maple St", startTime: "09:00", endTime: "11:00", inspectionId: "insp-1", userId: "u-ada" }),
+ item({ id: "i-2", title: "Oak Ave", startTime: "13:00", endTime: "14:00", inspectionId: "insp-2", userId: "u-bo" }),
+ item({ id: "i-3", title: "Pine Rd", startTime: "10:00", inspectionId: "insp-3" }),
+ item({ id: "h-1", kind: "company_holiday", title: "Founders Day", allDay: true }),
+ ],
+ unassigned: [
+ item({ id: "i-3", title: "Pine Rd", startTime: "10:00", inspectionId: "insp-3" }),
+ ],
+};
+
+function renderBoard(board: DispatchPayload = BOARD) {
+ const Stub = createRoutesStub([
+ { path: "/", Component: () => },
+ ]);
+ return render( );
+}
+
+describe("DispatchBoard", () => {
+ it("puts each card in its owner's column and nowhere else", () => {
+ renderBoard();
+ const columns = screen.getAllByTestId("dispatch-column");
+ expect(columns).toHaveLength(2);
+
+ expect(columns[0].getAttribute("data-inspector-id")).toBe("u-ada");
+ expect(within(columns[0]).getByText("Maple St")).toBeTruthy();
+ expect(within(columns[0]).queryByText("Oak Ave")).toBeNull();
+
+ expect(columns[1].getAttribute("data-inspector-id")).toBe("u-bo");
+ expect(within(columns[1]).getByText("Oak Ave")).toBeTruthy();
+ });
+
+ it("falls back to the email when an inspector has no name", () => {
+ renderBoard();
+ expect(screen.getByText("bo@example.com")).toBeTruthy();
+ });
+
+ it("keeps an unowned inspection in the lane and out of every column", () => {
+ renderBoard();
+ const lane = screen.getByTestId("dispatch-unassigned-lane");
+ expect(within(lane).getByText("Pine Rd")).toBeTruthy();
+ for (const column of screen.getAllByTestId("dispatch-column")) {
+ expect(within(column).queryByText("Pine Rd")).toBeNull();
+ }
+ });
+
+ it("shows a company closure once for the whole board, not per column", () => {
+ renderBoard();
+ expect(screen.getAllByText(/Founders Day/)).toHaveLength(1);
+ for (const column of screen.getAllByTestId("dispatch-column")) {
+ expect(within(column).queryByText(/Founders Day/)).toBeNull();
+ }
+ });
+
+ it("renders an empty roster as an empty state rather than a bare axis", () => {
+ renderBoard({ ...BOARD, inspectors: [], items: [], unassigned: [] });
+ expect(screen.queryAllByTestId("dispatch-column")).toHaveLength(0);
+ expect(screen.getByText("No inspectors yet")).toBeTruthy();
+ });
+});
+
+describe("dispatch-helpers", () => {
+ it("places a card at the pixel its start hour implies", () => {
+ const geometry = cardGeometry(item({ id: "x", startTime: "09:00", endTime: "11:00" }));
+ expect(geometry).not.toBeNull();
+ expect(geometry?.topPx).toBe((9 - BOARD_START_HOUR) * HOUR_HEIGHT_PX);
+ expect(geometry?.heightPx).toBe(2 * HOUR_HEIGHT_PX);
+ expect(geometry?.clippedStart).toBe(false);
+ });
+
+ it("gives an end-less card a default span instead of a zero-height sliver", () => {
+ const geometry = cardGeometry(item({ id: "x", startTime: "09:00" }));
+ expect(geometry?.heightPx).toBe(HOUR_HEIGHT_PX);
+ });
+
+ it("clamps a pre-dawn job into view and says it is clipped", () => {
+ const geometry = cardGeometry(item({ id: "x", startTime: "05:00", endTime: "06:00" }));
+ expect(geometry).not.toBeNull();
+ expect(geometry?.topPx).toBe(0);
+ expect(geometry?.clippedStart).toBe(true);
+ });
+
+ it("has no geometry for an all-day item, so it cannot land at a random hour", () => {
+ expect(cardGeometry(item({ id: "x", allDay: true, startTime: "09:00" }))).toBeNull();
+ expect(cardGeometry(item({ id: "y" }))).toBeNull();
+ });
+
+ it("keeps a company holiday out of a person's column even when it has a userId", () => {
+ const items = [item({ id: "h", kind: "company_holiday", title: "Closed", allDay: true, userId: "u-ada" })];
+ expect(bucketColumn(items, "u-ada").untimed).toHaveLength(0);
+ expect(closureItems(items)).toHaveLength(1);
+ });
+
+ it("sorts a column by start time, not by feed order", () => {
+ const items = [
+ item({ id: "late", startTime: "15:00", userId: "u-ada" }),
+ item({ id: "early", startTime: "08:00", userId: "u-ada" }),
+ ];
+ expect(bucketColumn(items, "u-ada").timed.map((i) => i.id)).toEqual(["early", "late"]);
+ });
+
+ it("steps civil dates across a month boundary and a DST spring-forward without drifting", () => {
+ expect(shiftCivilDate("2027-02-28", 1)).toBe("2027-03-01");
+ expect(shiftCivilDate("2027-01-01", -1)).toBe("2026-12-31");
+ // US DST begins 2027-03-14; a day step must still be exactly one day.
+ expect(shiftCivilDate("2027-03-14", 1)).toBe("2027-03-15");
+ });
+});
diff --git a/app/components/dispatch/DispatchBoard.tsx b/app/components/dispatch/DispatchBoard.tsx
new file mode 100644
index 000000000..9db222ad1
--- /dev/null
+++ b/app/components/dispatch/DispatchBoard.tsx
@@ -0,0 +1,225 @@
+import { Link } from "react-router";
+import { EmptyState } from "@core/shared-ui";
+import { m } from "~/paraglide/messages";
+import { UnassignedLane } from "./UnassignedLane";
+import {
+ axisHeightPx,
+ boardHours,
+ bucketColumn,
+ cardGeometry,
+ cardTone,
+ closureItems,
+ hourLabel,
+ inspectorLabel,
+ HOUR_HEIGHT_PX,
+ type DispatchInspector,
+ type DispatchItem,
+ type DispatchPayload,
+} from "./dispatch-helpers";
+
+/**
+ * The dispatch board: one column per schedulable person, one shared time axis,
+ * and the unassigned lane pinned to the left.
+ *
+ * Read-only in this task. Every card already carries the identifiers a drop
+ * handler needs (`data-item-id`, `data-inspection-id`, the owning column's
+ * `data-inspector-id`), so drag-drop lands as behavior rather than as a rebuild.
+ *
+ * Columns are a horizontal scroller with a fixed minimum width instead of a
+ * fluid grid: a company with nine inspectors would otherwise get nine 90px
+ * columns, and a card that cannot show its address is not a card. The gutter
+ * sticks to the left edge so the hour a card sits on stays readable at any
+ * scroll offset.
+ */
+export function DispatchBoard({ board }: { board: DispatchPayload }) {
+ const hours = boardHours();
+ const closures = closureItems(board.items);
+ const axisPx = axisHeightPx();
+
+ return (
+
+ {closures.length > 0 && (
+
+ {closures.map((closure) => (
+
+ {m.dispatch_closed_prefix()}: {closure.title}
+
+ ))}
+
+ )}
+
+
+
+
+ {board.inspectors.length === 0 ? (
+
+
+
+ ) : (
+
+
+
+ {board.inspectors.map((inspector) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+function TimeGutter({ hours, axisPx }: { hours: number[]; axisPx: number }) {
+ return (
+
+ {/* Two spacers, not one: the gutter has to line up with BOTH the column
+ heading and the all-day strip, or every card sits an all-day row off. */}
+
+
+ {m.calendar_all_day()}
+
+
+ {hours.map((hour, index) => {
+ const label = hourLabel(hour);
+ return (
+ /* fg-3, not the day calendar's fg-4: fg-4 measured 3.07:1 against
+ the dark card surface, and an hour label is the one thing on this
+ axis a reader must be able to resolve. */
+
+ {label.hour12}:00 {label.meridiem}
+
+ );
+ })}
+
+
+ );
+}
+
+function InspectorColumn({
+ inspector,
+ items,
+ hours,
+ axisPx,
+}: {
+ inspector: DispatchInspector;
+ items: DispatchItem[];
+ hours: number[];
+ axisPx: number;
+}) {
+ const { timed, untimed } = bucketColumn(items, inspector.id);
+
+ return (
+
+
+
+ {inspectorLabel(inspector)}
+
+ {timed.length + untimed.length}
+
+
+
+ {untimed.map((item) => (
+
+ {item.title}
+
+ ))}
+
+
+
+ {hours.map((hour, index) => (
+
+ ))}
+
+ {timed.length === 0 && untimed.length === 0 && (
+
+ {m.dispatch_empty_day()}
+
+ )}
+
+ {timed.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function DispatchCard({ item }: { item: DispatchItem }) {
+ const geometry = cardGeometry(item);
+ if (!geometry) return null;
+
+ const body = (
+ <>
+
+
+ ⠿
+
+ {item.title}
+
+
+ {item.startTime}
+ {item.endTime ? `-${item.endTime}` : ""}
+ {geometry.clippedStart ? ` ${m.dispatch_card_before_axis()}` : ""}
+ {geometry.clippedEnd ? ` ${m.dispatch_card_after_axis()}` : ""}
+
+ >
+ );
+
+ return (
+
+ {item.inspectionId ? (
+
+ {body}
+
+ ) : (
+ body
+ )}
+
+ );
+}
diff --git a/app/components/dispatch/UnassignedLane.tsx b/app/components/dispatch/UnassignedLane.tsx
new file mode 100644
index 000000000..e63b6dcea
--- /dev/null
+++ b/app/components/dispatch/UnassignedLane.tsx
@@ -0,0 +1,87 @@
+import { Link } from "react-router";
+import { m } from "~/paraglide/messages";
+import { minutesOfDay, type DispatchItem } from "./dispatch-helpers";
+
+/**
+ * The left rail: inspections on this day that nobody owns.
+ *
+ * It is a LANE, not a column — the cards carry no axis position because an
+ * unassigned job's time is exactly the thing still being decided. Sorting is
+ * by requested time when one exists so the rail reads like a queue, with
+ * timeless jobs last rather than first (a job with no time is the least urgent
+ * thing to place, not the most).
+ *
+ * The cards are already marked up as drag sources (`data-sortable-item` +
+ * the grip). Nothing is wired to a drag library in this task; the affordance
+ * ships with the shape it will keep, so the drop handling lands as behavior
+ * rather than as a re-layout.
+ */
+export function UnassignedLane({ items }: { items: DispatchItem[] }) {
+ const sorted = [...items].sort((a, b) => {
+ const am = minutesOfDay(a.startTime);
+ const bm = minutesOfDay(b.startTime);
+ if (am == null && bm == null) return a.title.localeCompare(b.title);
+ if (am == null) return 1;
+ if (bm == null) return -1;
+ return am - bm;
+ });
+
+ return (
+
+
+ {/* fg-2: fg-3 measured 4.34:1 on the muted rail surface in light mode. */}
+
+ {m.dispatch_unassigned_heading()}
+ {sorted.length}
+
+
+
+ {sorted.length === 0 ? (
+
+ {m.dispatch_unassigned_empty()}
+
+ ) : (
+ sorted.map((item) => (
+
+
+
+ ⠿
+
+
+ {item.inspectionId ? (
+
+ {item.title}
+
+ ) : (
+
{item.title}
+ )}
+
+ {item.startTime ?? m.dispatch_column_untimed()}
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/app/components/dispatch/dispatch-helpers.ts b/app/components/dispatch/dispatch-helpers.ts
new file mode 100644
index 000000000..d3e61f3ad
--- /dev/null
+++ b/app/components/dispatch/dispatch-helpers.ts
@@ -0,0 +1,184 @@
+/**
+ * Dispatch board geometry and bucketing.
+ *
+ * Pure functions with no React and no `Date`: the board's placement rules are
+ * arithmetic over the wall-clock `HH:MM` strings the server already resolved in
+ * the TENANT timezone. Re-deriving a time here from an instant would reopen the
+ * calendar off-by-one — two dispatchers in different zones must see one card in
+ * one place, and the only string that guarantees that is the one the server sent.
+ */
+
+export interface DispatchInspector {
+ id: string;
+ name: string | null;
+ email: string;
+ role: string;
+}
+
+export interface DispatchItem {
+ id: string;
+ kind: string;
+ title: string;
+ start: string;
+ end: string;
+ civilDate: string;
+ startTime?: string;
+ endTime?: string;
+ allDay: boolean;
+ color?: string;
+ inspectionId?: string;
+ userId?: string;
+ meta?: Record;
+}
+
+export interface DispatchPayload {
+ date: string;
+ conflictPolicy: "advisory" | "block";
+ inspectors: DispatchInspector[];
+ items: DispatchItem[];
+ unassigned: DispatchItem[];
+}
+
+/** Axis bounds, in tenant wall-clock hours. Tenant-configurable later. */
+export const BOARD_START_HOUR = 7;
+export const BOARD_END_HOUR = 19;
+/** One axis hour in pixels — matches the day calendar's row height. */
+export const HOUR_HEIGHT_PX = 56;
+/** A card whose end instant was never stored still has to be grabbable. */
+export const DEFAULT_CARD_MINUTES = 60;
+/** Below this a card is a line, not a target. */
+const MIN_CARD_PX = 22;
+
+const AXIS_START_MIN = BOARD_START_HOUR * 60;
+const AXIS_END_MIN = BOARD_END_HOUR * 60;
+
+/** Hour labels down the gutter, inclusive of the closing hour's line. */
+export function boardHours(): number[] {
+ const out: number[] = [];
+ for (let h = BOARD_START_HOUR; h < BOARD_END_HOUR; h++) out.push(h);
+ return out;
+}
+
+/** `HH:MM` → minutes since midnight, or null when absent/malformed. */
+export function minutesOfDay(hhmm: string | undefined): number | null {
+ if (!hhmm) return null;
+ const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
+ if (!match) return null;
+ const hours = Number(match[1]);
+ const mins = Number(match[2]);
+ if (hours > 23 || mins > 59) return null;
+ return hours * 60 + mins;
+}
+
+/** 12-hour gutter label, assembled rather than formatted — see `lint:i18n`. */
+export function hourLabel(hour: number): { hour12: number; meridiem: "AM" | "PM" } {
+ const hour12 = hour % 12 === 0 ? 12 : hour % 12;
+ return { hour12, meridiem: hour >= 12 ? "PM" : "AM" };
+}
+
+export interface CardGeometry {
+ topPx: number;
+ heightPx: number;
+ /** The card really starts before the axis does — the top edge is a lie. */
+ clippedStart: boolean;
+ /** The card really ends after the axis does. */
+ clippedEnd: boolean;
+}
+
+const clamp = (value: number, low: number, high: number) =>
+ Math.min(Math.max(value, low), high);
+
+const pxFromAxis = (minute: number) =>
+ ((minute - AXIS_START_MIN) / 60) * HOUR_HEIGHT_PX;
+
+/**
+ * Where a timed card sits on the axis. Returns null for all-day items and for
+ * anything with no usable start — those belong in the all-day strip, not at an
+ * arbitrary pixel. Cards outside the axis are CLAMPED rather than dropped: an
+ * inspection at 06:00 is exactly the thing a dispatcher needs to see, and
+ * hiding it because the axis starts at 07:00 would make the board lie.
+ */
+export function cardGeometry(item: DispatchItem): CardGeometry | null {
+ const startMin = minutesOfDay(item.startTime);
+ if (item.allDay || startMin == null) return null;
+ const endMin = Math.max(
+ minutesOfDay(item.endTime) ?? startMin + DEFAULT_CARD_MINUTES,
+ startMin + 1,
+ );
+
+ const top = clamp(startMin, AXIS_START_MIN, AXIS_END_MIN - 1);
+ const bottom = clamp(endMin, top + 1, AXIS_END_MIN);
+
+ return {
+ topPx: pxFromAxis(top),
+ heightPx: Math.max(pxFromAxis(bottom) - pxFromAxis(top), MIN_CARD_PX),
+ clippedStart: startMin < AXIS_START_MIN,
+ clippedEnd: endMin > AXIS_END_MIN,
+ };
+}
+
+/** Total axis height, so the column and the gutter cannot disagree. */
+export function axisHeightPx(): number {
+ return (BOARD_END_HOUR - BOARD_START_HOUR) * HOUR_HEIGHT_PX;
+}
+
+/**
+ * Company-wide closures. These carry no `userId`, so they are NOT a column's
+ * items — they grey the whole board. Unassigned inspections also carry no
+ * `userId`, which is why this keys on the kind and not on the absence.
+ */
+export function closureItems(items: DispatchItem[]): DispatchItem[] {
+ return items.filter((item) => item.kind === "company_holiday");
+}
+
+export interface ColumnBuckets {
+ /** Placeable on the axis, earliest first. */
+ timed: DispatchItem[];
+ /** All-day or untimed — rendered in the strip above the axis. */
+ untimed: DispatchItem[];
+}
+
+/**
+ * One inspector's day. `userId` is the resolved owner the feed already worked
+ * out (link table first, legacy `inspections.inspector_id` as fallback), so the
+ * board never re-implements that precedence.
+ */
+export function bucketColumn(items: DispatchItem[], inspectorId: string): ColumnBuckets {
+ const mine = items.filter(
+ (item) => item.userId === inspectorId && item.kind !== "company_holiday",
+ );
+ const timed = mine.filter((item) => cardGeometry(item) !== null);
+ const untimed = mine.filter((item) => cardGeometry(item) === null);
+ timed.sort((a, b) => (minutesOfDay(a.startTime) ?? 0) - (minutesOfDay(b.startTime) ?? 0));
+ return { timed, untimed };
+}
+
+/** Design-system tone per item kind, mirroring the calendar's `eventColor`. */
+export function cardTone(kind: string): string {
+ if (kind === "calendar_block") return "bg-ih-fg-3 text-ih-fg-inverse";
+ if (kind === "external_busy") return "bg-ih-fg-4 text-ih-fg-inverse";
+ if (kind === "company_holiday") return "bg-ih-watch text-ih-fg-inverse";
+ return "bg-ih-primary text-ih-fg-inverse";
+}
+
+/** Column heading — a name when there is one, the login otherwise. */
+export function inspectorLabel(inspector: DispatchInspector): string {
+ const name = inspector.name?.trim();
+ return name ? name : inspector.email;
+}
+
+/**
+ * Shift a civil date by whole days without ever touching local time. Built on
+ * `Date.UTC` and read back with the UTC accessors, so the arithmetic happens in
+ * a zone with no DST and the result is a pure string transform.
+ */
+export function shiftCivilDate(date: string, days: number): string {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
+ if (!match) return date;
+ const at = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
+ at.setUTCDate(at.getUTCDate() + days);
+ const y = at.getUTCFullYear();
+ const m = String(at.getUTCMonth() + 1).padStart(2, "0");
+ const d = String(at.getUTCDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
diff --git a/app/routes/calendar-dispatch.tsx b/app/routes/calendar-dispatch.tsx
index f929704b6..c57b8e2e3 100644
--- a/app/routes/calendar-dispatch.tsx
+++ b/app/routes/calendar-dispatch.tsx
@@ -1,10 +1,5 @@
/**
- * /calendar/dispatch — the dispatch board's DATA half.
- *
- * This module deliberately exports a loader and no component yet: the board UI
- * (DispatchBoard, the unassigned lane, drag-drop) is the next task, and landing
- * the data contract first means it can be reviewed on its own terms. Adding the
- * default export is that task's first step.
+ * /calendar/dispatch — the dispatch board.
*
* The gate is a redirect, not an error page. Whether the actor may dispatch is
* decided on the server — `GET /api/calendar/dispatch` mounts
@@ -12,41 +7,24 @@
* and this loader simply honors its answer. That ordering matters: the page can
* never offer an action the API would refuse, because it never learns about the
* day at all unless the API already said yes.
+ *
+ * The board's payload types live in `~/components/dispatch/dispatch-helpers`
+ * rather than here, so the components that render them and the loader that
+ * fetches them cannot drift into two shapes of the same response.
*/
+import { Link, useLoaderData } from "react-router";
import { redirect } from "react-router";
+import { PageHeader, Button } from "@core/shared-ui";
import type { Route } from "./+types/calendar-dispatch";
import { requireToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
+import { LoadFailedNotice } from "~/components/LoadFailedNotice";
+import { DispatchBoard } from "~/components/dispatch/DispatchBoard";
+import { shiftCivilDate, type DispatchPayload } from "~/components/dispatch/dispatch-helpers";
+import { m } from "~/paraglide/messages";
-interface DispatchInspector {
- id: string;
- name: string | null;
- email: string;
- role: string;
-}
-
-interface DispatchItem {
- id: string;
- kind: string;
- title: string;
- start: string;
- end: string;
- civilDate: string;
- startTime?: string;
- endTime?: string;
- allDay: boolean;
- color?: string;
- inspectionId?: string;
- userId?: string;
- meta?: Record;
-}
-
-interface DispatchPayload {
- date: string;
- conflictPolicy: "advisory" | "block";
- inspectors: DispatchInspector[];
- items: DispatchItem[];
- unassigned: DispatchItem[];
+export function meta() {
+ return [{ title: m.dispatch_meta_title() }];
}
export async function loader({ request, context }: Route.LoaderArgs) {
@@ -80,3 +58,48 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return { failed: false as const, board };
}
+
+export default function CalendarDispatchPage() {
+ const { failed, board } = useLoaderData();
+
+ return (
+
+ {/* A board with no cards is a statement a dispatcher acts on by leaving
+ the day alone. Say when it is not a real answer (IA-118). */}
+ {failed &&
}
+
+
+
+ {board?.conflictPolicy === "block"
+ ? m.dispatch_policy_block()
+ : m.dispatch_policy_advisory()}
+
+
+ {m.calendar_page_title()}
+
+
+ }
+ />
+
+ {board && (
+
+
+ {m.dispatch_nav_prev_day()}
+
+
+ {m.calendar_nav_today()}
+
+
+ {m.dispatch_nav_next_day()}
+
+
+ )}
+
+ {board ? : null}
+
+ );
+}
diff --git a/messages/en/calendar.json b/messages/en/calendar.json
index 123611d94..7f6f8d48e 100644
--- a/messages/en/calendar.json
+++ b/messages/en/calendar.json
@@ -63,5 +63,22 @@
"calendar_sync_never": "Never synced",
"calendar_event_time_label": "Time:",
"calendar_event_time_range": "{start} - {end}",
- "calendar_event_status_results_received": "Results received"
+ "calendar_event_status_results_received": "Results received",
+ "dispatch_meta_title": "Dispatch - OpenInspection",
+ "dispatch_page_title": "Dispatch",
+ "dispatch_load_failed_what": "the dispatch board",
+ "dispatch_unassigned_heading": "Unassigned",
+ "dispatch_unassigned_empty": "Everything today has an inspector.",
+ "dispatch_no_inspectors_title": "No inspectors yet",
+ "dispatch_no_inspectors_body": "Invite someone who can be scheduled and their column appears here.",
+ "dispatch_nav_prev_day": "Previous day",
+ "dispatch_nav_next_day": "Next day",
+ "dispatch_policy_block": "Double-booking blocked",
+ "dispatch_policy_advisory": "Double-booking allowed",
+ "dispatch_closed_prefix": "Closed",
+ "dispatch_card_grip": "Drag to reschedule",
+ "dispatch_column_untimed": "No time set",
+ "dispatch_empty_day": "Nothing scheduled",
+ "dispatch_card_before_axis": "(starts earlier)",
+ "dispatch_card_after_axis": "(ends later)"
}
diff --git a/messages/es-419/calendar.json b/messages/es-419/calendar.json
index 78b10cb19..3c74a8869 100644
--- a/messages/es-419/calendar.json
+++ b/messages/es-419/calendar.json
@@ -63,5 +63,22 @@
"calendar_sync_never": "Nunca se sincronizó",
"calendar_event_time_label": "Hora:",
"calendar_event_time_range": "{start} - {end}",
- "calendar_event_status_results_received": "Resultados recibidos"
+ "calendar_event_status_results_received": "Resultados recibidos",
+ "dispatch_meta_title": "Despacho - OpenInspection",
+ "dispatch_page_title": "Despacho",
+ "dispatch_load_failed_what": "el tablero de despacho",
+ "dispatch_unassigned_heading": "Sin asignar",
+ "dispatch_unassigned_empty": "Todo lo de hoy tiene inspector.",
+ "dispatch_no_inspectors_title": "Aún no hay inspectores",
+ "dispatch_no_inspectors_body": "Invita a alguien que se pueda programar y su columna aparecerá aquí.",
+ "dispatch_nav_prev_day": "Día anterior",
+ "dispatch_nav_next_day": "Día siguiente",
+ "dispatch_policy_block": "Doble reserva bloqueada",
+ "dispatch_policy_advisory": "Doble reserva permitida",
+ "dispatch_closed_prefix": "Cerrado",
+ "dispatch_card_grip": "Arrastra para reprogramar",
+ "dispatch_column_untimed": "Sin hora",
+ "dispatch_empty_day": "Nada programado",
+ "dispatch_card_before_axis": "(empieza antes)",
+ "dispatch_card_after_axis": "(termina después)"
}
diff --git a/scripts/check-tz-safety.mjs b/scripts/check-tz-safety.mjs
index 9e26687a3..aaf504b89 100644
--- a/scripts/check-tz-safety.mjs
+++ b/scripts/check-tz-safety.mjs
@@ -56,7 +56,9 @@ export function findTzViolations(source, filename) {
// Calendar surface only. Test/spec files are exempt (they construct fixtures).
const SCOPE = [
'app/components/calendar',
+ 'app/components/dispatch',
'app/routes/calendar.tsx',
+ 'app/routes/calendar-dispatch.tsx',
'server/services/calendar-items.service.ts',
];
From 9f3672a1d1d37f00360c914d3d3bd62d217f1481 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 10:19:35 +0800
Subject: [PATCH 093/111] feat(dispatch): drag-drop reschedule and reassign
A card dragged onto a column moves in time AND in ownership in one write
(PATCH /api/inspections/:id/schedule): a dispatcher's gesture moves both, and
two calls would leave a window where the board shows a job at a time nobody
owns. Dropping onto the unassigned lane takes the person off and leaves the
time alone.
Vertical position snaps to the tenant's booking_slot_interval_min, not to a
prettier number - a dragged job has to land on a time the booking engine would
also have offered a customer. The lattice and the day's tenant-local midnight
ride along in the board payload (slotIntervalMin, dayStartMs), so a dropped
pixel becomes an instant without the browser ever guessing a timezone.
Conflicts stay two distinct outcomes: advisory overlaps toast and the write
stands; a 409 from a blocking tenant opens a modal naming the colliding jobs
and nothing was written. There is deliberately no "do it anyway" - an override
would make the setting a suggestion.
Drag is HTML5 DnD, not sortablejs (plan corrected at source, superproject
48a36f35): sortablejs reorders DOM children and every card here is absolutely
positioned, so its drop model cannot express which pixel the drag ended at.
No dependency was added either way.
---
app/components/dispatch/ConflictModal.tsx | 48 +++
.../dispatch/DispatchBoard.test.tsx | 132 ++++++-
app/components/dispatch/DispatchBoard.tsx | 344 ++++++++----------
app/components/dispatch/DispatchColumn.tsx | 230 ++++++++++++
app/components/dispatch/UnassignedLane.tsx | 36 +-
app/components/dispatch/dispatch-helpers.ts | 85 +++++
app/routes/calendar-dispatch.tsx | 60 ++-
messages/en/calendar.json | 7 +-
messages/es-419/calendar.json | 7 +-
server/api/calendar-items.ts | 20 +-
.../lib/validations/calendar-items.schema.ts | 4 +
tests/unit/calendar/dispatch-board.spec.ts | 22 ++
12 files changed, 791 insertions(+), 204 deletions(-)
create mode 100644 app/components/dispatch/ConflictModal.tsx
create mode 100644 app/components/dispatch/DispatchColumn.tsx
diff --git a/app/components/dispatch/ConflictModal.tsx b/app/components/dispatch/ConflictModal.tsx
new file mode 100644
index 000000000..68e3e7190
--- /dev/null
+++ b/app/components/dispatch/ConflictModal.tsx
@@ -0,0 +1,48 @@
+import { Button, Modal } from "@core/shared-ui";
+import { m } from "~/paraglide/messages";
+import type { ScheduleConflict } from "./dispatch-helpers";
+
+/**
+ * What a refused drop is allowed to say.
+ *
+ * The tenant's `booking_conflict_policy` is `block`, so the server already
+ * declined the write — this window reports a decision, it does not ask for
+ * one. There is deliberately no "do it anyway": an override here would make
+ * the setting a suggestion, and the same drag would then mean different things
+ * depending on which surface performed it.
+ *
+ * It names the colliding jobs because "that slot is taken" without saying BY
+ * WHAT sends the dispatcher hunting through the board they were already
+ * looking at.
+ */
+export function ConflictModal({
+ open,
+ conflicts,
+ onClose,
+}: {
+ open: boolean;
+ conflicts: ScheduleConflict[];
+ onClose: () => void;
+}) {
+ return (
+ {m.dispatch_conflict_close()} }
+ >
+
{m.dispatch_conflict_body()}
+
+ {conflicts.map((conflict) => (
+
+ {conflict.propertyAddress}
+ {conflict.date}
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/dispatch/DispatchBoard.test.tsx b/app/components/dispatch/DispatchBoard.test.tsx
index 047d1331b..f2b6a7629 100644
--- a/app/components/dispatch/DispatchBoard.test.tsx
+++ b/app/components/dispatch/DispatchBoard.test.tsx
@@ -10,8 +10,8 @@
* the lane, a company holiday belongs to the whole board rather than to a
* person, and an out-of-axis job is clamped into view rather than dropped.
*/
-import { describe, it, expect } from "vitest";
-import { render, screen, within } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { DispatchBoard } from "./DispatchBoard";
@@ -21,7 +21,10 @@ import {
bucketColumn,
cardGeometry,
closureItems,
+ isDraggableItem,
+ minuteFromOffsetY,
shiftCivilDate,
+ snapMinute,
type DispatchItem,
type DispatchPayload,
} from "./dispatch-helpers";
@@ -43,9 +46,13 @@ const ROSTER = [
{ id: "u-bo", name: null, email: "bo@example.com", role: "manager" },
];
+const DAY_START_MS = Date.UTC(2027, 2, 15, 4, 0, 0);
+
const BOARD: DispatchPayload = {
date: "2027-03-15",
conflictPolicy: "block",
+ slotIntervalMin: 30,
+ dayStartMs: DAY_START_MS,
inspectors: ROSTER,
items: [
item({ id: "i-1", title: "Maple St", startTime: "09:00", endTime: "11:00", inspectionId: "insp-1", userId: "u-ada" }),
@@ -58,13 +65,38 @@ const BOARD: DispatchPayload = {
],
};
-function renderBoard(board: DispatchPayload = BOARD) {
+function renderBoard(
+ board: DispatchPayload = BOARD,
+ action?: (args: { request: Request }) => unknown,
+) {
const Stub = createRoutesStub([
- { path: "/", Component: () =>
},
+ {
+ path: "/",
+ Component: () =>
,
+ ...(action ? { action } : {}),
+ },
]);
return render(
);
}
+/**
+ * happy-dom reports a zero-origin rect, so clientY IS the axis offset here.
+ *
+ * The drop is dispatched as a real MouseEvent named "drop" rather than through
+ * `fireEvent.drop`: happy-dom's DragEvent does not carry pointer coordinates,
+ * and a drop with no clientY is exactly the case these tests exist to pin down.
+ */
+function dragCardTo(cardText: string, dropzone: Element, clientY: number) {
+ const card = screen.getByText(cardText).closest("[data-item-id]") as HTMLElement;
+ const dataTransfer = { setData: vi.fn(), effectAllowed: "" };
+ fireEvent.dragStart(card, { dataTransfer });
+ for (const type of ["dragover", "drop"]) {
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientY });
+ Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
+ fireEvent(dropzone, event);
+ }
+}
+
describe("DispatchBoard", () => {
it("puts each card in its owner's column and nowhere else", () => {
renderBoard();
@@ -108,7 +140,99 @@ describe("DispatchBoard", () => {
});
});
+describe("DispatchBoard drag-drop", () => {
+ it("sends the dropped column AND the snapped instant in one write", async () => {
+ const posted: Record
[] = [];
+ renderBoard(BOARD, async ({ request }) => {
+ const form = await request.formData();
+ posted.push(Object.fromEntries(form) as Record);
+ return { ok: true, conflicts: [] };
+ });
+
+ const ada = screen.getAllByTestId("dispatch-column")[0];
+ // 112px below the axis top = 09:00 on a 56px hour starting at 07:00.
+ dragCardTo("Pine Rd", ada.querySelector("[data-dispatch-dropzone]")!, 112);
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ expect(posted[0]).toMatchObject({
+ intent: "reschedule",
+ inspectionId: "insp-3",
+ leadInspectorId: "u-ada",
+ scheduledStartMs: String(DAY_START_MS + 9 * 60 * 60_000),
+ });
+ });
+
+ it("snaps a between-slots drop onto the tenant's booking lattice", async () => {
+ const posted: Record[] = [];
+ renderBoard(BOARD, async ({ request }) => {
+ const form = await request.formData();
+ posted.push(Object.fromEntries(form) as Record);
+ return { ok: true, conflicts: [] };
+ });
+
+ const ada = screen.getAllByTestId("dispatch-column")[0];
+ // 130px ≈ 09:19 — with a 30-minute interval the only honest answer is 09:30.
+ dragCardTo("Pine Rd", ada.querySelector("[data-dispatch-dropzone]")!, 130);
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ expect(posted[0].scheduledStartMs).toBe(String(DAY_START_MS + (9 * 60 + 30) * 60_000));
+ });
+
+ it("unassigns with the time intact when a card is dropped on the lane", async () => {
+ const posted: Record[] = [];
+ renderBoard(BOARD, async ({ request }) => {
+ const form = await request.formData();
+ posted.push(Object.fromEntries(form) as Record);
+ return { ok: true, conflicts: [] };
+ });
+
+ dragCardTo("Maple St", screen.getByTestId("dispatch-unassigned-lane"), 0);
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ expect(posted[0].leadInspectorId).toBe("");
+ expect(posted[0].scheduledStartMs).toBe(String(DAY_START_MS + 9 * 60 * 60_000));
+ });
+
+ it("opens the conflict modal on a blocked drop instead of claiming success", async () => {
+ renderBoard(BOARD, async () => ({
+ ok: false,
+ code: "SCHEDULE_CONFLICT",
+ message: "blocked",
+ conflicts: [{ inspectionId: "insp-9", propertyAddress: "77 Cedar Ln", date: "2027-03-15", inspectorId: "u-ada" }],
+ }));
+
+ const ada = screen.getAllByTestId("dispatch-column")[0];
+ dragCardTo("Pine Rd", ada.querySelector("[data-dispatch-dropzone]")!, 112);
+
+ await waitFor(() => expect(screen.getByText("77 Cedar Ln")).toBeTruthy());
+ expect(screen.getByText("That slot is already taken")).toBeTruthy();
+ });
+
+ it("does not offer a company closure as a drag source", () => {
+ renderBoard();
+ const closure = screen.getByText(/Founders Day/);
+ expect(closure.closest("[draggable=true]")).toBeNull();
+ expect(isDraggableItem(item({ id: "h", kind: "company_holiday", allDay: true }))).toBe(false);
+ expect(isDraggableItem(item({ id: "b", kind: "calendar_block", startTime: "09:00", userId: "u-ada" }))).toBe(false);
+ });
+});
+
describe("dispatch-helpers", () => {
+ it("snaps to the tenant interval, never to a prettier number", () => {
+ expect(snapMinute(9 * 60 + 19, 30)).toBe(9 * 60 + 30);
+ expect(snapMinute(9 * 60 + 14, 30)).toBe(9 * 60);
+ expect(snapMinute(9 * 60 + 7, 15)).toBe(9 * 60);
+ expect(snapMinute(9 * 60 + 8, 15)).toBe(9 * 60 + 15);
+ // A zero/garbage interval must not divide by zero into NaN minutes.
+ expect(snapMinute(9 * 60 + 19, 0)).toBe(9 * 60 + 30);
+ });
+
+ it("clamps a drop past either end of the axis back onto it", () => {
+ expect(minuteFromOffsetY(-500, 30)).toBe(BOARD_START_HOUR * 60);
+ expect(minuteFromOffsetY(99_999, 30)).toBe(19 * 60);
+ });
+
+
it("places a card at the pixel its start hour implies", () => {
const geometry = cardGeometry(item({ id: "x", startTime: "09:00", endTime: "11:00" }));
expect(geometry).not.toBeNull();
diff --git a/app/components/dispatch/DispatchBoard.tsx b/app/components/dispatch/DispatchBoard.tsx
index 9db222ad1..4ad2e17b7 100644
--- a/app/components/dispatch/DispatchBoard.tsx
+++ b/app/components/dispatch/DispatchBoard.tsx
@@ -1,225 +1,195 @@
-import { Link } from "react-router";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useFetcher } from "react-router";
import { EmptyState } from "@core/shared-ui";
import { m } from "~/paraglide/messages";
+import { pushToast } from "~/hooks/useToast";
import { UnassignedLane } from "./UnassignedLane";
+import { ConflictModal } from "./ConflictModal";
+import { InspectorColumn, TimeGutter } from "./DispatchColumn";
import {
axisHeightPx,
boardHours,
- bucketColumn,
- cardGeometry,
- cardTone,
closureItems,
- hourLabel,
- inspectorLabel,
- HOUR_HEIGHT_PX,
- type DispatchInspector,
+ currentStartMs,
+ isDraggableItem,
+ minuteFromOffsetY,
+ minuteToEpochMs,
type DispatchItem,
type DispatchPayload,
+ type RescheduleResult,
+ type ScheduleConflict,
} from "./dispatch-helpers";
/**
* The dispatch board: one column per schedulable person, one shared time axis,
* and the unassigned lane pinned to the left.
*
- * Read-only in this task. Every card already carries the identifiers a drop
- * handler needs (`data-item-id`, `data-inspection-id`, the owning column's
- * `data-inspector-id`), so drag-drop lands as behavior rather than as a rebuild.
+ * Dragging uses the platform's own HTML5 drag-and-drop, the same mechanism the
+ * calendar's day/week/month views already use. The plan reached for sortablejs
+ * because it is already a dependency — but sortablejs REORDERS DOM CHILDREN,
+ * and every card here is absolutely positioned on a time axis. Its drop model
+ * ("between these two siblings") cannot express this board's only question,
+ * "which pixel did you let go at", and making it answer that means mutating the
+ * DOM and then undoing the mutation so React can re-render from server state.
+ * HTML5 DnD answers it directly with `clientY`, adds no dependency either, and
+ * leaves React the single source of truth.
*
- * Columns are a horizontal scroller with a fixed minimum width instead of a
- * fluid grid: a company with nine inspectors would otherwise get nine 90px
- * columns, and a card that cannot show its address is not a card. The gutter
- * sticks to the left edge so the hour a card sits on stays readable at any
- * scroll offset.
+ * A drop is one write: `PATCH /api/inspections/:id/schedule` through the route
+ * action, carrying both the new instant and the new lead. Time and ownership
+ * move together because a dispatcher's gesture moves them together — two calls
+ * would leave a window where the board shows a job at a time nobody owns.
*/
export function DispatchBoard({ board }: { board: DispatchPayload }) {
+ const fetcher = useFetcher();
const hours = boardHours();
const closures = closureItems(board.items);
const axisPx = axisHeightPx();
- return (
-
- {closures.length > 0 && (
-
- {closures.map((closure) => (
-
- {m.dispatch_closed_prefix()}: {closure.title}
-
- ))}
-
- )}
-
-
-
+ const [draggingId, setDraggingId] = useState
(null);
+ const [hover, setHover] = useState<{ inspectorId: string; minute: number } | null>(null);
+ const [blocked, setBlocked] = useState(null);
- {board.inspectors.length === 0 ? (
-
-
-
- ) : (
-
-
-
- {board.inspectors.map((inspector) => (
-
- ))}
-
-
- )}
-
-
+ const byId = useMemo(
+ () => new Map(board.items.map((item) => [item.id, item])),
+ [board.items],
);
-}
-function TimeGutter({ hours, axisPx }: { hours: number[]; axisPx: number }) {
- return (
-
- {/* Two spacers, not one: the gutter has to line up with BOTH the column
- heading and the all-day strip, or every card sits an all-day row off. */}
-
-
- {m.calendar_all_day()}
-
-
- {hours.map((hour, index) => {
- const label = hourLabel(hour);
- return (
- /* fg-3, not the day calendar's fg-4: fg-4 measured 3.07:1 against
- the dark card surface, and an hour label is the one thing on this
- axis a reader must be able to resolve. */
-
- {label.hour12}:00 {label.meridiem}
-
- );
- })}
-
-
- );
-}
+ // Report each result once. `fetcher.data` survives across re-renders, so a
+ // plain effect on it would re-toast on every unrelated state change — the
+ // board has several (hover, drag id), and a warning that reappears when you
+ // move the mouse reads as a second failure.
+ const handled = useRef(null);
+ useEffect(() => {
+ const data = fetcher.data;
+ if (!data || fetcher.state !== "idle" || handled.current === data) return;
+ handled.current = data;
+ if (data.ok) {
+ if (data.conflicts && data.conflicts.length > 0) {
+ pushToast({ message: m.dispatch_toast_overlap(), variant: "warning", durationMs: 6000 });
+ }
+ return;
+ }
+ if (data.code === "SCHEDULE_CONFLICT") {
+ setBlocked(data.conflicts ?? []);
+ return;
+ }
+ pushToast({
+ message: data.message || m.dispatch_toast_failed(),
+ variant: "error",
+ durationMs: 6000,
+ });
+ }, [fetcher.data, fetcher.state]);
-function InspectorColumn({
- inspector,
- items,
- hours,
- axisPx,
-}: {
- inspector: DispatchInspector;
- items: DispatchItem[];
- hours: number[];
- axisPx: number;
-}) {
- const { timed, untimed } = bucketColumn(items, inspector.id);
+ const dragged = draggingId ? byId.get(draggingId) ?? null : null;
- return (
-
-
-
- {inspectorLabel(inspector)}
-
- {timed.length + untimed.length}
-
+ function move(item: DispatchItem, startMs: number, leadInspectorId: string) {
+ if (!item.inspectionId) return;
+ fetcher.submit(
+ {
+ intent: "reschedule",
+ inspectionId: item.inspectionId,
+ scheduledStartMs: String(startMs),
+ leadInspectorId,
+ },
+ { method: "post" },
+ );
+ }
-
- {untimed.map((item) => (
-
- {item.title}
-
- ))}
-
+ function dropOnColumn(inspectorId: string, event: React.DragEvent
) {
+ event.preventDefault();
+ setHover(null);
+ setDraggingId(null);
+ if (!dragged || !isDraggableItem(dragged)) return;
+ const rect = event.currentTarget.getBoundingClientRect();
+ const minute = minuteFromOffsetY(event.clientY - rect.top, board.slotIntervalMin);
+ // A drop with no usable pointer position is not a time. Sending it anyway
+ // would post NaN milliseconds and move the job to the epoch.
+ if (!Number.isFinite(minute)) return;
+ move(dragged, minuteToEpochMs(board.dayStartMs, minute), inspectorId);
+ }
+ // Dropping into the lane is an UNASSIGN, not a reschedule: the time the job
+ // was pencilled in for is exactly what a dispatcher is still holding while
+ // they look for someone to work it, so the instant is carried over unchanged.
+ function dropOnLane(event: React.DragEvent) {
+ event.preventDefault();
+ setHover(null);
+ setDraggingId(null);
+ if (!dragged || !isDraggableItem(dragged)) return;
+ const startMs = currentStartMs(dragged, board.dayStartMs);
+ if (startMs == null) return;
+ move(dragged, startMs, "");
+ }
+
+ const busy = fetcher.state !== "idle";
+
+ return (
+ <>
- {hours.map((hour, index) => (
-
- ))}
-
- {timed.length === 0 && untimed.length === 0 && (
-
- {m.dispatch_empty_day()}
-
+ {closures.length > 0 && (
+
+ {closures.map((closure) => (
+
+ {m.dispatch_closed_prefix()}: {closure.title}
+
+ ))}
+
)}
- {timed.map((item) => (
-
- ))}
-
-
- );
-}
+
+
setDraggingId(null)}
+ onDropItem={dropOnLane}
+ />
-function DispatchCard({ item }: { item: DispatchItem }) {
- const geometry = cardGeometry(item);
- if (!geometry) return null;
+ {board.inspectors.length === 0 ? (
+
+
+
+ ) : (
+
+
+
+ {board.inspectors.map((inspector) => (
+ { setDraggingId(null); setHover(null); }}
+ onDragOverAxis={(minute) => setHover({ inspectorId: inspector.id, minute })}
+ onDragLeaveAxis={() => setHover(null)}
+ onDropAxis={(event) => dropOnColumn(inspector.id, event)}
+ slotIntervalMin={board.slotIntervalMin}
+ />
+ ))}
+
+
+ )}
+
+
- const body = (
- <>
-
-
- ⠿
-
- {item.title}
-
-
- {item.startTime}
- {item.endTime ? `-${item.endTime}` : ""}
- {geometry.clippedStart ? ` ${m.dispatch_card_before_axis()}` : ""}
- {geometry.clippedEnd ? ` ${m.dispatch_card_after_axis()}` : ""}
-
+ setBlocked(null)}
+ />
>
);
-
- return (
-
- {item.inspectionId ? (
-
- {body}
-
- ) : (
- body
- )}
-
- );
}
diff --git a/app/components/dispatch/DispatchColumn.tsx b/app/components/dispatch/DispatchColumn.tsx
new file mode 100644
index 000000000..473f6c03f
--- /dev/null
+++ b/app/components/dispatch/DispatchColumn.tsx
@@ -0,0 +1,230 @@
+/**
+ * The board's column primitives: the shared hour gutter, one inspector's
+ * column, and the card that sits on it.
+ *
+ * Split out of `DispatchBoard.tsx` when that file crossed the 400-line gate.
+ * The seam is deliberate rather than arbitrary — everything here is presentation
+ * driven entirely by props, while the board keeps the state, the fetcher and
+ * the drop decisions. Nothing in this file knows what a drop MEANS.
+ */
+import { Link } from "react-router";
+import { m } from "~/paraglide/messages";
+import {
+ bucketColumn,
+ cardGeometry,
+ cardTone,
+ hourLabel,
+ inspectorLabel,
+ isDraggableItem,
+ minuteFromOffsetY,
+ minuteToHm,
+ offsetYFromMinute,
+ HOUR_HEIGHT_PX,
+ type DispatchInspector,
+ type DispatchItem,
+} from "./dispatch-helpers";
+
+export function TimeGutter({ hours, axisPx }: { hours: number[]; axisPx: number }) {
+ return (
+
+ {/* Two spacers, not one: the gutter has to line up with BOTH the column
+ heading and the all-day strip, or every card sits an all-day row off. */}
+
+
+ {m.calendar_all_day()}
+
+
+ {hours.map((hour, index) => {
+ const label = hourLabel(hour);
+ return (
+ /* fg-3, not the day calendar's fg-4: fg-4 measured 3.07:1 against
+ the dark card surface, and an hour label is the one thing on this
+ axis a reader must be able to resolve. */
+
+ {label.hour12}:00 {label.meridiem}
+
+ );
+ })}
+
+
+ );
+}
+
+export function InspectorColumn({
+ inspector,
+ items,
+ hours,
+ axisPx,
+ draggingId,
+ hoverMinute,
+ onDragStartItem,
+ onDragEndItem,
+ onDragOverAxis,
+ onDragLeaveAxis,
+ onDropAxis,
+ slotIntervalMin,
+}: {
+ inspector: DispatchInspector;
+ items: DispatchItem[];
+ hours: number[];
+ axisPx: number;
+ draggingId: string | null;
+ hoverMinute: number | null;
+ onDragStartItem: (id: string) => void;
+ onDragEndItem: () => void;
+ onDragOverAxis: (minute: number) => void;
+ onDragLeaveAxis: () => void;
+ onDropAxis: (event: React.DragEvent) => void;
+ slotIntervalMin: number;
+}) {
+ const { timed, untimed } = bucketColumn(items, inspector.id);
+
+ return (
+
+
+
+ {inspectorLabel(inspector)}
+
+ {timed.length + untimed.length}
+
+
+
+ {untimed.map((item) => (
+
+ {item.title}
+
+ ))}
+
+
+
{
+ if (!draggingId) return;
+ // Without preventDefault the browser refuses the drop outright — this
+ // is what makes the element a drop target, not just a hover surface.
+ event.preventDefault();
+ const rect = event.currentTarget.getBoundingClientRect();
+ onDragOverAxis(minuteFromOffsetY(event.clientY - rect.top, slotIntervalMin));
+ }}
+ onDragLeave={onDragLeaveAxis}
+ onDrop={onDropAxis}
+ >
+ {hours.map((hour, index) => (
+
+ ))}
+
+ {hoverMinute != null && (
+
+
+ {minuteToHm(hoverMinute)}
+
+
+ )}
+
+ {timed.length === 0 && untimed.length === 0 && (
+
+ {m.dispatch_empty_day()}
+
+ )}
+
+ {timed.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function DispatchCard({
+ item,
+ dragging,
+ onDragStartItem,
+ onDragEndItem,
+}: {
+ item: DispatchItem;
+ dragging: boolean;
+ onDragStartItem: (id: string) => void;
+ onDragEndItem: () => void;
+}) {
+ const geometry = cardGeometry(item);
+ if (!geometry) return null;
+ const draggable = isDraggableItem(item);
+
+ const body = (
+ <>
+
+ {draggable && (
+
+ ⠿
+
+ )}
+ {item.title}
+
+
+ {item.startTime}
+ {item.endTime ? `-${item.endTime}` : ""}
+ {geometry.clippedStart ? ` ${m.dispatch_card_before_axis()}` : ""}
+ {geometry.clippedEnd ? ` ${m.dispatch_card_after_axis()}` : ""}
+
+ >
+ );
+
+ return (
+ {
+ event.dataTransfer.setData("text/plain", item.id);
+ event.dataTransfer.effectAllowed = "move";
+ onDragStartItem(item.id);
+ }}
+ onDragEnd={onDragEndItem}
+ className={`absolute inset-x-1 overflow-hidden rounded-lg px-2 py-1 text-[11px] font-bold ${cardTone(item.kind)}${dragging ? " opacity-40" : ""}`}
+ style={{ top: `${geometry.topPx}px`, height: `${geometry.heightPx}px` }}
+ >
+ {item.inspectionId ? (
+
+ {body}
+
+ ) : (
+ body
+ )}
+
+ );
+}
diff --git a/app/components/dispatch/UnassignedLane.tsx b/app/components/dispatch/UnassignedLane.tsx
index e63b6dcea..4d8cd96f5 100644
--- a/app/components/dispatch/UnassignedLane.tsx
+++ b/app/components/dispatch/UnassignedLane.tsx
@@ -1,6 +1,6 @@
import { Link } from "react-router";
import { m } from "~/paraglide/messages";
-import { minutesOfDay, type DispatchItem } from "./dispatch-helpers";
+import { isDraggableItem, minutesOfDay, type DispatchItem } from "./dispatch-helpers";
/**
* The left rail: inspections on this day that nobody owns.
@@ -11,12 +11,24 @@ import { minutesOfDay, type DispatchItem } from "./dispatch-helpers";
* timeless jobs last rather than first (a job with no time is the least urgent
* thing to place, not the most).
*
- * The cards are already marked up as drag sources (`data-sortable-item` +
- * the grip). Nothing is wired to a drag library in this task; the affordance
- * ships with the shape it will keep, so the drop handling lands as behavior
- * rather than as a re-layout.
+ * It is also a drop target in both directions: dragging a card OUT places it on
+ * someone's day, dragging one IN takes the person off and leaves the time
+ * alone. Unassigning by dropping is the gesture a dispatcher already has for
+ * "I need to find someone else for this".
*/
-export function UnassignedLane({ items }: { items: DispatchItem[] }) {
+export function UnassignedLane({
+ items,
+ draggingId,
+ onDragStartItem,
+ onDragEndItem,
+ onDropItem,
+}: {
+ items: DispatchItem[];
+ draggingId: string | null;
+ onDragStartItem: (id: string) => void;
+ onDragEndItem: () => void;
+ onDropItem: (event: React.DragEvent) => void;
+}) {
const sorted = [...items].sort((a, b) => {
const am = minutesOfDay(a.startTime);
const bm = minutesOfDay(b.startTime);
@@ -31,6 +43,8 @@ export function UnassignedLane({ items }: { items: DispatchItem[] }) {
className="w-56 shrink-0 border-r border-ih-border bg-ih-bg-muted"
data-testid="dispatch-unassigned-lane"
aria-label={m.dispatch_unassigned_heading()}
+ onDragOver={(event) => { if (draggingId) event.preventDefault(); }}
+ onDrop={onDropItem}
>
{/* fg-2: fg-3 measured 4.34:1 on the muted rail surface in light mode. */}
@@ -48,10 +62,16 @@ export function UnassignedLane({ items }: { items: DispatchItem[] }) {
sorted.map((item) => (
{
+ event.dataTransfer.setData("text/plain", item.id);
+ event.dataTransfer.effectAllowed = "move";
+ onDragStartItem(item.id);
+ }}
+ onDragEnd={onDragEndItem}
+ className={`rounded-lg border border-ih-border bg-ih-bg-card p-2 shadow-ih-card${draggingId === item.id ? " opacity-40" : ""}`}
>
0 ? intervalMin : 30;
+ return Math.round(minute / step) * step;
+}
+
+/**
+ * Pixel offset inside a column's axis → snapped minute-of-day, clamped so a
+ * drop near the bottom edge cannot produce a start after the axis ends.
+ */
+export function minuteFromOffsetY(offsetY: number, intervalMin: number): number {
+ const raw = AXIS_START_MIN + (offsetY / HOUR_HEIGHT_PX) * 60;
+ const snapped = snapMinute(raw, intervalMin);
+ return clamp(snapped, AXIS_START_MIN, AXIS_END_MIN);
+}
+
+/** Axis pixel for a minute-of-day — the inverse of `minuteFromOffsetY`. */
+export function offsetYFromMinute(minute: number): number {
+ return pxFromAxis(clamp(minute, AXIS_START_MIN, AXIS_END_MIN));
+}
+
+/** Minute-of-day → instant, anchored on the tenant's own midnight. */
+export function minuteToEpochMs(dayStartMs: number, minute: number): number {
+ return dayStartMs + minute * 60_000;
+}
+
+/**
+ * The instant a card currently occupies. The server layers the real
+ * `scheduledStartMs` onto every inspection it has one for; the wall-clock
+ * fallback exists for rows whose instant was never stored, so dropping such a
+ * card into the unassigned lane still has something to send.
+ */
+export function currentStartMs(item: DispatchItem, dayStartMs: number): number | null {
+ const stored = item.meta?.scheduledStartMs;
+ if (typeof stored === "number" && Number.isFinite(stored)) return stored;
+ const minute = minutesOfDay(item.startTime);
+ return minute == null ? null : minuteToEpochMs(dayStartMs, minute);
+}
+
+/** `HH:MM` for a minute-of-day — assembled, never formatted (see `lint:i18n`). */
+export function minuteToHm(minute: number): string {
+ const h = Math.floor(minute / 60) % 24;
+ return `${String(h).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
+}
+
/**
* Shift a civil date by whole days without ever touching local time. Built on
* `Date.UTC` and read back with the UTC accessors, so the arithmetic happens in
diff --git a/app/routes/calendar-dispatch.tsx b/app/routes/calendar-dispatch.tsx
index c57b8e2e3..6f00e1294 100644
--- a/app/routes/calendar-dispatch.tsx
+++ b/app/routes/calendar-dispatch.tsx
@@ -20,7 +20,12 @@ import { requireToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
import { LoadFailedNotice } from "~/components/LoadFailedNotice";
import { DispatchBoard } from "~/components/dispatch/DispatchBoard";
-import { shiftCivilDate, type DispatchPayload } from "~/components/dispatch/dispatch-helpers";
+import {
+ shiftCivilDate,
+ type DispatchPayload,
+ type RescheduleResult,
+ type ScheduleConflict,
+} from "~/components/dispatch/dispatch-helpers";
import { m } from "~/paraglide/messages";
export function meta() {
@@ -59,6 +64,59 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return { failed: false as const, board };
}
+/**
+ * One drop, one write.
+ *
+ * The board never calls the API itself — a client `fetch('/api/…')` carries no
+ * auth in this app, so the instant and the new lead travel through here and out
+ * over the token-relay client. The three outcomes are kept DISTINCT on the way
+ * back: applied cleanly, applied with advisory overlaps, and refused (409) by a
+ * tenant that blocks double-booking. Flattening the last two into "there were
+ * conflicts" is how a board ends up reporting a move the server declined.
+ */
+export async function action({ request, context }: Route.ActionArgs): Promise {
+ const token = await requireToken(context, request);
+ const api = createApi(context, { token });
+ const form = await request.formData();
+
+ if (String(form.get("intent") ?? "") !== "reschedule") {
+ return { ok: false, message: m.calendar_action_unknown() };
+ }
+
+ const inspectionId = String(form.get("inspectionId") ?? "");
+ const scheduledStartMs = Number(form.get("scheduledStartMs"));
+ if (!inspectionId || !Number.isFinite(scheduledStartMs) || scheduledStartMs <= 0) {
+ return { ok: false, message: m.dispatch_toast_failed() };
+ }
+
+ // Empty string is an explicit UNASSIGN (the lane drop). An ABSENT key means
+ // "leave assignment alone" — the schedule endpoint distinguishes the two by
+ // key presence, so the difference has to survive the form encoding.
+ const leadRaw = form.get("leadInspectorId");
+ const assignment = leadRaw === null
+ ? {}
+ : { leadInspectorId: String(leadRaw) === "" ? null : String(leadRaw) };
+
+ const res = await api.inspections[":id"].schedule.$patch({
+ param: { id: inspectionId },
+ json: { scheduledStartMs, ...assignment },
+ });
+
+ const body = (await res.json().catch(() => null)) as
+ | { data?: { conflicts?: ScheduleConflict[] } ; error?: { code?: string; message?: string; conflicts?: ScheduleConflict[] } }
+ | null;
+
+ if (res.ok) {
+ return { ok: true, conflicts: body?.data?.conflicts ?? [] };
+ }
+ return {
+ ok: false,
+ code: body?.error?.code ?? "RESCHEDULE_FAILED",
+ message: body?.error?.message ?? m.dispatch_toast_failed(),
+ conflicts: body?.error?.conflicts ?? [],
+ };
+}
+
export default function CalendarDispatchPage() {
const { failed, board } = useLoaderData();
diff --git a/messages/en/calendar.json b/messages/en/calendar.json
index 7f6f8d48e..07e87995b 100644
--- a/messages/en/calendar.json
+++ b/messages/en/calendar.json
@@ -80,5 +80,10 @@
"dispatch_column_untimed": "No time set",
"dispatch_empty_day": "Nothing scheduled",
"dispatch_card_before_axis": "(starts earlier)",
- "dispatch_card_after_axis": "(ends later)"
+ "dispatch_card_after_axis": "(ends later)",
+ "dispatch_conflict_title": "That slot is already taken",
+ "dispatch_conflict_body": "This company blocks double-booking, so nothing was moved. Pick a free time, or free the inspector first.",
+ "dispatch_conflict_close": "Close",
+ "dispatch_toast_overlap": "Moved, but it overlaps other work.",
+ "dispatch_toast_failed": "Could not move that inspection."
}
diff --git a/messages/es-419/calendar.json b/messages/es-419/calendar.json
index 3c74a8869..656d6273e 100644
--- a/messages/es-419/calendar.json
+++ b/messages/es-419/calendar.json
@@ -80,5 +80,10 @@
"dispatch_column_untimed": "Sin hora",
"dispatch_empty_day": "Nada programado",
"dispatch_card_before_axis": "(empieza antes)",
- "dispatch_card_after_axis": "(termina después)"
+ "dispatch_card_after_axis": "(termina después)",
+ "dispatch_conflict_title": "Ese horario ya está ocupado",
+ "dispatch_conflict_body": "Esta empresa bloquea la doble reserva, así que no se movió nada. Elige un horario libre o libera antes al inspector.",
+ "dispatch_conflict_close": "Cerrar",
+ "dispatch_toast_overlap": "Se movió, pero se superpone con otro trabajo.",
+ "dispatch_toast_failed": "No se pudo mover esa inspección."
}
diff --git a/server/api/calendar-items.ts b/server/api/calendar-items.ts
index d64baa948..2fab24cf2 100644
--- a/server/api/calendar-items.ts
+++ b/server/api/calendar-items.ts
@@ -5,7 +5,7 @@ import { requireRole } from '../lib/middleware/rbac';
import { requireCapability } from '../lib/middleware/require-capability';
import { createApiRouter } from '../lib/openapi-router';
import { inspections, tenantConfigs, users } from '../lib/db/schema';
-import { epochMsToWallClockHm, epochMsToWallClockYmd, resolveTenantTimeZone } from '../lib/tz';
+import { epochMsToWallClockHm, epochMsToWallClockYmd, resolveTenantTimeZone, wallClockToEpochMs } from '../lib/tz';
import { withMcpMetadata } from '../lib/route-metadata-standards';
import {
CalendarItemsErrorSchema,
@@ -147,6 +147,7 @@ const calendarItemsRoutes = createApiRouter()
const cfg = await db.select({
defaultTimezone: tenantConfigs.defaultTimezone,
bookingConflictPolicy: tenantConfigs.bookingConflictPolicy,
+ bookingSlotIntervalMin: tenantConfigs.bookingSlotIntervalMin,
})
.from(tenantConfigs)
.where(eq(tenantConfigs.tenantId, tenantId))
@@ -231,9 +232,24 @@ const calendarItemsRoutes = createApiRouter()
// would be a second place for it to be applied differently.
const unassigned = boardItems.filter((i) => i.kind === 'inspection' && !i.userId);
+ // The snap grid and the day's zero point travel WITH the board, so a
+ // drag never has to guess a timezone in the browser: a dropped pixel is
+ // `dayStartMs + minutes * 60000`, and the reschedule endpoint derives
+ // the civil date back from that instant in this same zone.
+ const slotIntervalMin = cfg?.bookingSlotIntervalMin ?? 30;
+ const dayStartMs = wallClockToEpochMs(date, '00:00', tenantTz);
+
return c.json({
success: true as const,
- data: { date, conflictPolicy, inspectors: roster, items: boardItems, unassigned },
+ data: {
+ date,
+ conflictPolicy,
+ slotIntervalMin,
+ dayStartMs,
+ inspectors: roster,
+ items: boardItems,
+ unassigned,
+ },
}, 200);
});
diff --git a/server/lib/validations/calendar-items.schema.ts b/server/lib/validations/calendar-items.schema.ts
index c1477ad72..19f010c9b 100644
--- a/server/lib/validations/calendar-items.schema.ts
+++ b/server/lib/validations/calendar-items.schema.ts
@@ -91,6 +91,10 @@ export const DispatchBoardResponseSchema = z.object({
date: CivilDateSchema.describe('The civil date actually rendered (echoes the query, or today in the tenant timezone).'),
conflictPolicy: z.enum(['advisory', 'block'])
.describe('Tenant booking_conflict_policy. `block` means the reschedule endpoint will refuse an overlapping drop with 409, so the board warns BEFORE the round trip.'),
+ slotIntervalMin: z.number().int().positive()
+ .describe('Tenant booking_slot_interval_min. The grid a vertical drag snaps to, so a dragged card lands on the same lattice the booking engine offers customers.'),
+ dayStartMs: z.number().int()
+ .describe('Epoch milliseconds of 00:00 on `date` IN THE TENANT TIMEZONE. The board converts a dropped pixel to an instant with dayStartMs + minutes*60000 rather than guessing a zone in the browser; the server still derives the civil date back from the instant it is sent.'),
inspectors: z.array(DispatchInspectorSchema).describe('One board column each, sorted by display name.'),
items: z.array(CalendarItemSchema).describe('Every calendar item on that day, for all inspectors.'),
unassigned: z.array(CalendarItemSchema)
diff --git a/tests/unit/calendar/dispatch-board.spec.ts b/tests/unit/calendar/dispatch-board.spec.ts
index 7301efce7..7fd750705 100644
--- a/tests/unit/calendar/dispatch-board.spec.ts
+++ b/tests/unit/calendar/dispatch-board.spec.ts
@@ -36,6 +36,8 @@ const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
interface BoardPayload {
date: string;
conflictPolicy: string;
+ slotIntervalMin: number;
+ dayStartMs: number;
inspectors: Array<{ id: string; name: string | null }>;
items: Array<{ id: string; kind: string; allDay: boolean; startTime?: string; userId?: string; meta?: Record }>;
unassigned: Array<{ id: string }>;
@@ -137,6 +139,26 @@ describe('GET /api/calendar/dispatch', () => {
expect(body.data.conflictPolicy).toBe('advisory');
});
+ it('ships the snap lattice and the tenant-local midnight a drag needs', async () => {
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ // Not decoration: the board turns a dropped pixel into an instant with
+ // dayStartMs + minutes*60000, so a wrong or missing anchor silently
+ // reschedules to the wrong day, and a wrong interval produces starts the
+ // booking engine would never have offered.
+ expect(body.data.slotIntervalMin).toBe(30);
+ expect(body.data.dayStartMs).toBe(Date.UTC(2026, 5, 1, 0, 0, 0));
+ });
+
+ it('echoes a non-default booking_slot_interval_min rather than assuming 30', async () => {
+ await db.update(schema.tenantConfigs)
+ .set({ bookingSlotIntervalMin: 45 })
+ .where(eq(schema.tenantConfigs.tenantId, TENANT));
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.slotIntervalMin).toBe(45);
+ });
+
it('echoes the tenant booking_conflict_policy', async () => {
await db.update(schema.tenantConfigs)
.set({ bookingConflictPolicy: 'block' })
From 027fb3738a978675cd9b08d0264b399babb260a1 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 10:41:26 +0800
Subject: [PATCH 094/111] feat(dispatch): find-a-time modal on new inspection
The wizard's date picker asks "when do you want it"; this asks "when could it
actually happen", and answers with the part the public booking surface
deliberately withholds - WHICH inspector is free.
New authenticated route GET /api/schedule/day-slots, composed onto the existing
/api/schedule router rather than mounted second at the same path: two
.route('/api/schedule', ...) calls work at runtime but the RPC TYPE keeps only
the first, so the BFF client silently loses every route on the second one.
Gated on requireCapability('scheduleOthers') like the rest of this plan, and a
caller-supplied userIds list NARROWS the qualified set instead of replacing it -
an id from a query string must not become bookable by being named.
Duration is resolved client-side rather than pushed into getTenantSlots: the
service reports slot STARTS, and whether a two-hour job fits at 10:30 is a
question about consecutive starts that the response already answers. Changing
that signature would have reached into the public booking path to compute
something the caller can derive. startsFittingDuration requires every slot the
duration spans to exist, follow on at exactly the tenant interval, and be free -
the contiguity check is what stops a lunch-break gap reading as an opening.
Slots arrive through a resource-route loader (BFF), and a failed lookup is
reported as a failure, not as an empty day.
New route, so the OpenAPI snapshot was regenerated (npm run mcp:snapshot).
---
.../dispatch/FindATimeModal.test.tsx | 118 +++++++++++++
app/components/dispatch/FindATimeModal.tsx | 162 ++++++++++++++++++
app/components/dispatch/dispatch-helpers.ts | 42 +++++
app/components/new-inspection/ConfirmStep.tsx | 24 ++-
.../new-inspection/FindATimeLauncher.tsx | 51 ++++++
app/routes.ts | 3 +
app/routes/resources/day-slots.ts | 70 ++++++++
messages/en/calendar.json | 14 +-
messages/es-419/calendar.json | 14 +-
server/api/schedule-day-slots.ts | 93 ++++++++++
server/api/schedule-week-summary.ts | 8 +-
server/lib/mcp/openapi-snapshot.json | 39 +++++
.../validations/schedule-day-slots.schema.ts | 50 ++++++
tests/unit/calendar/day-slots.spec.ts | 142 +++++++++++++++
14 files changed, 824 insertions(+), 6 deletions(-)
create mode 100644 app/components/dispatch/FindATimeModal.test.tsx
create mode 100644 app/components/dispatch/FindATimeModal.tsx
create mode 100644 app/components/new-inspection/FindATimeLauncher.tsx
create mode 100644 app/routes/resources/day-slots.ts
create mode 100644 server/api/schedule-day-slots.ts
create mode 100644 server/lib/validations/schedule-day-slots.schema.ts
create mode 100644 tests/unit/calendar/day-slots.spec.ts
diff --git a/app/components/dispatch/FindATimeModal.test.tsx b/app/components/dispatch/FindATimeModal.test.tsx
new file mode 100644
index 000000000..016c8863a
--- /dev/null
+++ b/app/components/dispatch/FindATimeModal.test.tsx
@@ -0,0 +1,118 @@
+// @vitest-environment happy-dom
+/**
+ * Find-a-Time makes a promise: "this start is free for the whole job".
+ *
+ * The failure mode is silent and expensive — offering 09:00 for a three-hour
+ * inspection whose 10:00 is already taken sends someone to a house they will
+ * have to leave halfway through. So the assertions here are about what is NOT
+ * offered, and about the difference between "nothing is free" and "we could not
+ * find out", which look identical if a failed load is rendered as an empty day.
+ */
+import { describe, it, expect } from "vitest";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import { createRoutesStub } from "react-router";
+
+import { FindATimeModal } from "./FindATimeModal";
+import { startsFittingDuration, type DaySlot } from "./dispatch-helpers";
+
+const MEMBERS = [
+ { id: "u-ada", name: "Ada" },
+ { id: "u-bo", name: "Bo" },
+];
+
+function slot(time: string, available: boolean, inspectorIds: string[] = []): DaySlot {
+ return { time, available, inspectorIds };
+}
+
+function renderModal(payload: unknown) {
+ const Stub = createRoutesStub([
+ {
+ path: "/",
+ Component: () => (
+ {}}
+ initialDate="2027-03-15"
+ members={MEMBERS}
+ onPick={() => {}}
+ />
+ ),
+ },
+ { path: "/resources/day-slots", loader: () => payload },
+ ]);
+ return render( );
+}
+
+const FULL_DAY = {
+ failed: false,
+ date: "2027-03-15",
+ intervalMin: 30,
+ slots: [
+ slot("09:00", true, ["u-ada"]),
+ slot("09:30", true, ["u-ada", "u-bo"]),
+ slot("10:00", false),
+ slot("10:30", true, ["u-bo"]),
+ slot("11:00", true, ["u-bo"]),
+ ],
+ holidayAdvisory: null,
+};
+
+describe("FindATimeModal", () => {
+ it("offers only starts where the whole duration fits", async () => {
+ renderModal(FULL_DAY);
+ await waitFor(() => expect(screen.getAllByTestId("find-a-time-slot").length).toBeGreaterThan(0));
+ const offered = screen.getAllByTestId("find-a-time-slot").map((b) => b.textContent);
+ // Default duration is 60 minutes = two consecutive free slots.
+ // 09:00+09:30 fits; 09:30 does not (10:00 is taken); 10:30+11:00 fits.
+ expect(offered.some((t) => t?.startsWith("09:00"))).toBe(true);
+ expect(offered.some((t) => t?.startsWith("09:30"))).toBe(false);
+ expect(offered.some((t) => t?.startsWith("10:30"))).toBe(true);
+ });
+
+ it("names the inspector when exactly one is free at that start", async () => {
+ renderModal(FULL_DAY);
+ await waitFor(() => expect(screen.getAllByTestId("find-a-time-slot").length).toBeGreaterThan(0));
+ // Scoped to the results: "Ada" is also an option in the inspector filter.
+ const results = screen.getByTestId("find-a-time-results");
+ expect(within(results).getAllByText("Ada").length).toBeGreaterThan(0);
+ });
+
+ it("says a lookup FAILED rather than showing an empty day", async () => {
+ renderModal({ failed: true, date: "2027-03-15", intervalMin: 30, slots: [], holidayAdvisory: null });
+ await waitFor(() =>
+ expect(screen.getByText("Availability could not be checked. Try again.")).toBeTruthy(),
+ );
+ expect(screen.queryAllByTestId("find-a-time-slot")).toHaveLength(0);
+ });
+
+ it("says nothing fits when the day really is full", async () => {
+ renderModal({ failed: false, date: "2027-03-15", intervalMin: 30, slots: [slot("09:00", false)], holidayAdvisory: null });
+ await waitFor(() =>
+ expect(screen.getByText("No window that long is free on this day.")).toBeTruthy(),
+ );
+ });
+});
+
+describe("startsFittingDuration", () => {
+ const slots = FULL_DAY.slots;
+
+ it("needs every consecutive slot the duration spans", () => {
+ expect([...startsFittingDuration(slots, 30, 30)]).toEqual(["09:00", "09:30", "10:30", "11:00"]);
+ expect([...startsFittingDuration(slots, 30, 60)]).toEqual(["09:00", "10:30"]);
+ expect([...startsFittingDuration(slots, 30, 90)]).toEqual([]);
+ });
+
+ it("refuses to step over a GAP in the grid", () => {
+ // 09:00 and 12:00 are both free, but the hours between them are not slots
+ // at all — a closed window. Index arithmetic alone would call this a
+ // three-hour opening.
+ const split = [slot("09:00", true, ["u-ada"]), slot("12:00", true, ["u-ada"])];
+ expect([...startsFittingDuration(split, 30, 60)]).toEqual([]);
+ expect([...startsFittingDuration(split, 30, 30)]).toEqual(["09:00", "12:00"]);
+ });
+
+ it("rounds a duration that is not a whole number of slots UP", () => {
+ // 45 minutes on a 30-minute grid occupies two slots, not one.
+ expect([...startsFittingDuration(slots, 30, 45)]).toEqual(["09:00", "10:30"]);
+ });
+});
diff --git a/app/components/dispatch/FindATimeModal.tsx b/app/components/dispatch/FindATimeModal.tsx
new file mode 100644
index 000000000..bf05b62ed
--- /dev/null
+++ b/app/components/dispatch/FindATimeModal.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useMemo, useState } from "react";
+import { useFetcher } from "react-router";
+import { Button, Modal, Select } from "@core/shared-ui";
+import { m } from "~/paraglide/messages";
+import type { DaySlotsPayload } from "~/routes/resources/day-slots";
+import { startsFittingDuration } from "./dispatch-helpers";
+
+export interface FindATimeMember {
+ id: string;
+ name: string;
+ email?: string;
+}
+
+const DURATION_CHOICES = [60, 90, 120, 180, 240];
+
+/**
+ * "When could this actually happen?" — the question the wizard's date picker
+ * cannot answer.
+ *
+ * Slots arrive through a route loader, never a browser `fetch('/api/…')`: the
+ * JWT lives in an HttpOnly cookie the React Router server relays, so a direct
+ * client call would be unauthenticated. And it is the STAFF slots endpoint, not
+ * the public booking one — the public surface deliberately withholds which
+ * inspector is free, which is the only part a dispatcher needs.
+ *
+ * A start is offered only when the whole DURATION fits from it. Showing a free
+ * 09:00 for a three-hour job whose 10:00 is taken would be a promise the
+ * calendar cannot keep.
+ */
+export function FindATimeModal({
+ open,
+ onClose,
+ initialDate,
+ members,
+ onPick,
+}: {
+ open: boolean;
+ onClose: () => void;
+ initialDate: string;
+ members: FindATimeMember[];
+ onPick: (pick: { date: string; time: string; inspectorId: string | null }) => void;
+}) {
+ const fetcher = useFetcher();
+ const [date, setDate] = useState(initialDate);
+ const [durationMin, setDurationMin] = useState(DURATION_CHOICES[0]);
+ const [inspectorId, setInspectorId] = useState("");
+
+ useEffect(() => { if (open) setDate(initialDate); }, [open, initialDate]);
+
+ useEffect(() => {
+ if (!open || !date) return;
+ const params = new URLSearchParams({ date });
+ if (inspectorId) params.set("userIds", inspectorId);
+ fetcher.load(`/resources/day-slots?${params.toString()}`);
+ // The fetcher identity changes every render; depending on it would reload
+ // in a loop. The inputs below are the whole query.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, date, inspectorId]);
+
+ const data = fetcher.data;
+ const slots = useMemo(() => data?.slots ?? [], [data]);
+ const fitting = useMemo(
+ () => startsFittingDuration(slots, data?.intervalMin ?? 30, durationMin),
+ [slots, data?.intervalMin, durationMin],
+ );
+
+ const loading = fetcher.state !== "idle";
+ const memberName = (id: string) =>
+ members.find((member) => member.id === id)?.name ?? id;
+
+ return (
+ {m.find_a_time_close()}}
+ >
+
+
+ {m.find_a_time_date()}
+ setDate(event.target.value)}
+ className="h-9 rounded-lg border border-ih-border bg-ih-bg-input px-2 text-[13px] font-normal text-ih-fg-1"
+ />
+
+
+
+ {m.find_a_time_duration()}
+ setDurationMin(Number(event.target.value))}
+ options={DURATION_CHOICES.map((minutes) => ({
+ value: String(minutes),
+ label: m.find_a_time_minutes({ count: minutes }),
+ }))}
+ />
+
+
+
+ {m.find_a_time_inspector()}
+ setInspectorId(event.target.value)}
+ options={[
+ { value: "", label: m.find_a_time_anyone() },
+ ...members.map((member) => ({ value: member.id, label: member.name })),
+ ]}
+ />
+
+
+
+
+ {data?.holidayAdvisory && (
+
+ {m.dispatch_closed_prefix()}: {data.holidayAdvisory.name}
+
+ )}
+
+ {loading &&
{m.find_a_time_loading()}
}
+
+ {/* "Nothing is free" and "we could not find out" are different answers,
+ and only one of them means keep looking on this day. */}
+ {!loading && data?.failed && (
+
{m.find_a_time_failed()}
+ )}
+
+ {!loading && data && !data.failed && fitting.size === 0 && (
+
{m.find_a_time_none()}
+ )}
+
+ {!loading && fitting.size > 0 && (
+
+ {slots.filter((slot) => fitting.has(slot.time)).map((slot) => (
+ {
+ onPick({
+ date,
+ time: slot.time,
+ // Only commit an inspector when the answer is unambiguous:
+ // an explicit filter, or exactly one person free then.
+ inspectorId: inspectorId || (slot.inspectorIds.length === 1 ? slot.inspectorIds[0] : null),
+ });
+ onClose();
+ }}
+ className="rounded-lg border border-ih-border bg-ih-bg-card px-3 py-2 text-left hover:bg-ih-bg-muted"
+ >
+ {slot.time}
+
+ {slot.inspectorIds.length === 1
+ ? memberName(slot.inspectorIds[0])
+ : m.find_a_time_free_count({ count: slot.inspectorIds.length })}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/app/components/dispatch/dispatch-helpers.ts b/app/components/dispatch/dispatch-helpers.ts
index fd7d8af3c..34a371946 100644
--- a/app/components/dispatch/dispatch-helpers.ts
+++ b/app/components/dispatch/dispatch-helpers.ts
@@ -252,6 +252,48 @@ export function minuteToHm(minute: number): string {
return `${String(h).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
}
+export interface DaySlot {
+ time: string;
+ available: boolean;
+ inspectorIds: string[];
+}
+
+/**
+ * Which slot STARTS can actually hold a job of `durationMin`.
+ *
+ * The slots endpoint reports starts, not windows — a 09:00 slot being free says
+ * nothing about 09:30, and offering "09:00" for a three-hour job whose 10:00
+ * slot is taken is worse than offering nothing: it is a promise the calendar
+ * cannot keep. So a start qualifies only when every consecutive slot it needs
+ * exists, follows on at exactly `intervalMin`, and is free. The contiguity
+ * check is not paranoia: a gap in the grid is a closed window (lunch, a
+ * split shift), and index arithmetic alone would step straight over it.
+ */
+export function startsFittingDuration(
+ slots: DaySlot[],
+ intervalMin: number,
+ durationMin: number,
+): Set {
+ const step = intervalMin > 0 ? intervalMin : 30;
+ const needed = Math.max(1, Math.ceil((durationMin > 0 ? durationMin : step) / step));
+ const fits = new Set();
+
+ for (let i = 0; i < slots.length; i++) {
+ let ok = true;
+ for (let n = 0; n < needed; n++) {
+ const slot = slots[i + n];
+ const previous = n === 0 ? null : slots[i + n - 1];
+ if (!slot || !slot.available) { ok = false; break; }
+ if (previous) {
+ const gap = (minutesOfDay(slot.time) ?? 0) - (minutesOfDay(previous.time) ?? 0);
+ if (gap !== step) { ok = false; break; }
+ }
+ }
+ if (ok) fits.add(slots[i].time);
+ }
+ return fits;
+}
+
/**
* Shift a civil date by whole days without ever touching local time. Built on
* `Date.UTC` and read back with the UTC accessors, so the arithmetic happens in
diff --git a/app/components/new-inspection/ConfirmStep.tsx b/app/components/new-inspection/ConfirmStep.tsx
index 48688ad86..1b2a6accc 100644
--- a/app/components/new-inspection/ConfirmStep.tsx
+++ b/app/components/new-inspection/ConfirmStep.tsx
@@ -3,6 +3,7 @@ import type { WizardTeamMember } from "../NewInspectionWizard";
import { ScheduleStep } from "./ScheduleStep";
import { TeamStep } from "./TeamStep";
import { m } from "~/paraglide/messages";
+import { FindATimeLauncher } from "./FindATimeLauncher";
type ConflictFetcher = ReturnType<
typeof useFetcher<{
@@ -59,9 +60,26 @@ export function ConfirmStep({
return (
-
- {m.new_inspection_step_schedule()}
-
+
+
+ {m.new_inspection_step_schedule()}
+
+ {/* The picker below asks "when do you want it"; this asks
+ "when could it actually happen". It lives here because a
+ chosen slot writes THREE of this step's fields at once. */}
+
{
+ setDate(pick.date);
+ setTime(pick.time);
+ if (pick.inspectorId) {
+ setInspectorId(pick.inspectorId);
+ setSoloMode(false);
+ }
+ }}
+ />
+
void;
+}) {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+ setOpen(true)}
+ data-testid="find-a-time-open"
+ >
+ {m.find_a_time_open()}
+
+
+ setOpen(false)}
+ initialDate={date}
+ members={teamMembers}
+ onPick={onPick}
+ />
+ >
+ );
+}
diff --git a/app/routes.ts b/app/routes.ts
index dbf2b8273..18b7dafde 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -112,6 +112,9 @@ export default [
route("resources/schedule-conflicts", "routes/resources/schedule-conflicts.ts"),
route("resources/holiday-check", "routes/resources/holiday-check.ts"),
route("resources/week-summary", "routes/resources/week-summary.ts"),
+ // Find-a-Time: one day of slots WITH the free inspectors named (the public
+ // booking surface withholds identities by design).
+ route("resources/day-slots", "routes/resources/day-slots.ts"),
// #198 — Google Places autocomplete/details BFF (token-relay proxy).
route("resources/places", "routes/resources/places.tsx"),
// Inspections workspace — the primary list/stats/wizard surface (formerly
diff --git a/app/routes/resources/day-slots.ts b/app/routes/resources/day-slots.ts
new file mode 100644
index 000000000..b3e53a1c6
--- /dev/null
+++ b/app/routes/resources/day-slots.ts
@@ -0,0 +1,70 @@
+/**
+ * BFF resource route for Find-a-Time.
+ *
+ * Loaded with `useFetcher` from the new-inspection wizard. It exists because a
+ * browser `fetch('/api/…')` in this app carries no auth — the JWT lives in an
+ * HttpOnly cookie the React Router server holds and relays. So slot data has to
+ * come through a loader, and this is the smallest one that does it.
+ *
+ * A failure returns an EMPTY slot list with `failed: true` rather than an empty
+ * list alone: "nobody is free" and "we could not find out" are different
+ * answers, and Find-a-Time is a surface where confusing them sends a dispatcher
+ * to call an inspector who was actually available.
+ */
+import { requireToken } from "~/lib/session.server";
+import { createApi } from "~/lib/api-client.server";
+import type { LoadContext } from "~/lib/load-context";
+
+export interface DaySlot {
+ time: string;
+ available: boolean;
+ inspectorIds: string[];
+}
+
+export interface DaySlotsPayload {
+ failed: boolean;
+ date: string;
+ intervalMin: number;
+ slots: DaySlot[];
+ holidayAdvisory: { date: string; name: string } | null;
+}
+
+export async function loader({
+ request,
+ context,
+}: {
+ request: Request;
+ context: LoadContext;
+}): Promise {
+ const token = await requireToken(context, request);
+ const api = createApi(context, { token });
+ const url = new URL(request.url);
+ const date = url.searchParams.get("date") ?? "";
+ const userIds = url.searchParams.get("userIds") ?? "";
+
+ const empty: DaySlotsPayload = {
+ failed: true,
+ date,
+ intervalMin: 30,
+ slots: [],
+ holidayAdvisory: null,
+ };
+ if (!date) return { ...empty, failed: false };
+
+ const res = await api.schedule["day-slots"]
+ .$get({ query: { date, ...(userIds ? { userIds } : {}) } })
+ .catch(() => null);
+ if (!res?.ok) return empty;
+
+ const body = (await res.json()) as { data?: Omit };
+ const data = body.data;
+ if (!data) return empty;
+
+ return {
+ failed: false,
+ date: data.date,
+ intervalMin: data.intervalMin,
+ slots: data.slots,
+ holidayAdvisory: data.holidayAdvisory ?? null,
+ };
+}
diff --git a/messages/en/calendar.json b/messages/en/calendar.json
index 07e87995b..490db020a 100644
--- a/messages/en/calendar.json
+++ b/messages/en/calendar.json
@@ -85,5 +85,17 @@
"dispatch_conflict_body": "This company blocks double-booking, so nothing was moved. Pick a free time, or free the inspector first.",
"dispatch_conflict_close": "Close",
"dispatch_toast_overlap": "Moved, but it overlaps other work.",
- "dispatch_toast_failed": "Could not move that inspection."
+ "dispatch_toast_failed": "Could not move that inspection.",
+ "find_a_time_open": "Find a time",
+ "find_a_time_title": "Find a time",
+ "find_a_time_close": "Close",
+ "find_a_time_date": "Date",
+ "find_a_time_duration": "Duration",
+ "find_a_time_inspector": "Inspector",
+ "find_a_time_anyone": "Anyone available",
+ "find_a_time_minutes": "{count} min",
+ "find_a_time_loading": "Checking availability...",
+ "find_a_time_none": "No window that long is free on this day.",
+ "find_a_time_failed": "Availability could not be checked. Try again.",
+ "find_a_time_free_count": "{count} free"
}
diff --git a/messages/es-419/calendar.json b/messages/es-419/calendar.json
index 656d6273e..cbe731ada 100644
--- a/messages/es-419/calendar.json
+++ b/messages/es-419/calendar.json
@@ -85,5 +85,17 @@
"dispatch_conflict_body": "Esta empresa bloquea la doble reserva, así que no se movió nada. Elige un horario libre o libera antes al inspector.",
"dispatch_conflict_close": "Cerrar",
"dispatch_toast_overlap": "Se movió, pero se superpone con otro trabajo.",
- "dispatch_toast_failed": "No se pudo mover esa inspección."
+ "dispatch_toast_failed": "No se pudo mover esa inspección.",
+ "find_a_time_open": "Buscar un horario",
+ "find_a_time_title": "Buscar un horario",
+ "find_a_time_close": "Cerrar",
+ "find_a_time_date": "Fecha",
+ "find_a_time_duration": "Duración",
+ "find_a_time_inspector": "Inspector",
+ "find_a_time_anyone": "Cualquiera disponible",
+ "find_a_time_minutes": "{count} min",
+ "find_a_time_loading": "Comprobando disponibilidad...",
+ "find_a_time_none": "No hay ninguna ventana libre tan larga ese día.",
+ "find_a_time_failed": "No se pudo comprobar la disponibilidad. Inténtalo de nuevo.",
+ "find_a_time_free_count": "{count} libres"
}
diff --git a/server/api/schedule-day-slots.ts b/server/api/schedule-day-slots.ts
new file mode 100644
index 000000000..273659306
--- /dev/null
+++ b/server/api/schedule-day-slots.ts
@@ -0,0 +1,93 @@
+/**
+ * GET /api/schedule/day-slots — one day of slots, with the free inspectors named.
+ *
+ * A sibling of `schedule-week-summary.ts` rather than an extension of it: that
+ * route answers "how does the WEEK look" in four statuses and deliberately
+ * throws the slot detail away; this one answers "who could take a job at 10:30
+ * on Tuesday". Same service call underneath (`getTenantSlots`), opposite
+ * resolution.
+ *
+ * Gated on `requireCapability('scheduleOthers')` to match the rest of the
+ * dispatch surface. Reading who else is free is the same privilege as putting
+ * work on them, and unlike a role tier the capability is toggleable in both
+ * directions.
+ *
+ * DURATION is not a parameter here on purpose. `getTenantSlots` reports slot
+ * STARTS; whether a two-hour job fits at 10:30 is a question about consecutive
+ * starts, answerable from this response alone. Pushing it into the service
+ * would change a signature the public booking path also depends on, to compute
+ * something the caller can already derive.
+ */
+import { createRoute } from '@hono/zod-openapi';
+import { eq } from 'drizzle-orm';
+import { requireRole } from '../lib/middleware/rbac';
+import { requireCapability } from '../lib/middleware/require-capability';
+import { createApiRouter } from '../lib/openapi-router';
+import { tenantConfigs } from '../lib/db/schema';
+import { withMcpMetadata } from '../lib/route-metadata-standards';
+import { getDrizzle } from '../lib/route-helpers';
+import {
+ DaySlotsErrorSchema,
+ DaySlotsQuerySchema,
+ DaySlotsResponseSchema,
+} from '../lib/validations/schedule-day-slots.schema';
+import { BookingService } from '../services/booking.service';
+
+const daySlotsRoute = createRoute(withMcpMetadata({
+ method: 'get',
+ path: '/day-slots',
+ operationId: 'getScheduleDaySlots',
+ tags: ['calendar'],
+ summary: 'Free slots for one day, naming the free inspectors',
+ description: 'Returns every slot start on a civil date with the inspectors free at each, plus the tenant slot interval. Staff-only counterpart to the identity-hiding public booking slots endpoint.',
+ middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('scheduleOthers')] as const,
+ request: { query: DaySlotsQuerySchema },
+ responses: {
+ 200: {
+ content: { 'application/json': { schema: DaySlotsResponseSchema } },
+ description: 'Slot starts for the requested day',
+ },
+ 403: {
+ content: { 'application/json': { schema: DaySlotsErrorSchema } },
+ description: 'The caller lacks the scheduleOthers capability',
+ },
+ },
+ security: [{ bearerAuth: [] }],
+}, { scopes: ['read'], tier: 'extended', capability: 'scheduleOthers' }));
+
+const scheduleDaySlotsRoutes = createApiRouter()
+ .openapi(daySlotsRoute, async (c) => {
+ const tenantId = c.get('tenantId');
+ const { date, userIds } = c.req.valid('query');
+ const db = getDrizzle(c);
+
+ const cfg = await db.select({
+ bookingSlotIntervalMin: tenantConfigs.bookingSlotIntervalMin,
+ })
+ .from(tenantConfigs)
+ .where(eq(tenantConfigs.tenantId, tenantId))
+ .get();
+
+ const service = new BookingService(c.env.DB);
+ const allQualified = await service.getQualifiedInspectorIds(tenantId, []);
+ // Narrowing INTERSECTS with the qualified set rather than replacing it:
+ // an id the caller invented, or one belonging to somebody who cannot
+ // take this work, must not become a free inspector by being asked for.
+ const qualified = userIds
+ ? allQualified.filter((id) => userIds.includes(id))
+ : allQualified;
+
+ const { slots, holidayAdvisory } = await service.getTenantSlots(tenantId, date, [], qualified);
+
+ return c.json({
+ success: true as const,
+ data: {
+ date,
+ intervalMin: cfg?.bookingSlotIntervalMin ?? 30,
+ slots,
+ holidayAdvisory: holidayAdvisory ?? null,
+ },
+ }, 200);
+ });
+
+export default scheduleDaySlotsRoutes;
diff --git a/server/api/schedule-week-summary.ts b/server/api/schedule-week-summary.ts
index 209d8d9c6..ad4231f8b 100644
--- a/server/api/schedule-week-summary.ts
+++ b/server/api/schedule-week-summary.ts
@@ -17,6 +17,7 @@ import {
import { BookingService } from '../services/booking.service';
import { isAdminRole } from '../lib/auth/roles';
import { getDrizzle } from '../lib/route-helpers';
+import scheduleDaySlotsRoutes from './schedule-day-slots';
const WEEK_LENGTH = 7;
const DAY_MS = 86_400_000;
@@ -162,7 +163,12 @@ const scheduleWeekSummaryRoutes = createApiRouter()
}));
return c.json({ success: true as const, data: { days } }, 200);
- });
+ })
+ // Both /api/schedule routes are composed here rather than mounted twice in
+ // server/index.ts: two `.route('/api/schedule', …)` calls work at runtime
+ // but the RPC TYPE keeps only the first, so the BFF client silently loses
+ // every route on the second router. One mount, one type.
+ .route('/', scheduleDaySlotsRoutes);
export type ScheduleApi = typeof scheduleWeekSummaryRoutes;
diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json
index ad62e9a7f..bc3b487d1 100644
--- a/server/lib/mcp/openapi-snapshot.json
+++ b/server/lib/mcp/openapi-snapshot.json
@@ -8421,6 +8421,45 @@
"summary": "Detect same-day-hour assignment conflicts for an inspector",
"description": "IA-6 — advisory same-day-hour collision check counting lead and helper assignments. Callers render a warning; scheduling is never blocked."
},
+ {
+ "operationId": "getScheduleDaySlots",
+ "method": "GET",
+ "pathTemplate": "/api/schedule/day-slots",
+ "scopes": [
+ "read"
+ ],
+ "tag": "calendar",
+ "tier": "extended",
+ "inputSchema": {
+ "parameters": [
+ {
+ "name": "date",
+ "in": "query",
+ "required": true,
+ "description": "Civil date to check, YYYY-MM-DD.",
+ "schema": {
+ "type": "string",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
+ "description": "Civil date to check, YYYY-MM-DD."
+ }
+ },
+ {
+ "name": "userIds",
+ "in": "query",
+ "required": false,
+ "description": "Comma-separated inspector ids to narrow the search to. Omit to consider everyone qualified.",
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Comma-separated inspector ids to narrow the search to. Omit to consider everyone qualified."
+ }
+ }
+ ],
+ "body": null
+ },
+ "summary": "Free slots for one day, naming the free inspectors",
+ "description": "Returns every slot start on a civil date with the inspectors free at each, plus the tenant slot interval. Staff-only counterpart to the identity-hiding public booking slots endpoint."
+ },
{
"operationId": "getScheduleWeekSummary",
"method": "GET",
diff --git a/server/lib/validations/schedule-day-slots.schema.ts b/server/lib/validations/schedule-day-slots.schema.ts
new file mode 100644
index 000000000..f9be93951
--- /dev/null
+++ b/server/lib/validations/schedule-day-slots.schema.ts
@@ -0,0 +1,50 @@
+import { z } from '@hono/zod-openapi';
+
+/**
+ * GET /api/schedule/day-slots — staff Find-a-Time.
+ *
+ * The public booking surface (`GET /api/public/slots`) answers a different
+ * question and answers it deliberately vaguely: it reports `{ time, available }`
+ * and never says WHO is free, because free-inspector identities are not the
+ * public's business. A dispatcher needs exactly the part that surface withholds,
+ * so this is a separate authenticated route rather than a flag on that one — the
+ * two have opposite disclosure rules and merging them is how the strict one
+ * eventually leaks.
+ */
+export const DaySlotsQuerySchema = z.object({
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format')
+ .describe('Civil date to check, YYYY-MM-DD.'),
+ userIds: z.string().trim().min(1).transform((value) =>
+ [...new Set(value.split(',').map((id) => id.trim()).filter(Boolean))],
+ ).pipe(z.array(z.string().min(1)).min(1)).optional()
+ .describe('Comma-separated inspector ids to narrow the search to. Omit to consider everyone qualified.'),
+});
+
+const DaySlotSchema = z.object({
+ time: z.string().regex(/^\d{2}:\d{2}$/).describe('Slot start, wall clock HH:MM in the tenant timezone.'),
+ available: z.boolean().describe('Whether at least one of the considered inspectors is free at this start.'),
+ inspectorIds: z.array(z.string())
+ .describe('The inspectors free at this start. Empty when the slot is taken — this is the field the public surface withholds by design.'),
+});
+
+export const DaySlotsResponseSchema = z.object({
+ success: z.literal(true),
+ data: z.object({
+ date: z.string().describe('The civil date the slots belong to.'),
+ intervalMin: z.number().int().positive()
+ .describe('Tenant booking_slot_interval_min — the spacing between consecutive starts, so the caller can tell how many consecutive slots a duration needs without assuming 30.'),
+ slots: z.array(DaySlotSchema).describe('Every slot start on the day, in chronological order.'),
+ holidayAdvisory: z.object({
+ date: z.string(),
+ name: z.string(),
+ }).nullable().describe('Set when the day is a company holiday that only ADVISES; a blocking holiday returns no slots at all.'),
+ }),
+});
+
+export const DaySlotsErrorSchema = z.object({
+ success: z.literal(false),
+ error: z.object({
+ message: z.string(),
+ code: z.string(),
+ }),
+});
diff --git a/tests/unit/calendar/day-slots.spec.ts b/tests/unit/calendar/day-slots.spec.ts
new file mode 100644
index 000000000..6e5b241e2
--- /dev/null
+++ b/tests/unit/calendar/day-slots.spec.ts
@@ -0,0 +1,142 @@
+/**
+ * GET /api/schedule/day-slots — staff Find-a-Time.
+ *
+ * HTTP-level, because the two things worth pinning here are both middleware or
+ * boundary behaviour: the `scheduleOthers` gate (a handler-level test would
+ * answer 200 for everybody), and the fact that a caller-supplied inspector list
+ * NARROWS the qualified set rather than replacing it. The second one is the
+ * quiet security-shaped bug: if an arbitrary id could be echoed back as "free",
+ * the wizard would happily assign work to somebody who cannot take it.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { HonoConfig } from '../../../server/types/hono';
+import type { UserRole } from '../../../server/types/auth';
+import { AppError } from '../../../server/lib/errors';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const getTenantSlots = vi.fn();
+const getQualifiedInspectorIds = vi.fn();
+vi.mock('../../../server/services/booking.service', () => ({
+ BookingService: class {
+ getTenantSlots = getTenantSlots;
+ getQualifiedInspectorIds = getQualifiedInspectorIds;
+ },
+}));
+
+// eslint-disable-next-line import/order
+import scheduleRoutes from '../../../server/api/schedule-week-summary';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const ACTOR = '00000000-0000-0000-0000-000000000099';
+const ADA = '00000000-0000-0000-0000-0000000000a1';
+const BO = '00000000-0000-0000-0000-0000000000b2';
+const DAY = '2026-06-01';
+
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+function buildApp(
+ db: BetterSQLite3Database,
+ role: UserRole,
+ overrides: Record | null = null,
+) {
+ (mockDrizzle as ReturnType).mockReturnValue(db);
+ const app = new OpenAPIHono();
+
+ app.onError((err, c) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT);
+ c.set('userRole', role);
+ c.set('user', { sub: ACTOR, role, tenantId: TENANT });
+ c.set('sdb', {
+ getById: async () => ({ permissionOverrides: overrides }),
+ } as unknown as HonoConfig['Variables']['sdb']);
+ await next();
+ });
+
+ app.route('/api/schedule', scheduleRoutes);
+ return app;
+}
+
+function get(app: OpenAPIHono, query = `date=${DAY}`) {
+ return app.request(`/api/schedule/day-slots?${query}`, {}, FAKE_ENV);
+}
+
+interface SlotsPayload {
+ date: string;
+ intervalMin: number;
+ slots: Array<{ time: string; available: boolean; inspectorIds: string[] }>;
+ holidayAdvisory: { date: string; name: string } | null;
+}
+
+describe('GET /api/schedule/day-slots', () => {
+ let db: BetterSQLite3Database;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ getTenantSlots.mockReset();
+ getQualifiedInspectorIds.mockReset();
+ getQualifiedInspectorIds.mockResolvedValue([ADA, BO]);
+ getTenantSlots.mockResolvedValue({
+ slots: [{ time: '09:00', available: true, inspectorIds: [ADA] }],
+ });
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.tenantConfigs).values({
+ tenantId: TENANT,
+ bookingSlotIntervalMin: 45,
+ updatedAt: new Date(),
+ });
+ });
+
+ it('inspector without the scheduleOthers override → 403', async () => {
+ const res = await get(buildApp(db, 'inspector'));
+ expect(res.status).toBe(403);
+ });
+
+ it('inspector WITH the scheduleOthers override → 200', async () => {
+ const res = await get(buildApp(db, 'inspector', { scheduleOthers: true }));
+ expect(res.status).toBe(200);
+ });
+
+ it('names the free inspectors — the part the public slots surface withholds', async () => {
+ const res = await get(buildApp(db, 'owner'));
+ expect(res.status).toBe(200);
+ const body = await res.json() as { data: SlotsPayload };
+ expect(body.data.slots[0].inspectorIds).toEqual([ADA]);
+ expect(body.data.intervalMin).toBe(45);
+ });
+
+ it('NARROWS the qualified set with userIds instead of replacing it', async () => {
+ const outsider = '00000000-0000-0000-0000-0000000000ff';
+ await get(buildApp(db, 'owner'), `date=${DAY}&userIds=${ADA},${outsider}`);
+ // ADA survives because she was qualified; the outsider does not become
+ // bookable by being named in a query string.
+ expect(getTenantSlots).toHaveBeenCalledWith(TENANT, DAY, [], [ADA]);
+ });
+
+ it('considers everyone qualified when no userIds are given', async () => {
+ await get(buildApp(db, 'owner'));
+ expect(getTenantSlots).toHaveBeenCalledWith(TENANT, DAY, [], [ADA, BO]);
+ });
+
+ it('rejects a malformed date rather than guessing one', async () => {
+ const res = await get(buildApp(db, 'owner'), 'date=next-tuesday');
+ expect(res.status).toBe(400);
+ });
+});
From acc56d0644081dcc9aa09a7d5c448a768998c8c3 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 11:06:36 +0800
Subject: [PATCH 095/111] feat(dispatch): navigation and calendar cross-link
Dispatch reaches the sidebar, gated on the scheduleOthers CAPABILITY rather
than on a role tier - an inspector granted the override may dispatch and a
manager whose override was revoked may not, and the shipped server guards key
on exactly that.
Getting there needed three pieces, because "the session profile already carries
it" was not true: GET /api/session/context now resolves the viewer's
capabilities with the same getCapabilities the API guards use and ships the
ANSWER (not the raw overrides, which would make every consumer re-implement an
authorization rule); SessionContext gained the field plus useCapability /
useCapabilities; and BOTH nav surfaces filter through one visibleNavItems -
filtering only the desktop sidebar would leave the mobile drawer offering a
link that redirects.
Filtering fails CLOSED: no context means no entry. The server refuses either
way; this only decides whether to offer the door.
The calendar's "Dispatch view" button is gated on the same resolved capability.
The existing canManageTeam = isAdminRole uses are left alone - reconciling
/api/calendar/items with the capability is a separate, already-recorded gap.
Mobile: the board stays desktop-first and scrolls its columns sideways rather
than stacking, because a stacked board is a list and a list cannot show two
people's 10:00 at once. The page says so where the gesture is needed.
---
app/components/Sidebar.tsx | 7 +-
.../calendar/CalendarScopeToolbar.test.tsx | 128 ++++++++++++------
.../calendar/CalendarScopeToolbar.tsx | 14 ++
app/components/dispatch/DispatchBoard.tsx | 8 ++
app/components/sidebar.test.ts | 36 +++++
app/components/sidebar/MobileDrawer.tsx | 7 +-
app/components/sidebar/nav-items.tsx | 23 ++++
app/hooks/useSessionContext.ts | 25 ++++
messages/en/calendar.json | 4 +-
messages/en/nav.json | 3 +-
messages/es-419/calendar.json | 4 +-
messages/es-419/nav.json | 3 +-
server/api/session-context.ts | 18 +++
.../session-context-capabilities.spec.ts | 116 ++++++++++++++++
14 files changed, 342 insertions(+), 54 deletions(-)
create mode 100644 tests/unit/platform/session-context-capabilities.spec.ts
diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx
index aff33bcd1..a951e23f6 100644
--- a/app/components/Sidebar.tsx
+++ b/app/components/Sidebar.tsx
@@ -1,8 +1,8 @@
import { useState, useRef } from "react";
import { NavLink, useRouteLoaderData } from "react-router";
-import { useSessionContext, useUnreadMessages } from "~/hooks/useSessionContext";
+import { useCapabilities, useSessionContext, useUnreadMessages } from "~/hooks/useSessionContext";
import { writeSidebarCookie, type UiPrefs } from "~/lib/ui-prefs";
-import { IC, WORKSPACE_ITEMS } from "~/components/sidebar/nav-items";
+import { IC, WORKSPACE_ITEMS, visibleNavItems } from "~/components/sidebar/nav-items";
import { SidebarGroup } from "~/components/sidebar/SidebarGroup";
import { UserMenuPopover } from "~/components/sidebar/UserMenuPopover";
import { MobileHeader } from "~/components/sidebar/MobileHeader";
@@ -21,6 +21,7 @@ export function Sidebar() {
// flash from the old two-pass localStorage read).
const rootPrefs = useRouteLoaderData("root") as UiPrefs | undefined;
const [collapsed, setCollapsed] = useState(rootPrefs?.sidebarCollapsed ?? false);
+ const capabilities = useCapabilities();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef(null);
const ctx = useSessionContext();
@@ -98,7 +99,7 @@ export function Sidebar() {
{/* Nav */}
- (i.to === "/messages" ? { ...i, badge: unreadMessages } : i))} collapsed={collapsed} />
+ (i.to === "/messages" ? { ...i, badge: unreadMessages } : i))} collapsed={collapsed} />
{/* ds-allow: compact sidebar nav rhythm (10/7/14px), no semantic spacing token */}
| null = null,
+) {
+ const Stub = createRoutesStub([
+ {
+ path: "/",
+ id: "routes/auth-layout",
+ loader: () => (capabilities ? { context: { user: { capabilities } } } : { context: null }),
+ Component: () => ui,
+ },
+ ]);
+ return render( );
+}
+
+const BASE = {
+ members: [],
+ selectedUserIds: [],
+ onScopeChange: vi.fn(),
+ onToggleMember: vi.fn(),
+ locale: "en-US",
+};
+
describe("CalendarScopeToolbar", () => {
- it("defaults Team for owner", () => {
+ it("defaults Team for owner", async () => {
const scope = defaultCalendarScope("owner");
- const { getByRole } = render(
- ,
- );
+ renderToolbar( );
expect(scope).toBe("team");
- expect(getByRole("button", { name: "Team" }).getAttribute("aria-pressed")).toBe("true");
- expect(getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("false");
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Team" }).getAttribute("aria-pressed")).toBe("true"),
+ );
+ expect(screen.getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("false");
});
- it("defaults My for inspector and hides Team", () => {
+ it("defaults My for inspector and hides Team", async () => {
const scope = defaultCalendarScope("inspector");
- const { getByRole, queryByRole } = render(
- ,
- );
+ renderToolbar( );
expect(scope).toBe("my");
- expect(getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("true");
- expect(queryByRole("button", { name: "Team" })).toBeNull();
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("true"),
+ );
+ expect(screen.queryByRole("button", { name: "Team" })).toBeNull();
});
- it("shows inspector chips in Team mode for managers", () => {
- const { getByRole } = render(
+ it("shows inspector chips in Team mode for managers", async () => {
+ renderToolbar(
{
{ id: "u2", name: "Sam", email: "sam@example.com", role: "inspector" },
]}
selectedUserIds={["u1"]}
- onScopeChange={vi.fn()}
- onToggleMember={vi.fn()}
- locale="en-US"
/>,
);
- expect(getByRole("button", { name: "Alex" }).getAttribute("aria-pressed")).toBe("true");
- expect(getByRole("button", { name: "Sam" }).getAttribute("aria-pressed")).toBe("false");
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Alex" }).getAttribute("aria-pressed")).toBe("true"),
+ );
+ expect(screen.getByRole("button", { name: "Sam" }).getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("offers Dispatch only to a viewer who holds scheduleOthers", async () => {
+ // A manager whose override was revoked: the role tier says yes, the
+ // capability says no, and /calendar/dispatch would redirect them back.
+ renderToolbar(
+ ,
+ { scheduleOthers: false },
+ );
+ await waitFor(() => expect(screen.getByRole("button", { name: "Team" })).toBeTruthy());
+ expect(screen.queryByTestId("calendar-open-dispatch")).toBeNull();
+
+ renderToolbar(
+ ,
+ { scheduleOthers: true },
+ );
+ expect(await screen.findByTestId("calendar-open-dispatch")).toBeTruthy();
+ });
+
+ it("keeps the cross-link out of My mode", async () => {
+ renderToolbar(
+ ,
+ { scheduleOthers: true },
+ );
+ await waitFor(() => expect(screen.getByRole("button", { name: "My" })).toBeTruthy());
+ expect(screen.queryByTestId("calendar-open-dispatch")).toBeNull();
});
- it("shows sync freshness beside each Team chip", () => {
+ it("shows sync freshness beside each Team chip", async () => {
const now = Date.UTC(2026, 7, 3, 12, 0, 0);
- const { container } = render(
+ const { container } = renderToolbar(
{
},
]}
selectedUserIds={["u1"]}
- onScopeChange={vi.fn()}
- onToggleMember={vi.fn()}
- locale="en-US"
now={now}
/>,
);
- const states = [...container.querySelectorAll("[data-sync-state]")]
- .map((el) => el.getAttribute("data-sync-state"));
- expect(states).toEqual(["connected", "stale", "not-connected"]);
+ await waitFor(() => {
+ const states = [...container.querySelectorAll("[data-sync-state]")]
+ .map((el) => el.getAttribute("data-sync-state"));
+ expect(states).toEqual(["connected", "stale", "not-connected"]);
+ });
});
});
diff --git a/app/components/calendar/CalendarScopeToolbar.tsx b/app/components/calendar/CalendarScopeToolbar.tsx
index a05e43bdd..78d86920d 100644
--- a/app/components/calendar/CalendarScopeToolbar.tsx
+++ b/app/components/calendar/CalendarScopeToolbar.tsx
@@ -1,4 +1,7 @@
+import { Link } from "react-router";
+import { Button } from "@core/shared-ui";
import { isAdminRole } from "~/lib/access";
+import { useCapability } from "~/hooks/useSessionContext";
import type { CalendarScope } from "~/components/calendar/calendar-helpers";
import type { CalendarMember } from "~/components/calendar/BlockTimeDrawer";
import { InspectorSyncBadge } from "~/components/calendar/InspectorSyncBadge";
@@ -31,9 +34,20 @@ export function CalendarScopeToolbar({
now,
}: CalendarScopeToolbarProps) {
const canManageTeam = isAdminRole(role);
+ // The cross-link is gated on the CAPABILITY, not on canManageTeam:
+ // /calendar/dispatch is guarded by `scheduleOthers`, so a role-tier button
+ // would offer a manager whose override was revoked a page that redirects
+ // straight back here. The existing canManageTeam uses stay as they are —
+ // reconciling /api/calendar/items with the capability is a separate gap.
+ const canDispatch = useCapability("scheduleOthers");
return (
+ {canDispatch && scope === "team" && (
+
+
{m.calendar_open_dispatch()}
+
+ )}
) : (
+ {/* Dispatch is a desktop-first surface. On a narrow screen the
+ columns scroll sideways rather than reflow — a stacked board
+ is a list, and a list cannot show two people's 10:00 at once,
+ which is the entire reason to open this page. Say so, once,
+ where the gesture is needed. */}
+
+ {m.dispatch_scroll_hint()}
+
{board.inspectors.map((inspector) => (
diff --git a/app/components/sidebar.test.ts b/app/components/sidebar.test.ts
index 5034cc929..20fc2379a 100644
--- a/app/components/sidebar.test.ts
+++ b/app/components/sidebar.test.ts
@@ -38,6 +38,42 @@ describe('Sidebar', () => {
expect(text).not.toContain('"/repair-items"');
});
+ it('hides Dispatch without scheduleOthers and shows it with the override', async () => {
+ const { WORKSPACE_ITEMS, visibleNavItems } = await import('~/components/sidebar/nav-items');
+ const dispatchItem = WORKSPACE_ITEMS.find((i) => i.to === '/calendar/dispatch');
+ expect(dispatchItem?.capability).toBe('scheduleOthers');
+
+ // An inspector's ROLE default is scheduleOthers: false — and an inspector
+ // granted the override is exactly the user this feature was gated for, so
+ // the entry must key on the resolved capability rather than the tier.
+ const hidden = visibleNavItems(WORKSPACE_ITEMS, { scheduleOthers: false });
+ expect(hidden.some((i) => i.to === '/calendar/dispatch')).toBe(false);
+
+ const shown = visibleNavItems(WORKSPACE_ITEMS, { scheduleOthers: true });
+ expect(shown.some((i) => i.to === '/calendar/dispatch')).toBe(true);
+
+ // Ungated entries are never filtered out by this.
+ expect(hidden.some((i) => i.to === '/inspections')).toBe(true);
+ }, 20000);
+
+ it('fails CLOSED when the session context is missing', async () => {
+ const { WORKSPACE_ITEMS, visibleNavItems } = await import('~/components/sidebar/nav-items');
+ for (const capabilities of [null, undefined, {}]) {
+ const items = visibleNavItems(WORKSPACE_ITEMS, capabilities);
+ expect(items.some((i) => i.to === '/calendar/dispatch')).toBe(false);
+ }
+ }, 20000);
+
+ it('filters in BOTH nav surfaces, not just the desktop one', async () => {
+ // A capability filter applied to one surface only is invisible in review
+ // and obvious to the inspector who taps a link that redirects them.
+ for (const mod of ['~/components/Sidebar?raw', '~/components/sidebar/MobileDrawer?raw']) {
+ const src = await import(/* @vite-ignore */ mod);
+ const text = (src as unknown as { default: string }).default;
+ expect(text).toContain('visibleNavItems(WORKSPACE_ITEMS');
+ }
+ }, 20000);
+
it('IA-25: User Menu trigger button is present in Sidebar source', async () => {
const src = await import('~/components/Sidebar?raw');
const text = (src as unknown as { default: string }).default;
diff --git a/app/components/sidebar/MobileDrawer.tsx b/app/components/sidebar/MobileDrawer.tsx
index 237085c81..dff13d7a4 100644
--- a/app/components/sidebar/MobileDrawer.tsx
+++ b/app/components/sidebar/MobileDrawer.tsx
@@ -1,6 +1,6 @@
import { NavLink } from "react-router";
-import { useSessionContext } from "~/hooks/useSessionContext";
-import { IC, WORKSPACE_ITEMS } from "~/components/sidebar/nav-items";
+import { useCapabilities, useSessionContext } from "~/hooks/useSessionContext";
+import { IC, WORKSPACE_ITEMS, visibleNavItems } from "~/components/sidebar/nav-items";
import { ThemeSegmentControl } from "~/components/sidebar/ThemeSegmentControl";
import { LocaleSwitcher } from "~/components/LocaleSwitcher";
import { Avatar, Icon } from "@core/shared-ui";
@@ -9,6 +9,7 @@ import { m } from "~/paraglide/messages";
// ─── Mobile drawer ─────────────────────────────────────────────────────────────
export function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const ctx = useSessionContext();
+ const capabilities = useCapabilities();
const companyName = ctx?.branding?.companyName || "OpenInspection";
const logoUrl = ctx?.branding?.logoUrl || "/logo.svg";
@@ -38,7 +39,7 @@ export function MobileDrawer({ open, onClose }: { open: boolean; onClose: () =>
{/* ds-allow: compact mobile drawer nav rhythm (10/2px), no semantic spacing token */}
{m.nav_section_workspace()}
- {WORKSPACE_ITEMS.map((item) => (
+ {visibleNavItems(WORKSPACE_ITEMS, capabilities).map((item) => (
`flex items-center gap-3 px-3 py-2 rounded-ih-button text-[13px] font-medium transition-all ${isActive ? "bg-ih-primary-tint text-ih-primary font-bold" : "text-ih-fg-2 hover:bg-ih-bg-muted hover:text-ih-primary"}`}>
{item.icon}
{item.label()}
diff --git a/app/components/sidebar/nav-items.tsx b/app/components/sidebar/nav-items.tsx
index 85a846d43..80e6454a1 100644
--- a/app/components/sidebar/nav-items.tsx
+++ b/app/components/sidebar/nav-items.tsx
@@ -1,4 +1,5 @@
import { m } from "~/paraglide/messages";
+import type { Capability, CapabilitySet } from "../../../server/lib/auth/capabilities";
export interface NavItem {
to: string;
@@ -9,6 +10,12 @@ export interface NavItem {
icon: React.ReactNode;
/** Count pill after the label (unread indicators). Render-time injected. */
badge?: number;
+ /**
+ * Capability required to SEE this entry. A role tier is not good enough: an
+ * inspector granted `scheduleOthers` may dispatch and a manager whose
+ * override was revoked may not, and the server guard keys on the capability.
+ */
+ capability?: Capability;
}
export const IC = "w-4 h-4 shrink-0";
@@ -16,9 +23,25 @@ export const IC = "w-4 h-4 shrink-0";
export const WORKSPACE_ITEMS: NavItem[] = [
{ to: "/inspections", label: () => m.nav_item_inspections(), icon: },
{ to: "/calendar", label: () => m.nav_item_calendar(), icon: },
+ { to: "/calendar/dispatch", label: () => m.nav_item_dispatch(), capability: "scheduleOthers", icon: },
{ to: "/messages", label: () => m.nav_item_messages(), icon: },
{ to: "/contacts", label: () => m.nav_item_contacts(), icon: },
{ to: "/invoices", label: () => m.nav_item_invoices(), icon: },
{ to: "/metrics", label: () => m.nav_item_metrics(), icon: },
{ to: "/team", label: () => m.nav_item_team(), icon: },
];
+
+/**
+ * The entries this viewer may actually open.
+ *
+ * Both nav surfaces call this — filtering only the desktop sidebar would leave
+ * the mobile drawer offering Dispatch to an inspector who gets redirected the
+ * moment they tap it. Missing capabilities means missing context, which is
+ * treated as "no": the door is only offered when the answer is a definite yes.
+ */
+export function visibleNavItems(
+ items: NavItem[],
+ capabilities: Partial | null | undefined,
+): NavItem[] {
+ return items.filter((item) => !item.capability || capabilities?.[item.capability] === true);
+}
diff --git a/app/hooks/useSessionContext.ts b/app/hooks/useSessionContext.ts
index 987e4f0b4..3a6765234 100644
--- a/app/hooks/useSessionContext.ts
+++ b/app/hooks/useSessionContext.ts
@@ -1,4 +1,5 @@
import { useRouteLoaderData } from "react-router";
+import type { Capability, CapabilitySet } from "../../server/lib/auth/capabilities";
import {
resolveDisplayPrefs,
type DateFormat,
@@ -50,6 +51,13 @@ export interface SessionContext {
dateFormat: DateFormat | null;
/** Per-user clock override, or null to inherit the tenant (#270). */
timeFormat: TimeFormat | null;
+ /**
+ * The viewer's RESOLVED capabilities — role defaults with their personal
+ * overrides already applied, computed by the same `getCapabilities` the API
+ * guards use. Resolved server-side on purpose: the chrome must never work
+ * out a second answer to a question the server already decided.
+ */
+ capabilities: CapabilitySet;
};
deployment: {
mode: string;
@@ -70,6 +78,23 @@ export function useUnreadMessages(): number {
return (ctx as (SessionContext & { unreadMessages?: number }) | null)?.unreadMessages ?? 0;
}
+/**
+ * One capability answer for the current viewer.
+ *
+ * FAIL-CLOSED when there is no context (outside the auth layout, or the fetch
+ * failed): a chrome entry that appears on a failed load is an entry that
+ * navigates to a 403. The server is the enforcer either way; this only decides
+ * whether to offer the door.
+ */
+export function useCapability(capability: Capability): boolean {
+ return useSessionContext()?.user.capabilities?.[capability] === true;
+}
+
+/** Every resolved capability, for callers filtering a list. */
+export function useCapabilities(): Partial | null {
+ return useSessionContext()?.user.capabilities ?? null;
+}
+
export function useSessionContext(): SessionContext | null {
const data = useRouteLoaderData("routes/auth-layout") as
| { context: SessionContext | null }
diff --git a/messages/en/calendar.json b/messages/en/calendar.json
index 490db020a..7be16fafe 100644
--- a/messages/en/calendar.json
+++ b/messages/en/calendar.json
@@ -97,5 +97,7 @@
"find_a_time_loading": "Checking availability...",
"find_a_time_none": "No window that long is free on this day.",
"find_a_time_failed": "Availability could not be checked. Try again.",
- "find_a_time_free_count": "{count} free"
+ "find_a_time_free_count": "{count} free",
+ "calendar_open_dispatch": "Dispatch view",
+ "dispatch_scroll_hint": "Scroll sideways to see every inspector."
}
diff --git a/messages/en/nav.json b/messages/en/nav.json
index dabab438c..752127239 100644
--- a/messages/en/nav.json
+++ b/messages/en/nav.json
@@ -31,5 +31,6 @@
"nav_theme_field_title": "High-contrast large type for outdoor use",
"nav_theme_aria": "Color theme",
"nav_language_label": "Language",
- "nav_language_aria": "Interface language"
+ "nav_language_aria": "Interface language",
+ "nav_item_dispatch": "Dispatch"
}
diff --git a/messages/es-419/calendar.json b/messages/es-419/calendar.json
index cbe731ada..b9b7e1023 100644
--- a/messages/es-419/calendar.json
+++ b/messages/es-419/calendar.json
@@ -97,5 +97,7 @@
"find_a_time_loading": "Comprobando disponibilidad...",
"find_a_time_none": "No hay ninguna ventana libre tan larga ese día.",
"find_a_time_failed": "No se pudo comprobar la disponibilidad. Inténtalo de nuevo.",
- "find_a_time_free_count": "{count} libres"
+ "find_a_time_free_count": "{count} libres",
+ "calendar_open_dispatch": "Vista de despacho",
+ "dispatch_scroll_hint": "Desplázate de lado para ver a todos los inspectores."
}
diff --git a/messages/es-419/nav.json b/messages/es-419/nav.json
index 769464d66..3ceb48408 100644
--- a/messages/es-419/nav.json
+++ b/messages/es-419/nav.json
@@ -31,5 +31,6 @@
"nav_theme_field_title": "Tipografía grande de alto contraste para uso en exteriores",
"nav_theme_aria": "Tema de color",
"nav_language_label": "Idioma",
- "nav_language_aria": "Idioma de la interfaz"
+ "nav_language_aria": "Idioma de la interfaz",
+ "nav_item_dispatch": "Despacho"
}
diff --git a/server/api/session-context.ts b/server/api/session-context.ts
index d1385c84c..b31690b65 100644
--- a/server/api/session-context.ts
+++ b/server/api/session-context.ts
@@ -14,6 +14,8 @@ import {
import { Errors } from '../lib/errors';
import { logger } from '../lib/logger';
import { mcpEnabled } from '../lib/mcp/flag';
+import { coerceOverrides, getCapabilities, type CapabilitySet } from '../lib/auth/capabilities';
+import { isRole } from '../lib/auth/roles';
import { getDrizzle } from '../lib/route-helpers';
import { getBaseUrl } from '../lib/url';
import { resolveTenantLegalUrls, type LegalMode } from '../lib/legal-links';
@@ -55,6 +57,11 @@ const sessionContextRoutes = createApiRouter()
let userTimeFormat: TimeFormat | null = null;
let tenantDateFormat: DateFormat = DEFAULT_DISPLAY_PREFS.dateFormat;
let tenantTimeFormat: TimeFormat = DEFAULT_DISPLAY_PREFS.timeFormat;
+ // RESOLVED capabilities, not the raw overrides: whether an actor may do
+ // something is decided where it is ENFORCED, and shipping the answer
+ // rather than the ingredients means the chrome cannot resolve it a
+ // second, subtly different way (see Cross-Portal Reuse in CLAUDE.md).
+ let permissionOverridesRaw: unknown = null;
let tenantTimezone = 'UTC';
let tenantLocale = 'en-US';
let tenantCurrency = 'USD';
@@ -74,6 +81,7 @@ const sessionContextRoutes = createApiRouter()
locale: users.locale,
dateFormat: users.dateFormat,
timeFormat: users.timeFormat,
+ permissionOverrides: users.permissionOverrides,
})
.from(users)
.where(and(eq(users.id, user.sub), eq(users.tenantId, tenantId)))
@@ -81,6 +89,7 @@ const sessionContextRoutes = createApiRouter()
if (row) {
userName = row.name;
userEmail = row.email;
+ permissionOverridesRaw = row.permissionOverrides;
userTimezone = row.timezone;
userLocale = row.locale;
userDateFormat = isDateFormat(row.dateFormat) ? row.dateFormat : null;
@@ -227,6 +236,14 @@ const sessionContextRoutes = createApiRouter()
unreadMessages = await c.var.services.message.unreadCountForTenant(tenantId);
} catch { /* badge degrades to 0; the layout must never fail on it */ }
+ // An unknown role resolves as an inspector rather than throwing: the
+ // chrome must still render, and inspector is the least-privileged tier.
+ const roleForCaps = isRole(user.role) ? user.role : 'inspector';
+ const capabilities: CapabilitySet = getCapabilities(
+ roleForCaps,
+ coerceOverrides(permissionOverridesRaw),
+ );
+
return c.json({
success: true,
data: {
@@ -254,6 +271,7 @@ const sessionContextRoutes = createApiRouter()
name: userName,
email: userEmail,
role: user.role || 'inspector',
+ capabilities,
initials,
timezone: userTimezone,
locale: userLocale,
diff --git a/tests/unit/platform/session-context-capabilities.spec.ts b/tests/unit/platform/session-context-capabilities.spec.ts
new file mode 100644
index 000000000..dbaf57bbb
--- /dev/null
+++ b/tests/unit/platform/session-context-capabilities.spec.ts
@@ -0,0 +1,116 @@
+/**
+ * session-context capabilities — the chrome's copy of the server's answer.
+ *
+ * The sidebar decides whether to offer Dispatch from this payload. If it
+ * shipped the raw `permission_overrides` instead of the resolved set, every
+ * consumer would have to re-apply the role defaults and the owner pinning, and
+ * a second implementation of an authorization rule is a second place for it to
+ * be wrong. So what travels is the answer `getCapabilities` gave.
+ *
+ * The two cases worth pinning are the ones a role check gets backwards: an
+ * INSPECTOR granted `scheduleOthers` (may dispatch) and an owner, who cannot be
+ * reduced by an override at all.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import type { HonoConfig } from '../../../server/types/hono';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+// eslint-disable-next-line import/order
+import sessionContextRoutes from '../../../server/api/session-context';
+
+const TENANT_ID = '00000000-0000-0000-0000-0000000000aa';
+const USER_ID = 'u-caps';
+
+let testDb: BetterSQLite3Database;
+
+beforeEach(async () => {
+ const fixture = createTestDb();
+ testDb = fixture.db;
+ await setupSchema(fixture.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb);
+ await testDb.insert(schema.tenants).values({
+ id: TENANT_ID,
+ name: 'Caps Co',
+ slug: 'caps-co',
+ tier: 'free',
+ status: 'active',
+ deploymentMode: 'shared',
+ createdAt: new Date(),
+ });
+});
+
+async function seedUser(role: 'owner' | 'manager' | 'inspector', overrides: unknown) {
+ await testDb.insert(schema.users).values({
+ id: USER_ID,
+ tenantId: TENANT_ID,
+ email: 'u@caps.com',
+ name: 'Caps User',
+ passwordHash: 'h',
+ role,
+ permissionOverrides: overrides as never,
+ createdAt: new Date(),
+ });
+}
+
+function buildApp(role: 'owner' | 'manager' | 'inspector') {
+ const app = new OpenAPIHono();
+ app.use('*', async (c, next) => {
+ c.set('user', { sub: USER_ID, role } as never);
+ c.set('tenantId', TENANT_ID);
+ c.set('branding', {
+ companyName: 'Test',
+ primaryColor: '#000',
+ logoUrl: null,
+ defaultProfileId: 'signature',
+ isSaas: false,
+ portalBaseUrl: null,
+ tenantSlug: 'caps-co',
+ tenantStatus: 'active',
+ currentUserSlug: null,
+ bookingHost: null,
+ } as never);
+ c.set('profile', { mode: 'standalone', hasBilling: false, hasSeatQuota: false } as never);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (c as any).env = { APP_MODE: 'standalone', DB: {} as D1Database };
+ await next();
+ });
+ app.route('/api/session', sessionContextRoutes);
+ return app;
+}
+
+type Body = { data: { user: { capabilities: Record } } };
+
+async function capabilities(role: 'owner' | 'manager' | 'inspector') {
+ const res = await buildApp(role).request('/api/session/context');
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Body;
+ return body.data.user.capabilities;
+}
+
+describe('session-context capabilities', () => {
+ it('an inspector has no scheduleOthers by default', async () => {
+ await seedUser('inspector', null);
+ expect((await capabilities('inspector')).scheduleOthers).toBe(false);
+ });
+
+ it('an inspector WITH the override has it — the user this gate exists for', async () => {
+ await seedUser('inspector', { scheduleOthers: true });
+ expect((await capabilities('inspector')).scheduleOthers).toBe(true);
+ });
+
+ it('a manager whose override was revoked loses it', async () => {
+ await seedUser('manager', { scheduleOthers: false });
+ expect((await capabilities('manager')).scheduleOthers).toBe(false);
+ });
+
+ it('an owner cannot be reduced by an override', async () => {
+ await seedUser('owner', { scheduleOthers: false });
+ expect((await capabilities('owner')).scheduleOthers).toBe(true);
+ });
+});
From 6a9ea64721b0299757b4ede9c37a2a62aa19602f Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 11:17:31 +0800
Subject: [PATCH 096/111] fix(dispatch): read the dropped card from
dataTransfer, not from React state
Found in Chrome, not in the unit tests: `dragstart` set `draggingId` state and
the drop handler read it out of a closure, so a drop that landed before React
re-rendered saw `null` and silently did nothing. The unit tests missed it
because testing-library wraps every fireEvent in act(), which flushes state
between the two events - a real browser only usually gives you those frames.
The id now travels through `dataTransfer`, which is what it is for and which
needs no render in between; state stays the fallback and still drives the hover
indicator, where a frame of lag is invisible. The lane's dragover
preventDefault stopped being conditional on the same state for the same reason.
The regression test fires a drop with NO preceding dragstart - that is the race
made deterministic - and the test helper now carries a real dataTransfer rather
than a spy, so the drop reads back what dragstart wrote.
Verified end to end against local D1: an unassigned card dropped on an
inspector's 11:00 lands in that column at 11:00-13:00 (duration preserved) and
leaves the lane.
---
.../dispatch/DispatchBoard.test.tsx | 31 ++++++++++++++++++-
app/components/dispatch/DispatchBoard.tsx | 18 ++++++++++-
app/components/dispatch/UnassignedLane.tsx | 5 ++-
3 files changed, 51 insertions(+), 3 deletions(-)
diff --git a/app/components/dispatch/DispatchBoard.test.tsx b/app/components/dispatch/DispatchBoard.test.tsx
index f2b6a7629..bcb85d4d7 100644
--- a/app/components/dispatch/DispatchBoard.test.tsx
+++ b/app/components/dispatch/DispatchBoard.test.tsx
@@ -88,7 +88,14 @@ function renderBoard(
*/
function dragCardTo(cardText: string, dropzone: Element, clientY: number) {
const card = screen.getByText(cardText).closest("[data-item-id]") as HTMLElement;
- const dataTransfer = { setData: vi.fn(), effectAllowed: "" };
+ // A real dataTransfer, not a spy: the drop handler READS back what dragstart
+ // wrote, which is the whole point of carrying the id through the gesture.
+ const store: Record = {};
+ const dataTransfer = {
+ setData: (key: string, value: string) => { store[key] = value; },
+ getData: (key: string) => store[key] ?? "",
+ effectAllowed: "",
+ };
fireEvent.dragStart(card, { dataTransfer });
for (const type of ["dragover", "drop"]) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientY });
@@ -208,6 +215,28 @@ describe("DispatchBoard drag-drop", () => {
expect(screen.getByText("That slot is already taken")).toBeTruthy();
});
+ it("reads the dropped card from dataTransfer, not from a rendered state flush", async () => {
+ // Found in Chrome: `dragstart` sets React state, and a drop that lands
+ // before the re-render saw `null` and silently did nothing. Firing the drop
+ // with NO preceding dragstart is that race, made deterministic.
+ const posted: Record[] = [];
+ renderBoard(BOARD, async ({ request }) => {
+ const form = await request.formData();
+ posted.push(Object.fromEntries(form) as Record);
+ return { ok: true, conflicts: [] };
+ });
+
+ const ada = screen.getAllByTestId("dispatch-column")[0];
+ const zone = ada.querySelector("[data-dispatch-dropzone]")!;
+ const event = new MouseEvent("drop", { bubbles: true, cancelable: true, clientY: 112 });
+ Object.defineProperty(event, "dataTransfer", { value: { getData: () => "i-3", setData: vi.fn() } });
+ fireEvent(zone, event);
+
+ await waitFor(() => expect(posted).toHaveLength(1));
+ expect(posted[0].inspectionId).toBe("insp-3");
+ expect(posted[0].leadInspectorId).toBe("u-ada");
+ });
+
it("does not offer a company closure as a drag source", () => {
renderBoard();
const closure = screen.getByText(/Founders Day/);
diff --git a/app/components/dispatch/DispatchBoard.tsx b/app/components/dispatch/DispatchBoard.tsx
index 176b289d1..24fe60893 100644
--- a/app/components/dispatch/DispatchBoard.tsx
+++ b/app/components/dispatch/DispatchBoard.tsx
@@ -80,7 +80,21 @@ export function DispatchBoard({ board }: { board: DispatchPayload }) {
});
}, [fetcher.data, fetcher.state]);
- const dragged = draggingId ? byId.get(draggingId) ?? null : null;
+ /**
+ * Which card is being dropped, read from the DRAG ITSELF.
+ *
+ * `draggingId` state is set in `dragstart`, and a handler closure only sees
+ * it after React has re-rendered. A real browser leaves many frames between
+ * the two events so that usually happens — but "usually" is the whole bug:
+ * a drop that lands before the re-render read `null` and silently did
+ * nothing. `dataTransfer` carries the id through the gesture with no render
+ * in between, which is what it is for. State stays the fallback (and drives
+ * the hover indicator, where a frame of lag is invisible).
+ */
+ function draggedFrom(event: React.DragEvent): DispatchItem | null {
+ const id = event.dataTransfer?.getData("text/plain") || draggingId;
+ return id ? byId.get(id) ?? null : null;
+ }
function move(item: DispatchItem, startMs: number, leadInspectorId: string) {
if (!item.inspectionId) return;
@@ -97,6 +111,7 @@ export function DispatchBoard({ board }: { board: DispatchPayload }) {
function dropOnColumn(inspectorId: string, event: React.DragEvent) {
event.preventDefault();
+ const dragged = draggedFrom(event);
setHover(null);
setDraggingId(null);
if (!dragged || !isDraggableItem(dragged)) return;
@@ -113,6 +128,7 @@ export function DispatchBoard({ board }: { board: DispatchPayload }) {
// they look for someone to work it, so the instant is carried over unchanged.
function dropOnLane(event: React.DragEvent) {
event.preventDefault();
+ const dragged = draggedFrom(event);
setHover(null);
setDraggingId(null);
if (!dragged || !isDraggableItem(dragged)) return;
diff --git a/app/components/dispatch/UnassignedLane.tsx b/app/components/dispatch/UnassignedLane.tsx
index 4d8cd96f5..ea76451c2 100644
--- a/app/components/dispatch/UnassignedLane.tsx
+++ b/app/components/dispatch/UnassignedLane.tsx
@@ -43,7 +43,10 @@ export function UnassignedLane({
className="w-56 shrink-0 border-r border-ih-border bg-ih-bg-muted"
data-testid="dispatch-unassigned-lane"
aria-label={m.dispatch_unassigned_heading()}
- onDragOver={(event) => { if (draggingId) event.preventDefault(); }}
+ // Unconditional preventDefault: gating this on drag STATE loses the drop
+ // whenever the gesture outruns a React render. The lane is only a drop
+ // target during a drag anyway.
+ onDragOver={(event) => event.preventDefault()}
onDrop={onDropItem}
>
From ea6d1c241d45decbe4374246957cc9b01ae65822 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 11:48:43 +0800
Subject: [PATCH 097/111] feat(idempotency): canonical request fingerprinting
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
server/lib/idempotency/fingerprint.ts | 27 +++++++++++++++++
tests/unit/idempotency/fingerprint.spec.ts | 35 ++++++++++++++++++++++
2 files changed, 62 insertions(+)
create mode 100644 server/lib/idempotency/fingerprint.ts
create mode 100644 tests/unit/idempotency/fingerprint.spec.ts
diff --git a/server/lib/idempotency/fingerprint.ts b/server/lib/idempotency/fingerprint.ts
new file mode 100644
index 000000000..c0b994945
--- /dev/null
+++ b/server/lib/idempotency/fingerprint.ts
@@ -0,0 +1,27 @@
+/**
+ * Request fingerprinting for idempotency.
+ *
+ * A key replayed with a DIFFERENT payload must fail loudly rather than return
+ * the stored response — otherwise a user who corrects a field and resubmits
+ * gets the pre-correction result back and believes the edit took. That would
+ * make idempotency its own source of lost writes, which is worse than the
+ * duplicates it prevents.
+ *
+ * Object keys are sorted so that serialization order cannot change the hash.
+ * Array order is deliberately NOT sorted — [1,2] and [2,1] are different
+ * requests, and treating them as equal would silently merge them.
+ */
+export function canonicalize(value: unknown): string {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
+ const entries = Object.entries(value as Record)
+ .filter(([, v]) => v !== undefined)
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`;
+}
+
+export async function fingerprint(method: string, path: string, body: unknown): Promise {
+ const data = new TextEncoder().encode(`${method.toUpperCase()} ${path} ${canonicalize(body)}`);
+ const digest = await crypto.subtle.digest('SHA-256', data);
+ return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
+}
diff --git a/tests/unit/idempotency/fingerprint.spec.ts b/tests/unit/idempotency/fingerprint.spec.ts
new file mode 100644
index 000000000..06140ccb7
--- /dev/null
+++ b/tests/unit/idempotency/fingerprint.spec.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import { canonicalize, fingerprint } from '../../../server/lib/idempotency/fingerprint';
+
+describe('canonicalize', () => {
+ it('orders keys so payload order cannot change the fingerprint', () => {
+ expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 }));
+ });
+
+ it('recurses into nested objects and arrays', () => {
+ expect(canonicalize({ x: [{ b: 1, a: 2 }] })).toBe(canonicalize({ x: [{ a: 2, b: 1 }] }));
+ });
+
+ it('does NOT treat array order as insignificant — [1,2] is not [2,1]', () => {
+ expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
+ });
+});
+
+describe('fingerprint', () => {
+ it('differs when the body differs', async () => {
+ const a = await fingerprint('POST', '/api/inspections', { address: 'A' });
+ const b = await fingerprint('POST', '/api/inspections', { address: 'B' });
+ expect(a).not.toBe(b);
+ });
+
+ it('differs when the path differs but the body matches', async () => {
+ const a = await fingerprint('POST', '/api/inspections', { x: 1 });
+ const b = await fingerprint('POST', '/api/reports', { x: 1 });
+ expect(a).not.toBe(b);
+ });
+
+ it('is stable across calls', async () => {
+ const body = { address: '123 Main', date: '2026-08-05' };
+ expect(await fingerprint('POST', '/p', body)).toBe(await fingerprint('POST', '/p', body));
+ });
+});
From 1ca19627df547aaa8d1f358e86cfa22384a3ade5 Mon Sep 17 00:00:00 2001
From: important-new
Date: Wed, 5 Aug 2026 11:52:55 +0800
Subject: [PATCH 098/111] feat(idempotency): key store, with the row as the
lock
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
---
migrations/0039_nasty_random.sql | 13 +
migrations/meta/0039_snapshot.json | 10597 +++++++++++++++++++++++++
migrations/meta/_journal.json | 7 +
server/lib/db/schema/idempotency.ts | 36 +
server/lib/db/schema/index.ts | 3 +
server/lib/idempotency/store.ts | 81 +
tests/unit/idempotency/store.spec.ts | 41 +
7 files changed, 10778 insertions(+)
create mode 100644 migrations/0039_nasty_random.sql
create mode 100644 migrations/meta/0039_snapshot.json
create mode 100644 server/lib/db/schema/idempotency.ts
create mode 100644 server/lib/idempotency/store.ts
create mode 100644 tests/unit/idempotency/store.spec.ts
diff --git a/migrations/0039_nasty_random.sql b/migrations/0039_nasty_random.sql
new file mode 100644
index 000000000..b33b3b91a
--- /dev/null
+++ b/migrations/0039_nasty_random.sql
@@ -0,0 +1,13 @@
+CREATE TABLE `idempotency_keys` (
+ `tenant_id` text NOT NULL,
+ `key` text NOT NULL,
+ `fingerprint` text NOT NULL,
+ `state` text DEFAULT 'in_flight' NOT NULL,
+ `response_status` integer,
+ `response_body` text,
+ `created_at` integer NOT NULL,
+ `expires_at` integer NOT NULL,
+ PRIMARY KEY(`tenant_id`, `key`)
+);
+--> statement-breakpoint
+CREATE INDEX `idx_idempotency_expires` ON `idempotency_keys` (`expires_at`);
\ No newline at end of file
diff --git a/migrations/meta/0039_snapshot.json b/migrations/meta/0039_snapshot.json
new file mode 100644
index 000000000..e3cab69c6
--- /dev/null
+++ b/migrations/meta/0039_snapshot.json
@@ -0,0 +1,10597 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "b29a9fe3-4496-40de-93d2-ba1d2a816822",
+ "prevId": "ad589f6b-092b-4147-a70c-2acc19b14137",
+ "tables": {
+ "agreement_requests": {
+ "name": "agreement_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signature_base64": {
+ "name": "inspector_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_signed_at": {
+ "name": "inspector_signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_user_id": {
+ "name": "inspector_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_snapshot": {
+ "name": "content_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completion_policy": {
+ "name": "completion_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "purged_at": {
+ "name": "purged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agreement_requests_token_unique": {
+ "name": "agreement_requests_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_verify_token": {
+ "name": "idx_agreement_requests_verify_token",
+ "columns": [
+ "verification_token"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_requests_tenant": {
+ "name": "idx_agreement_requests_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_inspection": {
+ "name": "idx_agreement_requests_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_requests_token_hash": {
+ "name": "idx_agreement_requests_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "agreement_requests_tenant_id_tenants_id_fk": {
+ "name": "agreement_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspection_id_inspections_id_fk": {
+ "name": "agreement_requests_inspection_id_inspections_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_agreement_id_agreements_id_fk": {
+ "name": "agreement_requests_agreement_id_agreements_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agreement_requests_inspector_user_id_users_id_fk": {
+ "name": "agreement_requests_inspector_user_id_users_id_fk",
+ "tableFrom": "agreement_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreement_signers": {
+ "name": "agreement_signers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "signature_base64": {
+ "name": "signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_of": {
+ "name": "on_behalf_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "on_behalf_disclaimer": {
+ "name": "on_behalf_disclaimer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminded_at": {
+ "name": "last_reminded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "language_disclosure_version": {
+ "name": "language_disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreement_signers_tenant_request": {
+ "name": "idx_agreement_signers_tenant_request",
+ "columns": [
+ "tenant_id",
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_agreement_signers_request_email": {
+ "name": "idx_agreement_signers_request_email",
+ "columns": [
+ "request_id",
+ "email"
+ ],
+ "isUnique": true
+ },
+ "idx_agreement_signers_token_hash": {
+ "name": "idx_agreement_signers_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agreements": {
+ "name": "agreements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_agreements_tenant": {
+ "name": "idx_agreements_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "agreements_tenant_id_tenants_id_fk": {
+ "name": "agreements_tenant_id_tenants_id_fk",
+ "tableFrom": "agreements",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_logs": {
+ "name": "automation_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient": {
+ "name": "recipient",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_key": {
+ "name": "recipient_role_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'email'"
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_contact_id": {
+ "name": "recipient_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notice_id": {
+ "name": "notice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automation_logs_pending": {
+ "name": "idx_automation_logs_pending",
+ "columns": [
+ "tenant_id",
+ "status",
+ "send_at"
+ ],
+ "isUnique": false
+ },
+ "idx_automation_logs_insp": {
+ "name": "idx_automation_logs_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_automation_logs_event": {
+ "name": "uq_automation_logs_event",
+ "columns": [
+ "automation_id",
+ "inspection_id",
+ "event_id",
+ "channel",
+ "recipient"
+ ],
+ "isUnique": true,
+ "where": "event_id IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "automation_logs_tenant_id_tenants_id_fk": {
+ "name": "automation_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "automation_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_kind": {
+ "name": "recipient_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_role_profile_id": {
+ "name": "recipient_role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "delay_minutes": {
+ "name": "delay_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "subject_template": {
+ "name": "subject_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_template": {
+ "name": "body_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "channels": {
+ "name": "channels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[\"email\"]'"
+ },
+ "sms_body": {
+ "name": "sms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "in_app_template_id": {
+ "name": "in_app_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_automations_tenant": {
+ "name": "idx_automations_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automations_tenant_id_tenants_id_fk": {
+ "name": "automations_tenant_id_tenants_id_fk",
+ "tableFrom": "automations",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability": {
+ "name": "availability",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_of_week": {
+ "name": "day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_availability_inspector": {
+ "name": "idx_availability_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_availability_window_unique": {
+ "name": "idx_availability_window_unique",
+ "columns": [
+ "inspector_id",
+ "day_of_week",
+ "start_time"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_tenant_id_tenants_id_fk": {
+ "name": "availability_tenant_id_tenants_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_inspector_id_users_id_fk": {
+ "name": "availability_inspector_id_users_id_fk",
+ "tableFrom": "availability",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "availability_overrides": {
+ "name": "availability_overrides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_available": {
+ "name": "is_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "transparency": {
+ "name": "transparency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_avail_overrides_insp": {
+ "name": "idx_avail_overrides_insp",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_avail_overrides_block_unique": {
+ "name": "idx_avail_overrides_block_unique",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": true,
+ "where": "is_available = 0 AND source IS NULL"
+ },
+ "uq_avail_overrides_external": {
+ "name": "uq_avail_overrides_external",
+ "columns": [
+ "inspector_id",
+ "source",
+ "external_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "availability_overrides_tenant_id_tenants_id_fk": {
+ "name": "availability_overrides_tenant_id_tenants_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "availability_overrides_inspector_id_users_id_fk": {
+ "name": "availability_overrides_inspector_id_users_id_fk",
+ "tableFrom": "availability_overrides",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_blocks": {
+ "name": "calendar_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_all_day": {
+ "name": "is_all_day",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_calendar_blocks_tenant_user_date": {
+ "name": "idx_calendar_blocks_tenant_user_date",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connection_read_calendars": {
+ "name": "calendar_connection_read_calendars",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "external_calendar_id": {
+ "name": "external_calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_role": {
+ "name": "access_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_conn_read_cal": {
+ "name": "uq_conn_read_cal",
+ "columns": [
+ "connection_id",
+ "external_calendar_id"
+ ],
+ "isUnique": true
+ },
+ "idx_conn_read_cal_tenant": {
+ "name": "idx_conn_read_cal_tenant",
+ "columns": [
+ "tenant_id",
+ "connection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calendar_connections": {
+ "name": "calendar_connections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentials_dek_enc": {
+ "name": "credentials_dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calendar_id": {
+ "name": "calendar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_calendar_connections_user_provider": {
+ "name": "uq_calendar_connections_user_provider",
+ "columns": [
+ "user_id",
+ "provider"
+ ],
+ "isUnique": true
+ },
+ "idx_calendar_connections_tenant_user": {
+ "name": "idx_calendar_connections_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_usage": {
+ "name": "comment_usage",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comment_usage_user_last_used": {
+ "name": "idx_comment_usage_user_last_used",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "last_used_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comment_usage_comment_id_comments_id_fk": {
+ "name": "comment_usage_comment_id_comments_id_fk",
+ "tableFrom": "comment_usage",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "comment_usage_tenant_id_user_id_comment_id_pk": {
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "comment_id"
+ ],
+ "name": "comment_usage_tenant_id_user_id_comment_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_bucket": {
+ "name": "rating_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section": {
+ "name": "section",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_ids": {
+ "name": "section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_labels": {
+ "name": "item_labels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trigger_code": {
+ "name": "trigger_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_keywords": {
+ "name": "search_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repair_summary": {
+ "name": "repair_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_min_cents": {
+ "name": "estimate_min_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "estimate_max_cents": {
+ "name": "estimate_max_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recommended_contractor_type_id": {
+ "name": "recommended_contractor_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_comments_tenant": {
+ "name": "idx_comments_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_rating_bucket": {
+ "name": "idx_comments_rating_bucket",
+ "columns": [
+ "tenant_id",
+ "rating_bucket"
+ ],
+ "isUnique": false
+ },
+ "idx_comments_library_id": {
+ "name": "idx_comments_library_id",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "comments_tenant_id_tenants_id_fk": {
+ "name": "comments_tenant_id_tenants_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "commercial_subtypes": {
+ "name": "commercial_subtypes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_disabled": {
+ "name": "is_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_commercial_subtypes_tenant_name": {
+ "name": "idx_commercial_subtypes_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "commercial_subtypes_tenant_id_tenants_id_fk": {
+ "name": "commercial_subtypes_tenant_id_tenants_id_fk",
+ "tableFrom": "commercial_subtypes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "concierge_confirm_tokens": {
+ "name": "concierge_confirm_tokens",
+ "columns": {
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_concierge_tokens_expiry": {
+ "name": "idx_concierge_tokens_expiry",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ },
+ "idx_concierge_confirm_token_hash": {
+ "name": "idx_concierge_confirm_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "concierge_confirm_tokens_inspection_id_inspections_id_fk": {
+ "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk",
+ "tableFrom": "concierge_confirm_tokens",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contact_role_profiles": {
+ "name": "contact_role_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_template_id": {
+ "name": "email_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sms_template_id": {
+ "name": "sms_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "capability_overrides": {
+ "name": "capability_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_crp_tenant": {
+ "name": "idx_crp_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_crp_tenant_key": {
+ "name": "uq_crp_tenant_key",
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "isUnique": true,
+ "where": "is_active = 1"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contacts": {
+ "name": "contacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agency": {
+ "name": "agency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_user_id": {
+ "name": "agent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_linked_at": {
+ "name": "agent_linked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agent_revoked_at": {
+ "name": "agent_revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contacts_type": {
+ "name": "idx_contacts_type",
+ "columns": [
+ "tenant_id",
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_contacts_tenant": {
+ "name": "idx_contacts_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_contacts_tenant_email": {
+ "name": "uq_contacts_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "email IS NOT NULL AND archived_at IS NULL"
+ },
+ "uq_contacts_tenant_agent_user": {
+ "name": "uq_contacts_tenant_agent_user",
+ "columns": [
+ "tenant_id",
+ "agent_user_id"
+ ],
+ "isUnique": true,
+ "where": "agent_user_id IS NOT NULL AND archived_at IS NULL"
+ },
+ "idx_contacts_agent_user": {
+ "name": "idx_contacts_agent_user",
+ "columns": [
+ "agent_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "contacts_tenant_id_tenants_id_fk": {
+ "name": "contacts_tenant_id_tenants_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "contractor_types": {
+ "name": "contractor_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_contractor_types_tenant": {
+ "name": "idx_contractor_types_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cost_items": {
+ "name": "cost_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "building_id": {
+ "name": "building_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "instance_index": {
+ "name": "instance_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_id": {
+ "name": "unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "system": {
+ "name": "system",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cost_method": {
+ "name": "cost_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uom": {
+ "name": "uom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_cost_cents": {
+ "name": "unit_cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lump_sum_cents": {
+ "name": "lump_sum_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eul": {
+ "name": "eul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "eff_age": {
+ "name": "eff_age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rul": {
+ "name": "rul",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suggested_remedy": {
+ "name": "suggested_remedy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_ref": {
+ "name": "section_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_ref": {
+ "name": "photo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cost_items_tenant_inspection": {
+ "name": "idx_cost_items_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_cost_items_finding_key": {
+ "name": "idx_cost_items_finding_key",
+ "columns": [
+ "finding_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "defect_categories": {
+ "name": "defect_categories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6b7280'"
+ },
+ "is_summary_driver": {
+ "name": "is_summary_driver",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_defect_categories_tenant": {
+ "name": "idx_defect_categories_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discount_codes": {
+ "name": "discount_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uses_count": {
+ "name": "uses_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_discount_codes_tenant": {
+ "name": "idx_discount_codes_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_discount_codes_code_tenant": {
+ "name": "uq_discount_codes_code_tenant",
+ "columns": [
+ "upper(code)",
+ "tenant_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "discount_codes_tenant_id_tenants_id_fk": {
+ "name": "discount_codes_tenant_id_tenants_id_fk",
+ "tableFrom": "discount_codes",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_review_items": {
+ "name": "document_review_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_requested": {
+ "name": "is_requested",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_received": {
+ "name": "is_received",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_reviewed": {
+ "name": "is_reviewed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_na": {
+ "name": "is_na",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_doc_review_inspection": {
+ "name": "idx_doc_review_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_doc_review_item": {
+ "name": "uq_doc_review_item",
+ "columns": [
+ "inspection_id",
+ "document_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_suppressions": {
+ "name": "email_suppressions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_provider": {
+ "name": "source_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_event_id": {
+ "name": "provider_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_email_suppressions_email": {
+ "name": "idx_email_suppressions_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "erasure_log": {
+ "name": "erasure_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_email": {
+ "name": "subject_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "identity_basis": {
+ "name": "identity_basis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "decisions_json": {
+ "name": "decisions_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_count": {
+ "name": "retained_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "anonymized_count": {
+ "name": "anonymized_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "deleted_count": {
+ "name": "deleted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "response_note": {
+ "name": "response_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_erasure_log_tenant": {
+ "name": "idx_erasure_log_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "esign_audit_logs": {
+ "name": "esign_audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload_json": {
+ "name": "payload_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_esign_audit_logs_request": {
+ "name": "idx_esign_audit_logs_request",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_esign_audit_logs_event_dedup": {
+ "name": "idx_esign_audit_logs_event_dedup",
+ "columns": [
+ "tenant_id",
+ "request_id",
+ "event"
+ ],
+ "isUnique": true,
+ "where": "event NOT LIKE 'signer.%'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "event_types": {
+ "name": "event_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_duration_min": {
+ "name": "default_duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "default_price_cents": {
+ "name": "default_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#6366f1'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "follow_up_delay_hours": {
+ "name": "follow_up_delay_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 72
+ }
+ },
+ "indexes": {
+ "uq_event_types_tenant_slug": {
+ "name": "uq_event_types_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "event_types_tenant_id_tenants_id_fk": {
+ "name": "event_types_tenant_id_tenants_id_fk",
+ "tableFrom": "event_types",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "idempotency_keys": {
+ "name": "idempotency_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_flight'"
+ },
+ "response_status": {
+ "name": "response_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "response_body": {
+ "name": "response_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_idempotency_expires": {
+ "name": "idx_idempotency_expires",
+ "columns": [
+ "expires_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "idempotency_keys_tenant_id_key_pk": {
+ "columns": [
+ "tenant_id",
+ "key"
+ ],
+ "name": "idempotency_keys_tenant_id_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_access_tokens": {
+ "name": "inspection_access_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "recipient_email": {
+ "name": "recipient_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'client'"
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_enc": {
+ "name": "token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_iat_token": {
+ "name": "idx_iat_token",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_inspection": {
+ "name": "idx_iat_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_iat_recipient": {
+ "name": "idx_iat_recipient",
+ "columns": [
+ "inspection_id",
+ "recipient_email"
+ ],
+ "isUnique": true
+ },
+ "idx_iat_token_hash": {
+ "name": "idx_iat_token_hash",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "inspection_access_tokens_tenant_id_tenants_id_fk": {
+ "name": "inspection_access_tokens_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_access_tokens",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_events": {
+ "name": "inspection_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type_id": {
+ "name": "event_type_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'scheduled'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "results_received_at": {
+ "name": "results_received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gcal_event_id": {
+ "name": "gcal_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_events_scheduled": {
+ "name": "idx_inspection_events_scheduled",
+ "columns": [
+ "tenant_id",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_events_inspection": {
+ "name": "idx_inspection_events_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_events_tenant_id_tenants_id_fk": {
+ "name": "inspection_events_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspection_id_inspections_id_fk": {
+ "name": "inspection_events_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_events_event_type_id_event_types_id_fk": {
+ "name": "inspection_events_event_type_id_event_types_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "event_types",
+ "columnsFrom": [
+ "event_type_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_events_inspector_id_users_id_fk": {
+ "name": "inspection_events_inspector_id_users_id_fk",
+ "tableFrom": "inspection_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_inspectors": {
+ "name": "inspection_inspectors",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lead'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_insp_inspectors_tenant_user": {
+ "name": "idx_insp_inspectors_tenant_user",
+ "columns": [
+ "tenant_id",
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_inspectors_user": {
+ "name": "idx_insp_inspectors_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_inspectors_inspection_id_user_id_pk": {
+ "columns": [
+ "inspection_id",
+ "user_id"
+ ],
+ "name": "inspection_inspectors_inspection_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_item_tag_links": {
+ "name": "inspection_item_tag_links",
+ "columns": {
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tag_links_tenant": {
+ "name": "idx_tag_links_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_tag": {
+ "name": "idx_tag_links_tag",
+ "columns": [
+ "tag_id"
+ ],
+ "isUnique": false
+ },
+ "idx_tag_links_inspection_item": {
+ "name": "idx_tag_links_inspection_item",
+ "columns": [
+ "inspection_id",
+ "item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": {
+ "columns": [
+ "inspection_id",
+ "item_id",
+ "tag_id"
+ ],
+ "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_media_pool": {
+ "name": "inspection_media_pool",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'photo'"
+ },
+ "stream_uid": {
+ "name": "stream_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poster_pct": {
+ "name": "poster_pct",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_sec": {
+ "name": "duration_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'stream'"
+ },
+ "poster_key": {
+ "name": "poster_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_media_pool_tenant": {
+ "name": "idx_media_pool_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_media_pool_inspection": {
+ "name": "idx_media_pool_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_messages": {
+ "name": "inspection_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "from_role": {
+ "name": "from_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachments": {
+ "name": "attachments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_msg_inspection": {
+ "name": "idx_msg_inspection",
+ "columns": [
+ "inspection_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_contact": {
+ "name": "idx_msg_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_msg_unread": {
+ "name": "idx_msg_unread",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "from_role"
+ ],
+ "isUnique": false,
+ "where": "\"inspection_messages\".\"read_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "inspection_messages_tenant_id_tenants_id_fk": {
+ "name": "inspection_messages_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_messages_inspection_id_inspections_id_fk": {
+ "name": "inspection_messages_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_messages",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_people": {
+ "name": "inspection_people",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role_profile_id": {
+ "name": "role_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ip_inspection": {
+ "name": "idx_ip_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_ip_tenant": {
+ "name": "idx_ip_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_ip_insp_contact_role": {
+ "name": "uq_ip_insp_contact_role",
+ "columns": [
+ "inspection_id",
+ "contact_id",
+ "role_profile_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_requests": {
+ "name": "inspection_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_phone": {
+ "name": "client_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "property_city": {
+ "name": "property_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_state": {
+ "name": "property_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_zip": {
+ "name": "property_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_amount_cents": {
+ "name": "total_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_requests_tenant": {
+ "name": "idx_inspection_requests_tenant",
+ "columns": [
+ "tenant_id",
+ "status",
+ "scheduled_at"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_requests_email": {
+ "name": "idx_inspection_requests_email",
+ "columns": [
+ "tenant_id",
+ "client_email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_requests_tenant_id_tenants_id_fk": {
+ "name": "inspection_requests_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_requests",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_results": {
+ "name": "inspection_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ydoc_state": {
+ "name": "ydoc_state",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating_system_snapshot": {
+ "name": "rating_system_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_id": {
+ "name": "report_id",
+ "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_report": {
+ "name": "uq_results_report",
+ "columns": [
+ "report_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
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {
+ "idx_insp_services_tenant": {
+ "name": "idx_insp_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_insp_services_insp": {
+ "name": "idx_insp_services_insp",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspection_services_tenant_id_tenants_id_fk": {
+ "name": "inspection_services_tenant_id_tenants_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspection_services_inspection_id_inspections_id_fk": {
+ "name": "inspection_services_inspection_id_inspections_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "inspections",
+ "columnsFrom": [
+ "inspection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "inspection_services_service_id_services_id_fk": {
+ "name": "inspection_services_service_id_services_id_fk",
+ "tableFrom": "inspection_services",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_types": {
+ "name": "inspection_types",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "based_on": {
+ "name": "based_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_types_tenant_name": {
+ "name": "idx_inspection_types_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspection_units": {
+ "name": "inspection_units",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_unit_id": {
+ "name": "parent_unit_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unit'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "attrs": {
+ "name": "attrs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspection_units_tenant_inspection": {
+ "name": "idx_inspection_units_tenant_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspection_units_parent": {
+ "name": "idx_inspection_units_parent",
+ "columns": [
+ "parent_unit_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspections": {
+ "name": "inspections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspector_id": {
+ "name": "inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_address": {
+ "name": "property_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "address_place_id": {
+ "name": "address_place_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_street": {
+ "name": "address_street",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_city": {
+ "name": "address_city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_state": {
+ "name": "address_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_zip": {
+ "name": "address_zip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_county": {
+ "name": "address_county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lat": {
+ "name": "address_lat",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_lng": {
+ "name": "address_lng",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "address_geocoded_at": {
+ "name": "address_geocoded_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'requested'"
+ },
+ "report_status": {
+ "name": "report_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "payment_status": {
+ "name": "payment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unpaid'"
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_reason": {
+ "name": "cancel_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancel_notes": {
+ "name": "cancel_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_payment_required": {
+ "name": "is_payment_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_agreement_required": {
+ "name": "is_agreement_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_auto_sign_on_publish": {
+ "name": "is_auto_sign_on_publish",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "discount_code_id": {
+ "name": "discount_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "discount_amount_cents": {
+ "name": "discount_amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "closing_date": {
+ "name": "closing_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referral_source": {
+ "name": "referral_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reference_number": {
+ "name": "reference_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "internal_notes": {
+ "name": "internal_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "year_built": {
+ "name": "year_built",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sqft": {
+ "name": "sqft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "foundation_type": {
+ "name": "foundation_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bedrooms": {
+ "name": "bedrooms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bathrooms": {
+ "name": "bathrooms",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lot_size": {
+ "name": "lot_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_facts": {
+ "name": "property_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_photo_id": {
+ "name": "cover_photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_crop": {
+ "name": "cover_crop",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_image_key": {
+ "name": "cover_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_tier": {
+ "name": "report_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "county": {
+ "name": "county",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_automations_disabled": {
+ "name": "is_automations_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "template_snapshot": {
+ "name": "template_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_snapshot_version": {
+ "name": "template_snapshot_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1
+ },
+ "profile_override": {
+ "name": "profile_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "require_defect_fields_override": {
+ "name": "require_defect_fields_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "concierge_status": {
+ "name": "concierge_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_team_mode": {
+ "name": "is_team_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "lead_inspector_id": {
+ "name": "lead_inspector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "helper_inspector_ids": {
+ "name": "helper_inspector_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "data_version": {
+ "name": "data_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "source_inspection_id": {
+ "name": "source_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "root_inspection_id": {
+ "name": "root_inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reinspection_round": {
+ "name": "reinspection_round",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit_inspection_mode": {
+ "name": "unit_inspection_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tagged'"
+ },
+ "location_options": {
+ "name": "location_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sampling_declaration": {
+ "name": "sampling_declaration",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pca_narrative": {
+ "name": "pca_narrative",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deviations": {
+ "name": "deviations",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_mode": {
+ "name": "report_photo_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_start_ms": {
+ "name": "scheduled_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scheduled_end_ms": {
+ "name": "scheduled_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "badge_layout_override": {
+ "name": "badge_layout_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "report_photo_columns": {
+ "name": "report_photo_columns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "referred_by_contact_id": {
+ "name": "referred_by_contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_at": {
+ "name": "unlocked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlocked_by": {
+ "name": "unlocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unlock_reason": {
+ "name": "unlock_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reports_generated_at": {
+ "name": "reports_generated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspections_tenant": {
+ "name": "idx_inspections_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_request": {
+ "name": "idx_inspections_request",
+ "columns": [
+ "request_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector": {
+ "name": "idx_inspections_inspector",
+ "columns": [
+ "inspector_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_status": {
+ "name": "idx_inspections_tenant_status",
+ "columns": [
+ "tenant_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_tenant_date": {
+ "name": "idx_inspections_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_inspector_date": {
+ "name": "idx_inspections_inspector_date",
+ "columns": [
+ "inspector_id",
+ "date"
+ ],
+ "isUnique": false
+ },
+ "idx_inspections_root": {
+ "name": "idx_inspections_root",
+ "columns": [
+ "root_inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "inspections_tenant_id_tenants_id_fk": {
+ "name": "inspections_tenant_id_tenants_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_inspector_id_users_id_fk": {
+ "name": "inspections_inspector_id_users_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "inspector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_template_id_templates_id_fk": {
+ "name": "inspections_template_id_templates_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_discount_code_id_discount_codes_id_fk": {
+ "name": "inspections_discount_code_id_discount_codes_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "discount_codes",
+ "columnsFrom": [
+ "discount_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "inspections_request_id_inspection_requests_id_fk": {
+ "name": "inspections_request_id_inspection_requests_id_fk",
+ "tableFrom": "inspections",
+ "tableTo": "inspection_requests",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inspector_credentials": {
+ "name": "inspector_credentials",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "member_number": {
+ "name": "member_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_r2_key": {
+ "name": "image_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_inspector_credentials_tenant": {
+ "name": "idx_inspector_credentials_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_inspector_credentials_user": {
+ "name": "idx_inspector_credentials_user",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invoices": {
+ "name": "invoices",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "client_email": {
+ "name": "client_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "line_items": {
+ "name": "line_items",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "partial_paid_at": {
+ "name": "partial_paid_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voided_at": {
+ "name": "voided_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qbo_sync_status": {
+ "name": "qbo_sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "amount_paid_cents": {
+ "name": "amount_paid_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "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
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_libraries_kind_featured": {
+ "name": "idx_marketplace_libraries_kind_featured",
+ "columns": [
+ "kind",
+ "is_featured"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "marketplace_templates": {
+ "name": "marketplace_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "semver": {
+ "name": "semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "changelog": {
+ "name": "changelog",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "download_count": {
+ "name": "download_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "message_templates": {
+ "name": "message_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seeded": {
+ "name": "is_seeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ }
+ },
+ "indexes": {
+ "idx_message_templates_tenant_channel": {
+ "name": "idx_message_templates_tenant_channel",
+ "columns": [
+ "tenant_id",
+ "channel"
+ ],
+ "isUnique": false
+ },
+ "idx_message_templates_variant": {
+ "name": "idx_message_templates_variant",
+ "columns": [
+ "tenant_id",
+ "name",
+ "channel",
+ "locale"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messaging_compliance": {
+ "name": "messaging_compliance",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'own'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "subaccount_sid": {
+ "name": "subaccount_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_sid": {
+ "name": "customer_profile_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "customer_profile_status": {
+ "name": "customer_profile_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_sid": {
+ "name": "brand_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "brand_status": {
+ "name": "brand_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_sid": {
+ "name": "campaign_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "campaign_status": {
+ "name": "campaign_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_sid": {
+ "name": "tfv_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tfv_status": {
+ "name": "tfv_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "messaging_resource_sid": {
+ "name": "messaging_resource_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_meta": {
+ "name": "provider_meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number": {
+ "name": "provisioned_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provisioned_number_sid": {
+ "name": "provisioned_number_sid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "has_sender_attached": {
+ "name": "has_sender_attached",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "compliance_status": {
+ "name": "compliance_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'not_started'"
+ },
+ "rejection_reason": {
+ "name": "rejection_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "class_id": {
+ "name": "class_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notification_prefs_unique": {
+ "name": "idx_notification_prefs_unique",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "class_id",
+ "channel"
+ ],
+ "isUnique": true
+ },
+ "idx_notification_prefs_subject": {
+ "name": "idx_notification_prefs_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "order_payments": {
+ "name": "order_payments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "invoice_id": {
+ "name": "invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recorded_by": {
+ "name": "recorded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refunds_id": {
+ "name": "refunds_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_order_payments_inspection": {
+ "name": "idx_order_payments_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_order_payments_invoice": {
+ "name": "idx_order_payments_invoice",
+ "columns": [
+ "tenant_id",
+ "invoice_id"
+ ],
+ "isUnique": false
+ },
+ "uq_order_payments_provider_ref": {
+ "name": "uq_order_payments_provider_ref",
+ "columns": [
+ "tenant_id",
+ "provider",
+ "provider_ref"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "orphaned_media": {
+ "name": "orphaned_media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_orphaned_media_key": {
+ "name": "idx_orphaned_media_key",
+ "columns": [
+ "tenant_id",
+ "r2_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_webhook_events": {
+ "name": "processed_webhook_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "psq_responses": {
+ "name": "psq_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "responses": {
+ "name": "responses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'sent'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_psq_inspection": {
+ "name": "uq_psq_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": true
+ },
+ "idx_psq_share_token": {
+ "name": "idx_psq_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_connections": {
+ "name": "qbo_connections",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "realm_id": {
+ "name": "realm_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_sync_enabled": {
+ "name": "is_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "default_item_id": {
+ "name": "default_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'1'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_entity_map": {
+ "name": "qbo_entity_map",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_type": {
+ "name": "qbo_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_id": {
+ "name": "qbo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "qbo_sync_token": {
+ "name": "qbo_sync_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_qbo_entity_map_qbo": {
+ "name": "idx_qbo_entity_map_qbo",
+ "columns": [
+ "tenant_id",
+ "qbo_type",
+ "qbo_id"
+ ],
+ "isUnique": true
+ },
+ "idx_qbo_entity_map_oi": {
+ "name": "idx_qbo_entity_map_oi",
+ "columns": [
+ "tenant_id",
+ "oi_type",
+ "oi_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "qbo_sync_errors": {
+ "name": "qbo_sync_errors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_type": {
+ "name": "oi_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "oi_id": {
+ "name": "oi_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_msg": {
+ "name": "error_msg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "is_resolved": {
+ "name": "is_resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rating_systems": {
+ "name": "rating_systems",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "levels": {
+ "name": "levels",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_rating_systems_tenant_slug": {
+ "name": "idx_rating_systems_tenant_slug",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_rating_systems_tenant": {
+ "name": "idx_rating_systems_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rating_systems_tenant_id_tenants_id_fk": {
+ "name": "rating_systems_tenant_id_tenants_id_fk",
+ "tableFrom": "rating_systems",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_request_items": {
+ "name": "repair_request_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repair_request_id": {
+ "name": "repair_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finding_key": {
+ "name": "finding_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_title": {
+ "name": "section_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_label": {
+ "name": "item_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_snapshot": {
+ "name": "comment_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_credit_cents": {
+ "name": "requested_credit_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "defect_title_snapshot": {
+ "name": "defect_title_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_snapshot": {
+ "name": "location_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_snapshot": {
+ "name": "category_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trade_snapshot": {
+ "name": "trade_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_request_items_rr": {
+ "name": "idx_repair_request_items_rr",
+ "columns": [
+ "repair_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repair_requests": {
+ "name": "repair_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_kind": {
+ "name": "created_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by_ref": {
+ "name": "created_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "custom_intro": {
+ "name": "custom_intro",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_repair_requests_inspection": {
+ "name": "idx_repair_requests_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_repair_requests_share_token": {
+ "name": "idx_repair_requests_share_token",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_exports": {
+ "name": "report_exports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_exports_inspection": {
+ "name": "idx_report_exports_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_pdfs": {
+ "name": "report_pdfs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rendered_at": {
+ "name": "rendered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ready'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_report_pdfs_inspection_type": {
+ "name": "uq_report_pdfs_inspection_type",
+ "columns": [
+ "inspection_id",
+ "type",
+ "version_number"
+ ],
+ "isUnique": true
+ },
+ "idx_report_pdfs_tenant": {
+ "name": "idx_report_pdfs_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_status": {
+ "name": "idx_report_pdfs_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "idx_report_pdfs_content_hash": {
+ "name": "idx_report_pdfs_content_hash",
+ "columns": [
+ "inspection_id",
+ "type",
+ "content_hash"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "report_pdfs_tenant_id_tenants_id_fk": {
+ "name": "report_pdfs_tenant_id_tenants_id_fk",
+ "tableFrom": "report_pdfs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_signoff": {
+ "name": "report_signoff",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "person_id": {
+ "name": "person_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "qualifications_ref": {
+ "name": "qualifications_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signed_at": {
+ "name": "signed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "signature_ref": {
+ "name": "signature_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_dual_role": {
+ "name": "is_dual_role",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "idx_report_signoff_inspection": {
+ "name": "idx_report_signoff_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "uq_report_signoff_role": {
+ "name": "uq_report_signoff_role",
+ "columns": [
+ "inspection_id",
+ "role"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "report_versions": {
+ "name": "report_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version_number": {
+ "name": "version_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_json": {
+ "name": "snapshot_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prev_hash": {
+ "name": "prev_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "signature": {
+ "name": "signature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key_fingerprint": {
+ "name": "key_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_amendment": {
+ "name": "is_amendment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch() * 1000)"
+ },
+ "report_id": {
+ "name": "report_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_report_versions_report": {
+ "name": "idx_report_versions_report",
+ "columns": [
+ "report_id",
+ "version_number"
+ ],
+ "isUnique": false
+ },
+ "uq_report_versions_report_version": {
+ "name": "uq_report_versions_report_version",
+ "columns": [
+ "report_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": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": 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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_service_id": {
+ "name": "inspection_service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'in_progress'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_reports_inspection": {
+ "name": "idx_reports_inspection",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": false
+ },
+ "idx_reports_tenant": {
+ "name": "idx_reports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_reports_primary": {
+ "name": "uq_reports_primary",
+ "columns": [
+ "inspection_id"
+ ],
+ "isUnique": true,
+ "where": "kind = 'primary'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "service_inspectors": {
+ "name": "service_inspectors",
+ "columns": {
+ "service_id": {
+ "name": "service_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_service_inspectors_tenant": {
+ "name": "idx_service_inspectors_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "service_inspectors_service_id_user_id_pk": {
+ "columns": [
+ "service_id",
+ "user_id"
+ ],
+ "name": "service_inspectors_service_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "services": {
+ "name": "services",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "agreement_id": {
+ "name": "agreement_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "default_event_type_slugs": {
+ "name": "default_event_type_slugs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_services_tenant": {
+ "name": "idx_services_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "services_tenant_id_tenants_id_fk": {
+ "name": "services_tenant_id_tenants_id_fk",
+ "tableFrom": "services",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_template_id_templates_id_fk": {
+ "name": "services_template_id_templates_id_fk",
+ "tableFrom": "services",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "services_agreement_id_agreements_id_fk": {
+ "name": "services_agreement_id_agreements_id_fk",
+ "tableFrom": "services",
+ "tableTo": "agreements",
+ "columnsFrom": [
+ "agreement_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "signing_keys": {
+ "name": "signing_keys",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_enc": {
+ "name": "private_key_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "private_key_iv": {
+ "name": "private_key_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'Ed25519'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "signing_keys_tenant_id_tenants_id_fk": {
+ "name": "signing_keys_tenant_id_tenants_id_fk",
+ "tableFrom": "signing_keys",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_consent_log": {
+ "name": "sms_consent_log",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recipient_type": {
+ "name": "recipient_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "disclosure_version": {
+ "name": "disclosure_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "captured_via": {
+ "name": "captured_via",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip": {
+ "name": "ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'contact'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "idx_sms_consent_contact": {
+ "name": "idx_sms_consent_contact",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_sms_consent_subject": {
+ "name": "idx_sms_consent_subject",
+ "columns": [
+ "tenant_id",
+ "subject_kind",
+ "subject_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_delivery_status": {
+ "name": "sms_delivery_status",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_message_id": {
+ "name": "provider_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sms_delivery_status_msg": {
+ "name": "idx_sms_delivery_status_msg",
+ "columns": [
+ "tenant_id",
+ "provider_message_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sms_disclosure_versions": {
+ "name": "sms_disclosure_versions",
+ "columns": {
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_seed": {
+ "name": "is_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tags_tenant_name": {
+ "name": "idx_tags_tenant_name",
+ "columns": [
+ "tenant_id",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "idx_tags_tenant": {
+ "name": "idx_tags_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "templates": {
+ "name": "templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "schema": {
+ "name": "schema",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating_system_id": {
+ "name": "rating_system_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "property_type": {
+ "name": "property_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "commercial_subtype": {
+ "name": "commercial_subtype",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_featured": {
+ "name": "is_featured",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_templates_tenant": {
+ "name": "idx_templates_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_templates_rating_system": {
+ "name": "idx_templates_rating_system",
+ "columns": [
+ "rating_system_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "templates_tenant_id_tenants_id_fk": {
+ "name": "templates_tenant_id_tenants_id_fk",
+ "tableFrom": "templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_custom_holidays": {
+ "name": "tenant_custom_holidays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_tenant_custom_holidays_tenant_date": {
+ "name": "uq_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_custom_holidays_tenant_date": {
+ "name": "idx_tenant_custom_holidays_tenant_date",
+ "columns": [
+ "tenant_id",
+ "date"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_library_imports": {
+ "name": "tenant_library_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "row_count": {
+ "name": "row_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "uq_tenant_library_import": {
+ "name": "uq_tenant_library_import",
+ "columns": [
+ "tenant_id",
+ "library_id"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_library_imports_tenant": {
+ "name": "idx_tenant_library_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_import_history": {
+ "name": "tenant_marketplace_import_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_version": {
+ "name": "source_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_version": {
+ "name": "target_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_affected": {
+ "name": "rows_affected",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_marketplace_history_tenant": {
+ "name": "idx_marketplace_history_tenant",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_template": {
+ "name": "idx_marketplace_history_template",
+ "columns": [
+ "template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_marketplace_history_library": {
+ "name": "idx_marketplace_history_library",
+ "columns": [
+ "library_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_marketplace_imports": {
+ "name": "tenant_marketplace_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "marketplace_template_id": {
+ "name": "marketplace_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_semver": {
+ "name": "imported_semver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_template_id": {
+ "name": "local_template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imported_at": {
+ "name": "imported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mkt_imports_tmpl": {
+ "name": "idx_mkt_imports_tmpl",
+ "columns": [
+ "marketplace_template_id"
+ ],
+ "isUnique": false
+ },
+ "idx_mkt_imports_tenant": {
+ "name": "idx_mkt_imports_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": {
+ "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "marketplace_templates",
+ "columnsFrom": [
+ "marketplace_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tenant_marketplace_imports_local_template_id_templates_id_fk": {
+ "name": "tenant_marketplace_imports_local_template_id_templates_id_fk",
+ "tableFrom": "tenant_marketplace_imports",
+ "tableTo": "templates",
+ "columnsFrom": [
+ "local_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "usage_counters": {
+ "name": "usage_counters",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "period_key": {
+ "name": "period_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_usage_counters_tenant": {
+ "name": "idx_usage_counters_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "usage_counters_tenant_id_metric_period_key_pk": {
+ "columns": [
+ "tenant_id",
+ "metric",
+ "period_key"
+ ],
+ "name": "usage_counters_tenant_id_metric_period_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "email_templates": {
+ "name": "email_templates",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "blocks": {
+ "name": "blocks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_templates_tenant_id_tenants_id_fk": {
+ "name": "email_templates_tenant_id_tenants_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_templates_tenant_id_trigger_pk": {
+ "columns": [
+ "tenant_id",
+ "trigger"
+ ],
+ "name": "email_templates_tenant_id_trigger_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_configs": {
+ "name": "tenant_configs",
+ "columns": {
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "company_name": {
+ "name": "company_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "support_email": {
+ "name": "support_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_address": {
+ "name": "company_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_pdf_footer_shown": {
+ "name": "is_pdf_footer_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_page_numbers_shown": {
+ "name": "is_pdf_page_numbers_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_pdf_license_shown": {
+ "name": "is_pdf_license_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "sender_email": {
+ "name": "sender_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_mode": {
+ "name": "email_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "video_mode": {
+ "name": "video_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'r2'"
+ },
+ "sms_mode": {
+ "name": "sms_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'platform'"
+ },
+ "sender_display_name": {
+ "name": "sender_display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_of_contact": {
+ "name": "point_of_contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'company'"
+ },
+ "billing_url": {
+ "name": "billing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "review_url": {
+ "name": "review_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "company_phone": {
+ "name": "company_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "integration_config": {
+ "name": "integration_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "secrets_enc": {
+ "name": "secrets_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dek_enc": {
+ "name": "dek_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ics_token": {
+ "name": "ics_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "widget_allowed_origins": {
+ "name": "widget_allowed_origins",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_profile_id": {
+ "name": "default_profile_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'signature'"
+ },
+ "attention_thresholds": {
+ "name": "attention_thresholds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'"
+ },
+ "inspection_prefs": {
+ "name": "inspection_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_estimates_shown": {
+ "name": "is_estimates_shown",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_repair_list_enabled": {
+ "name": "is_repair_list_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_customer_repair_export_enabled": {
+ "name": "is_customer_repair_export_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unpaid_blocked": {
+ "name": "is_unpaid_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_unsigned_agreement_blocked": {
+ "name": "is_unsigned_agreement_blocked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "custom_referral_sources": {
+ "name": "custom_referral_sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dashboard_column_prefs": {
+ "name": "dashboard_column_prefs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_concierge_review_required": {
+ "name": "is_concierge_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_inspector_choice_allowed": {
+ "name": "is_inspector_choice_allowed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_pdf_pipeline_enabled": {
+ "name": "is_pdf_pipeline_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_team_mode_default": {
+ "name": "is_team_mode_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_apprentice_review_required": {
+ "name": "is_apprentice_review_required",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_guest_invites_enabled": {
+ "name": "is_guest_invites_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "require_defect_fields": {
+ "name": "require_defect_fields",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "agreement_retention_years": {
+ "name": "agreement_retention_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "reinspection_statuses": {
+ "name": "reinspection_statuses",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_collab_editing_enabled": {
+ "name": "is_collab_editing_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sms_byo_provider": {
+ "name": "sms_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_byo_provider": {
+ "name": "email_byo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'resend'"
+ },
+ "is_managed_eligible": {
+ "name": "is_managed_eligible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "managed_provider": {
+ "name": "managed_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'twilio'"
+ },
+ "is_reserve_schedule_enabled": {
+ "name": "is_reserve_schedule_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reserve_term_years": {
+ "name": "reserve_term_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 12
+ },
+ "inflation_rate_bps": {
+ "name": "inflation_rate_bps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_timezone": {
+ "name": "default_timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "booking_slot_mode": {
+ "name": "booking_slot_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "booking_slot_interval_min": {
+ "name": "booking_slot_interval_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "holiday_region": {
+ "name": "holiday_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "holiday_public_policy": {
+ "name": "holiday_public_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "holiday_internal_policy": {
+ "name": "holiday_internal_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ },
+ "default_locale": {
+ "name": "default_locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en-US'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'USD'"
+ },
+ "is_archive_revoking_access": {
+ "name": "is_archive_revoking_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "legal_mode": {
+ "name": "legal_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hosted'"
+ },
+ "custom_privacy_url": {
+ "name": "custom_privacy_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "custom_terms_url": {
+ "name": "custom_terms_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "privacy_body": {
+ "name": "privacy_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_body": {
+ "name": "terms_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'us'"
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'12h'"
+ },
+ "booking_conflict_policy": {
+ "name": "booking_conflict_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'advisory'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_configs_tenant_id_tenants_id_fk": {
+ "name": "tenant_configs_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_configs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenants": {
+ "name": "tenants",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "stripe_connect_account_id": {
+ "name": "stripe_connect_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "max_users": {
+ "name": "max_users",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "deployment_mode": {
+ "name": "deployment_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'shared'"
+ },
+ "applied_cmd_seq": {
+ "name": "applied_cmd_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "applied_cred_seq": {
+ "name": "applied_cred_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tenants_slug_unique": {
+ "name": "tenants_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_invites": {
+ "name": "tenant_invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inspector'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_invites_tenant": {
+ "name": "idx_invites_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "uq_tenant_invites_pending_email": {
+ "name": "uq_tenant_invites_pending_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "status = 'pending'"
+ }
+ },
+ "foreignKeys": {
+ "tenant_invites_tenant_id_tenants_id_fk": {
+ "name": "tenant_invites_tenant_id_tenants_id_fk",
+ "tableFrom": "tenant_invites",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "photo_url": {
+ "name": "photo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_signature_base64": {
+ "name": "default_signature_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_signature_enabled": {
+ "name": "is_signature_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manager'"
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_totp_enabled": {
+ "name": "is_totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_verified_at": {
+ "name": "totp_verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_active_at": {
+ "name": "last_active_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mentor_id": {
+ "name": "mentor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "assigned_section_ids": {
+ "name": "assigned_section_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "terms_accepted": {
+ "name": "terms_accepted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "permission_overrides": {
+ "name": "permission_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "date_format": {
+ "name": "date_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_format": {
+ "name": "time_format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "uq_users_tenant_email": {
+ "name": "uq_users_tenant_email",
+ "columns": [
+ "tenant_id",
+ "email"
+ ],
+ "isUnique": true,
+ "where": "deleted_at IS NULL"
+ },
+ "idx_users_tenant": {
+ "name": "idx_users_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_users_slug_per_tenant": {
+ "name": "idx_users_slug_per_tenant",
+ "columns": [
+ "tenant_id",
+ "slug"
+ ],
+ "isUnique": true
+ },
+ "idx_users_email": {
+ "name": "idx_users_email",
+ "columns": [
+ "email"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "users_tenant_id_tenants_id_fk": {
+ "name": "users_tenant_id_tenants_id_fk",
+ "tableFrom": "users",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspector_slug": {
+ "name": "inspector_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_audit_tenant_created": {
+ "name": "idx_audit_tenant_created",
+ "columns": [
+ "tenant_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_audit_entity": {
+ "name": "idx_audit_entity",
+ "columns": [
+ "entity_type",
+ "entity_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_tenant_id_tenants_id_fk": {
+ "name": "audit_logs_tenant_id_tenants_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_test_results": {
+ "name": "integration_test_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_ok": {
+ "name": "is_ok",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_by_user_id": {
+ "name": "tested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tested_at": {
+ "name": "tested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_integration_test_tenant_target": {
+ "name": "idx_integration_test_tenant_target",
+ "columns": [
+ "tenant_id",
+ "target",
+ "tested_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contact_id": {
+ "name": "contact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notifications_tenant_user_created": {
+ "name": "idx_notifications_tenant_user_created",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_user_unread": {
+ "name": "idx_notifications_tenant_user_unread",
+ "columns": [
+ "tenant_id",
+ "user_id",
+ "read_at"
+ ],
+ "isUnique": false
+ },
+ "idx_notifications_tenant_contact_created": {
+ "name": "idx_notifications_tenant_contact_created",
+ "columns": [
+ "tenant_id",
+ "contact_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_tenant_id_tenants_id_fk": {
+ "name": "notifications_tenant_id_tenants_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tenants",
+ "columnsFrom": [
+ "tenant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "parked_cmd_events": {
+ "name": "parked_cmd_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "envelope": {
+ "name": "envelope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_parked_cmd_events_received_at": {
+ "name": "idx_parked_cmd_events_received_at",
+ "columns": [
+ "received_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "processed_cmd_events": {
+ "name": "processed_cmd_events",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmd_type": {
+ "name": "cmd_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slug_reservations": {
+ "name": "slug_reservations",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sync_outbox": {
+ "name": "sync_outbox",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_tried_at": {
+ "name": "last_tried_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sync_outbox_status_created": {
+ "name": "idx_sync_outbox_status_created",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_destruction_records": {
+ "name": "tenant_destruction_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_slug": {
+ "name": "tenant_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rows_deleted": {
+ "name": "rows_deleted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_objects": {
+ "name": "r2_objects",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "r2_bytes": {
+ "name": "r2_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "kv_keys": {
+ "name": "kv_keys",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_destruction_tenant": {
+ "name": "idx_destruction_tenant",
+ "columns": [
+ "tenant_id"
+ ],
+ "isUnique": false
+ },
+ "idx_destruction_destroyed_at": {
+ "name": "idx_destruction_destroyed_at",
+ "columns": [
+ "destroyed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tenant_legal_versions": {
+ "name": "tenant_legal_versions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "doc": {
+ "name": "doc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_snapshot": {
+ "name": "body_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_material": {
+ "name": "is_material",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "published_by_user_id": {
+ "name": "published_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tenant_legal_versions_doc_version": {
+ "name": "idx_tenant_legal_versions_doc_version",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "version"
+ ],
+ "isUnique": true
+ },
+ "idx_tenant_legal_versions_latest": {
+ "name": "idx_tenant_legal_versions_latest",
+ "columns": [
+ "tenant_id",
+ "doc",
+ "published_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "client_uploads": {
+ "name": "client_uploads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inspection_id": {
+ "name": "inspection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_kind": {
+ "name": "uploaded_by_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_ref": {
+ "name": "uploaded_by_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uploaded_by_name": {
+ "name": "uploaded_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "r2_key": {
+ "name": "r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_client_uploads_inspection": {
+ "name": "idx_client_uploads_inspection",
+ "columns": [
+ "tenant_id",
+ "inspection_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {
+ "uq_discount_codes_code_tenant": {
+ "columns": {
+ "upper(code)": {
+ "isExpression": true
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index e4b3fa0e6..bba1aa907 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -274,6 +274,13 @@
"when": 1785884007195,
"tag": "0038_nasty_patriot",
"breakpoints": true
+ },
+ {
+ "idx": 39,
+ "version": "6",
+ "when": 1785901808286,
+ "tag": "0039_nasty_random",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/lib/db/schema/idempotency.ts b/server/lib/db/schema/idempotency.ts
new file mode 100644
index 000000000..36dcb41a6
--- /dev/null
+++ b/server/lib/db/schema/idempotency.ts
@@ -0,0 +1,36 @@
+import { sqliteTable, text, integer, primaryKey, index } from 'drizzle-orm/sqlite-core';
+
+/**
+ * Generic idempotency store (portal #107).
+ *
+ * One row per (tenant, key). The row IS the lock: `claim` inserts it, and a
+ * concurrent request that fails to insert knows someone else owns the work.
+ * That is why there is no separate lock table and no polling.
+ *
+ * The key is scoped to the TENANT, not global. A bare key is a shared
+ * namespace: two tenants that mint the same key would replay each other's
+ * stored response, which is a cross-tenant leak introduced by a correctness
+ * fix. `tenant_id` is therefore half of the primary key, and callers read it
+ * from the authenticated context — never from the request body.
+ *
+ * `responseBody` is the serialized success response, replayed verbatim so a
+ * retry is indistinguishable from the original call.
+ */
+export const idempotencyKeys = sqliteTable('idempotency_keys', {
+ tenantId: text('tenant_id').notNull(),
+ key: text('key').notNull(),
+ fingerprint: text('fingerprint').notNull(),
+ state: text('state', { enum: ['in_flight', 'done'] }).notNull().default('in_flight'),
+ responseStatus: integer('response_status'),
+ responseBody: text('response_body'),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
+}, (t) => [
+ primaryKey({ columns: [t.tenantId, t.key] }),
+ // Sweep read: "which claims have aged out?" (TTL is 24h — retries happen in
+ // seconds, so anything older is a different problem).
+ index('idx_idempotency_expires').on(t.expiresAt),
+]);
+
+export type IdempotencyKey = typeof idempotencyKeys.$inferSelect;
+export type NewIdempotencyKey = typeof idempotencyKeys.$inferInsert;
diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts
index 7e7685e48..ceb976551 100644
--- a/server/lib/db/schema/index.ts
+++ b/server/lib/db/schema/index.ts
@@ -90,3 +90,6 @@ export type { ReportExport, NewReportExport } from './report-export';
// Recipient notification preferences — one answer per (subject, class, channel).
export { notificationPreferences } from './notification-preferences';
export type { NotificationPreference, NewNotificationPreference } from './notification-preferences';
+// Generic idempotency ledger (portal #107) — one row per (tenant, key).
+export { idempotencyKeys } from './idempotency';
+export type { IdempotencyKey, NewIdempotencyKey } from './idempotency';
diff --git a/server/lib/idempotency/store.ts b/server/lib/idempotency/store.ts
new file mode 100644
index 000000000..7947566b2
--- /dev/null
+++ b/server/lib/idempotency/store.ts
@@ -0,0 +1,81 @@
+/**
+ * The idempotency key store — all SQL for the feature lives here.
+ *
+ * THE ROW IS THE LOCK. `claimKey` inserts the row; whoever's insert lands owns
+ * the work, and a concurrent caller whose insert conflicts learns that from the
+ * conflict itself. There is no separate lock table and nothing to poll, so
+ * there is no window in which two callers both believe they hold the claim.
+ *
+ * Kept free of Hono and of anything outside `server/lib/` so portal can vendor
+ * it byte-for-byte.
+ */
+import { and, eq } from 'drizzle-orm';
+import type { DrizzleD1Database } from 'drizzle-orm/d1';
+import { idempotencyKeys } from '../db/schema/idempotency';
+
+export interface ClaimArgs {
+ /** From the authenticated context. NEVER from the request body — see the schema. */
+ tenantId: string;
+ key: string;
+ fingerprint: string;
+ ttlMs: number;
+}
+
+export type ClaimResult =
+ | 'claimed'
+ | { state: 'done'; status: number; body: string }
+ | { state: 'in_flight' }
+ | { state: 'fingerprint_mismatch' };
+
+export async function claimKey(db: DrizzleD1Database, args: ClaimArgs): Promise {
+ const now = Date.now();
+ const inserted = await db
+ .insert(idempotencyKeys)
+ .values({
+ tenantId: args.tenantId,
+ key: args.key,
+ fingerprint: args.fingerprint,
+ state: 'in_flight',
+ createdAt: new Date(now),
+ expiresAt: new Date(now + args.ttlMs),
+ })
+ .onConflictDoNothing()
+ .returning({ key: idempotencyKeys.key });
+
+ if (inserted.length > 0) return 'claimed';
+
+ const [row] = await db
+ .select()
+ .from(idempotencyKeys)
+ .where(and(eq(idempotencyKeys.tenantId, args.tenantId), eq(idempotencyKeys.key, args.key)))
+ .limit(1);
+
+ // The row was swept between the insert and this read. Refusing is the safe
+ // direction: a retry a moment later claims cleanly, whereas running the
+ // handler here would be the duplicate this whole feature exists to prevent.
+ if (!row) return { state: 'in_flight' };
+
+ // Fingerprint first, ahead of state. A key replayed with a different
+ // payload must never receive the stored response — the caller edited
+ // something, and handing back the pre-edit result is a lost write.
+ if (row.fingerprint !== args.fingerprint) return { state: 'fingerprint_mismatch' };
+
+ if (row.state === 'done') {
+ return { state: 'done', status: row.responseStatus ?? 200, body: row.responseBody ?? '' };
+ }
+ return { state: 'in_flight' };
+}
+
+export interface CompleteArgs {
+ tenantId: string;
+ key: string;
+ status: number;
+ body: string;
+}
+
+export async function completeKey(db: DrizzleD1Database, args: CompleteArgs): Promise