From dad56a86e4b1aa2ab3af291c621d6e37b65bace0 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 22:44:13 +0800 Subject: [PATCH 01/77] test(quota): pin the D1 semantics the inspection cap depends on Probes the primitive the cap's concurrency guarantee rests on, before any code relies on it: a conditional upsert whose WHERE holds a correlated subquery over another table. Settles three things: - `excluded.tenant_id` IS in scope inside `DO UPDATE ... WHERE`, and resolves to the calling tenant (a sibling tenant's rows do not cap it). No bound-parameter fallback is needed. - `changes === 0` is reported when that subquery is false. - The plain `INSERT ... VALUES` form leaves the INSERT branch UNGATED: a conflict-free insert never reaches DO UPDATE, so a tenant already over the cap but with no counter row yet gets a free pass. The `INSERT ... SELECT ... WHERE` form gates both branches with the same predicate and is what the guard will use. --- .../quota/inspection-cap-counts-rows.spec.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/unit/quota/inspection-cap-counts-rows.spec.ts diff --git a/tests/unit/quota/inspection-cap-counts-rows.spec.ts b/tests/unit/quota/inspection-cap-counts-rows.spec.ts new file mode 100644 index 000000000..166366918 --- /dev/null +++ b/tests/unit/quota/inspection-cap-counts-rows.spec.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; + +/** + * The inspection cap is enforced by a single conditional upsert whose WHERE + * counts rows in another table. `meta.changes === 0` is what the guard reads to + * mean "at cap", so this asserts the primitive itself rather than trusting it. + * + * Specifically: does a conditional upsert whose `DO UPDATE ... WHERE` holds a + * correlated subquery report `changes === 0` when that subquery is false, and + * is `excluded.tenant_id` in scope there at all? Both are assumptions about + * SQLite/D1, not about our code, and the concurrency guarantee rests on them. + */ +describe('D1/SQLite: conditional upsert with a correlated subquery', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let sqlite: any; + + beforeEach(async () => { + const t = createTestDb(); + await setupSchema(t.sqlite); + sqlite = t.sqlite; + }); + + /** `inspections.tenant_id` carries a legacy FK, and better-sqlite3 enforces + * foreign keys by default — so the parent row has to exist first. */ + const seedTenant = (tenantId: string) => sqlite.prepare( + `INSERT INTO tenants (id, name, slug, created_at) VALUES (?, ?, ?, ?)`, + ).run(tenantId, `Tenant ${tenantId}`, tenantId, Date.now()); + + /** Minimal inspection rows — only the NOT NULL columns are supplied. */ + const seedInspections = (tenantId: string, n: number) => { + const stmt = sqlite.prepare( + `INSERT INTO inspections (id, tenant_id, property_address, date, created_at) + VALUES (?, ?, '1 Main St', '2026-08-05', ?)`, + ); + const base = countRows(tenantId); // so repeated calls do not collide on id + for (let i = 0; i < n; i++) stmt.run(`${tenantId}-insp-${base + i}`, tenantId, Date.now()); + }; + + const deleteInspections = (tenantId: string, n: number) => + sqlite.prepare( + `DELETE FROM inspections WHERE rowid IN + (SELECT rowid FROM inspections WHERE tenant_id = ? LIMIT ?)`, + ).run(tenantId, n); + + const countRows = (tenantId: string) => + sqlite.prepare('SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?').get(tenantId).n; + + const readCounter = (tenantId: string) => + sqlite.prepare( + `SELECT value FROM usage_counters + WHERE tenant_id = ? AND metric = 'inspections' AND period_key = 'lifetime'`, + ).get(tenantId)?.value; + + /** The exact statement shape the guard will use, `excluded.` references and all. */ + const upsert = (tenantId: string, cap: number) => sqlite.prepare( + `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) + VALUES (?, 'inspections', 'lifetime', + (SELECT COUNT(*) FROM inspections WHERE tenant_id = ?) + 1, ?) + ON CONFLICT(tenant_id, metric, period_key) + DO UPDATE SET value = (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + 1, + updated_at = excluded.updated_at + WHERE (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) < ?`, + ).run(tenantId, tenantId, Date.now(), cap); + + it('reports changes > 0 while under the cap', () => { + seedTenant('t1'); + seedInspections('t1', 2); + expect(upsert('t1', 5).changes).toBeGreaterThan(0); + }); + + it('reports changes === 0 once the row count reaches the cap', () => { + seedTenant('t1'); + seedInspections('t1', 5); + upsert('t1', 5); // creates the row (INSERT branch — no WHERE applies) + expect(upsert('t1', 5).changes).toBe(0); + }); + + it('the plain VALUES form leaves the INSERT branch UNGATED', () => { + // A conflict-free INSERT never reaches DO UPDATE, so its WHERE cannot + // block it: a tenant already over the cap but with no counter row yet + // gets a free pass. Documented here because it is why the guard uses + // the INSERT...SELECT form below instead. + seedTenant('t1'); + seedInspections('t1', 9); + expect(upsert('t1', 5).changes).toBeGreaterThan(0); + }); + + /** The form the guard actually uses: `INSERT ... SELECT ... WHERE` so the + * same row-count predicate gates the INSERT branch as well as the UPDATE. */ + const gatedUpsert = (tenantId: string, cap: number) => sqlite.prepare( + `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) + SELECT ?, 'inspections', 'lifetime', cnt.n + 1, ? + FROM (SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?) AS cnt + WHERE cnt.n < ? + ON CONFLICT(tenant_id, metric, period_key) + DO UPDATE SET value = (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + 1, + updated_at = excluded.updated_at + WHERE (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) < ?`, + ).run(tenantId, Date.now(), tenantId, cap, cap); + + it('the gated INSERT...SELECT form refuses a tenant already over the cap with no counter row', () => { + seedTenant('t1'); + seedInspections('t1', 9); + expect(gatedUpsert('t1', 5).changes).toBe(0); + expect(readCounter('t1')).toBeUndefined(); // no row was written at all + }); + + it('the gated form still admits a tenant under the cap with no counter row', () => { + seedTenant('t1'); + seedInspections('t1', 2); + expect(gatedUpsert('t1', 5).changes).toBeGreaterThan(0); + expect(readCounter('t1')).toBe(3); + }); + + it('the gated form behaves identically to the plain form on the UPDATE branch', () => { + seedTenant('t1'); + seedInspections('t1', 4); + expect(gatedUpsert('t1', 5).changes).toBeGreaterThan(0); // 4 rows < 5 → allowed + seedInspections('t1', 1); // the create lands: 5 rows + expect(gatedUpsert('t1', 5).changes).toBe(0); // now at cap + }); + + it('excluded.tenant_id IS in scope inside DO UPDATE ... WHERE', () => { + // If it were not, SQLite would raise "no such column" rather than run. + seedTenant('t1'); + seedTenant('t2'); + seedInspections('t1', 1); + seedInspections('t2', 9); + upsert('t1', 5); + expect(() => upsert('t1', 5)).not.toThrow(); + // And it resolves to THIS tenant: t2's 9 rows must not cap t1. + expect(upsert('t1', 5).changes).toBeGreaterThan(0); + }); + + it('lets the count FALL again when rows are deleted — the whole point', () => { + seedTenant('t1'); + seedInspections('t1', 5); + upsert('t1', 5); + expect(upsert('t1', 5).changes).toBe(0); // at cap + + deleteInspections('t1', 3); + expect(countRows('t1')).toBe(2); + expect(upsert('t1', 5).changes).toBeGreaterThan(0); // allowance returned + expect(readCounter('t1')).toBe(3); // cache heals to rows + this create + }); +}); From 3fc73777390206bf5cd8cde8d093cfc0a9607225 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:00:30 +0800 Subject: [PATCH 02/77] fix(quota): count inspections that exist, not creates that happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free-tier cap read a monotonic counter, so a tenant who deleted an inspection never got the allowance back. Found in production 2026-08-05: one tenant had 1 inspection and 4 of 5 consumed; another was capped at 5 with 3 rows. Five counters were corrected by hand that day — this makes that correction unnecessary rather than repeatable. The gate now counts inspection rows. `usage_counters.value` for this metric degrades from source-of-truth to a self-healing display cache. Still one statement, so `meta.changes === 0` stays the authoritative "at cap" answer with no read-then-write window inside the guard. Two things the probe (previous commit) forced: - `INSERT ... SELECT ... WHERE` rather than VALUES, so the INSERT branch is gated too. A conflict-free INSERT never reaches DO UPDATE, so the VALUES form waves through a tenant who has rows but no counter row. - `consumeInspection(tenantId, count)`. Because the gate counts rows the caller inserts only afterwards, N looped calls all read the same count and all pass — a 3-sub inspection request took a tenant from 3 inspections to 6, deterministically. InspectionRequestService.create now consumes the batch as one unit; it is admitted whole or not at all. What counting rows gives up, unavoidably: two creates that overlap before either row lands both pass. The cap becomes a steady-state invariant rather than a serialized claim — the overshoot is bounded by in-flight concurrency and self-corrects, since the next create counts what exists. That is the better trade: the old design bought serialization with a counter that could only climb, which is what cost real tenants their allowance permanently. sms/email are untouched — consumed events with no rows to count. Converting them to a row count would silently uncap them. --- server/features/plan-quota/guard.ts | 57 +++++-- server/services/inspection-request.service.ts | 10 +- .../unit/inspections/inspection-quota.spec.ts | 20 ++- .../quota/inspection-cap-counts-rows.spec.ts | 155 +++++++++++++++++- .../usage/plan-quota-guarded-services.spec.ts | 10 +- tests/unit/usage/plan-quota.spec.ts | 34 +++- 6 files changed, 252 insertions(+), 34 deletions(-) diff --git a/server/features/plan-quota/guard.ts b/server/features/plan-quota/guard.ts index c4ad24f4c..c449ea113 100644 --- a/server/features/plan-quota/guard.ts +++ b/server/features/plan-quota/guard.ts @@ -43,30 +43,59 @@ export class PlanQuotaGuard { }, ) {} - /** Atomic consume for inspection creation. Free+enforced: increment-if-below-cap - * (throws QuotaExhausted at the cap). Other tiers / standalone: plain increment - * (lifetime analytics). Counter is monotonic — deletes never refund. */ - async consumeInspection(tenantId: string): Promise { + /** Atomic consume for inspection creation. Free+enforced: allow-if-the-rows-a- + * tenant-has-plus-`count`-fit-under-the-cap (throws QuotaExhausted otherwise). + * Other tiers / standalone: plain increment (lifetime analytics). The cap + * counts the inspections a tenant HAS, so deleting one returns the allowance. + * + * `count` is the number of inspections the caller is about to create in one + * go. It must be passed for a batch rather than looping: because the gate + * counts rows and the caller inserts them only after this returns, N looped + * calls all read the same count and all pass — a 3-sub request would take a + * tenant from 3 inspections to 6. One call with `count: 3` is the same single + * statement and admits the batch only if the whole batch fits. */ + async consumeInspection(tenantId: string, count = 1): Promise { const tier = await readTenantTier(this.db, tenantId); if (!this.opts.enforced || tier !== 'free') { - await new MeteringService(this.db).record(tenantId, 'inspections', STOCK_PERIOD); + await new MeteringService(this.db).record(tenantId, 'inspections', STOCK_PERIOD, count); return; } const cap = FREE_TIER_CAPS.inspections; - // Single-statement conditional increment: the guarded UPDATE only fires - // while value < cap, so D1's `meta.changes === 0` is the authoritative - // "already at cap" signal even under concurrent callers — SQLite/D1 - // serialize writes to a given row, so there is no read-then-write window - // for two callers to both observe "below cap" and both increment past it. + // THE GATE COUNTS ROWS. `usage_counters.value` is a self-healing display + // cache for this metric, never the thing enforced — a stale value can no + // longer refuse a tenant who is genuinely under the cap, and a delete + // returns the allowance without anything having to write the counter back. + // (Do not "fix" a stale value by hand; the next create heals it. See #105.) + // + // Still a single statement, and still atomic: D1/SQLite serialize writes to + // a given row, so `meta.changes === 0` remains the authoritative "at cap" + // answer with no read-then-write window inside the guard. The INSERT branch + // is gated too — hence `INSERT ... SELECT ... WHERE` rather than VALUES, + // because a conflict-free INSERT never reaches DO UPDATE and would wave + // through a tenant who has rows but no counter row yet. + // + // What counting rows DOES give up, unavoidably: the caller inserts the + // inspection row AFTER this returns, so two creates that overlap before + // either row lands both see the same count and both pass. The cap is + // therefore a steady-state invariant, not a serialized claim — an overshoot + // is bounded by in-flight concurrency and self-corrects, because the next + // create counts the rows that actually exist and refuses. That trade is + // deliberate: the alternative (a counter that can only climb) is what cost + // real tenants their allowance permanently. The one case that would NOT + // have been a rare race — a caller creating N rows in a loop — is why + // `count` exists rather than being left to the caller to iterate. const res = await this.db.prepare( `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) - VALUES (?1, 'inspections', 'lifetime', 1, ?2) + SELECT ?1, 'inspections', 'lifetime', cnt.n + ?4, ?2 + FROM (SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?1) AS cnt + WHERE cnt.n + ?4 <= ?3 ON CONFLICT(tenant_id, metric, period_key) - DO UPDATE SET value = value + 1, updated_at = ?2 - WHERE usage_counters.value < ?3`, - ).bind(tenantId, Date.now(), cap).run(); + DO UPDATE SET value = (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + ?4, + updated_at = excluded.updated_at + WHERE (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + ?4 <= ?3`, + ).bind(tenantId, Date.now(), cap, count).run(); if (res.meta.changes === 0) { throw Errors.QuotaExhausted({ metric: 'inspections', used: cap, cap, billingPortalUrl: this.opts.billingPortalUrl }); diff --git a/server/services/inspection-request.service.ts b/server/services/inspection-request.service.ts index 4e53fa399..aea9d63fa 100644 --- a/server/services/inspection-request.service.ts +++ b/server/services/inspection-request.service.ts @@ -254,14 +254,14 @@ export class InspectionRequestService { const totalAmount = subs.reduce((sum, s) => sum + (s.price ?? 0), 0); - // Quota is consumed once per sub-inspection, after every precondition + // Quota is consumed for the whole batch at once, after every precondition // check above (bounds, template ownership) and BEFORE the parent // request row is inserted — a request row must never be orphaned // (created with zero children) because the tenant hit the cap - // partway through. - for (let i = 0; i < subs.length; i++) { - await this.planQuota?.consumeInspection(tenantId); - } + // partway through. One call, not a loop: the guard counts existing + // inspection rows and these are inserted below, so N looped calls would + // all read the same count and all pass. See consumeInspection's `count`. + await this.planQuota?.consumeInspection(tenantId, subs.length); await db.insert(inspectionRequests).values({ id: requestId, diff --git a/tests/unit/inspections/inspection-quota.spec.ts b/tests/unit/inspections/inspection-quota.spec.ts index 9d4d80fcf..1c23a20a5 100644 --- a/tests/unit/inspections/inspection-quota.spec.ts +++ b/tests/unit/inspections/inspection-quota.spec.ts @@ -103,14 +103,26 @@ describe('Inspection creation consumes the free-tier quota (Task 3)', () => { expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(3); }); - it('a delete does not refund quota', async () => { + it('a delete DOES return the allowance, and the counter heals on the next create', async () => { const guard = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); const svc = makeService(guard); - const a = await svc.createInspection(TENANT, minimalCreateData()); - await deleteInspectionCascade(testDb as unknown as DrizzleD1Database, makeR2(), TENANT, a.id); + // Fill the cap, then delete two — the tenant is under it again. + const created = []; + for (let i = 0; i < 5; i++) created.push(await svc.createInspection(TENANT, minimalCreateData())); + await expect(svc.createInspection(TENANT, minimalCreateData())).rejects.toMatchObject({ + status: 402, code: 'QUOTA_EXHAUSTED', + }); + for (const insp of created.slice(0, 2)) { + await deleteInspectionCascade(testDb as unknown as DrizzleD1Database, makeR2(), TENANT, insp.id); + } - expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(1); + // The counter is momentarily stale-high (nothing writes it on delete)... + expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(5); + // ...but it is a display cache, not the gate: the create is admitted, + // and the cache heals to the truth as it lands. + await expect(svc.createInspection(TENANT, minimalCreateData())).resolves.toBeDefined(); + expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(4); }); it('cloneInspection of a nonexistent id rejects and does not consume quota', async () => { diff --git a/tests/unit/quota/inspection-cap-counts-rows.spec.ts b/tests/unit/quota/inspection-cap-counts-rows.spec.ts index 166366918..8e15e1ceb 100644 --- a/tests/unit/quota/inspection-cap-counts-rows.spec.ts +++ b/tests/unit/quota/inspection-cap-counts-rows.spec.ts @@ -1,5 +1,15 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createTestDb, setupSchema } from '../db'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createTestDb, setupSchema, toRawD1 } from '../db'; + +// Same mocking pattern as tests/unit/usage/plan-quota.spec.ts: the guard's +// `drizzle(d1)` calls (tier lookup, MeteringService) resolve to the in-memory +// SQLite Drizzle instance, while its raw `db.prepare(...).bind(...).run()` path +// runs against `toRawD1` over the same underlying sqlite. +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 { MeteringService } from '../../../server/services/metering.service'; +import { FREE_TIER_CAPS } from '../../../server/features/plan-quota/policy'; /** * The inspection cap is enforced by a single conditional upsert whose WHERE @@ -145,3 +155,144 @@ describe('D1/SQLite: conditional upsert with a correlated subquery', () => { expect(readCounter('t1')).toBe(3); // cache heals to rows + this create }); }); + +/** + * The behaviour that primitive buys: the free-tier inspection cap gates on the + * inspections a tenant HAS, not on how many they have ever created. Deleting an + * inspection returns the allowance, and a stale `usage_counters.value` cannot + * block a tenant who is genuinely under the cap. + * + * The 2026-08-05 production defect is reproduced verbatim in the stale-counter + * case: one tenant had a single inspection row and a counter reading 4 of 5. + */ +describe('PlanQuotaGuard.consumeInspection counts rows, not creates', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let sqlite: any; + let testD1: D1Database; + let guard: PlanQuotaGuard; + + const CAP = FREE_TIER_CAPS.inspections; + + beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + sqlite = fixture.sqlite; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(fixture.db); + testD1 = toRawD1(sqlite); + sqlite.prepare( + `INSERT INTO tenants (id, name, slug, tier, created_at) VALUES (?, ?, ?, 'free', ?)`, + ).run('t1', 'Acme', 'acme', Date.now()); + guard = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); + }); + + const countRows = () => + sqlite.prepare("SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = 't1'").get().n; + + const seedInspections = (n: number) => { + const stmt = sqlite.prepare( + `INSERT INTO inspections (id, tenant_id, property_address, date, created_at) + VALUES (?, 't1', '1 Main St', '2026-08-05', ?)`, + ); + const base = countRows(); + for (let i = 0; i < n; i++) stmt.run(`insp-${base + i}`, Date.now()); + }; + + const deleteInspections = (n: number) => sqlite.prepare( + `DELETE FROM inspections WHERE rowid IN + (SELECT rowid FROM inspections WHERE tenant_id = 't1' LIMIT ?)`, + ).run(n); + + const setCounter = (value: number) => sqlite.prepare( + `INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) + VALUES ('t1', 'inspections', 'lifetime', ?, ?) + ON CONFLICT(tenant_id, metric, period_key) DO UPDATE SET value = excluded.value`, + ).run(value, Date.now()); + + it('a tenant with 5 inspections is at cap', async () => { + seedInspections(CAP); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ + status: 402, code: 'QUOTA_EXHAUSTED', + }); + }); + + it('deleting an inspection returns the allowance — the defect this fixes', async () => { + seedInspections(CAP); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + deleteInspections(2); // user cleans up duplicates + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + }); + + it('a stale counter does not block a tenant who is genuinely under the cap', async () => { + // The exact production state on 2026-08-05: counter said 4, one row existed. + seedInspections(1); + setCounter(4); + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + // ...and the counter heals to the truth rather than climbing from 4. + expect(await new MeteringService(testD1).lifetimeTotal('t1', 'inspections')).toBe(2); + }); + + it('a tenant over the cap with NO counter row is still refused', async () => { + // The INSERT branch has to be gated too — see the probe above. Reachable + // whenever rows exist without a counter (imports, a counter row deleted + // by hand to "fix" a stale value). + seedInspections(9); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + expect(await new MeteringService(testD1).lifetimeTotal('t1', 'inspections')).toBe(0); + }); + + it('the counter tracks the row count as a cache, one create ahead of the insert', async () => { + // consumeInspection runs immediately BEFORE the row is inserted, so the + // cached value is "rows now + this create" — which equals the row count + // once the caller's insert lands. + seedInspections(2); + await guard.consumeInspection('t1'); + expect(await new MeteringService(testD1).lifetimeTotal('t1', 'inspections')).toBe(3); + }); + + it('sms and email still TALLY — they have no rows to count', async () => { + const metering = new MeteringService(testD1); + await metering.record('t1', 'email', '2026-08'); + await metering.record('t1', 'email', '2026-08'); + expect(await metering.lifetimeTotal('t1', 'email')).toBe(2); + + // And the messaging gate still reads that tally, with zero rows anywhere + // to count — converting it to a row count would silently uncap it. + await metering.record('t1', 'sms', '2026-08', FREE_TIER_CAPS.sms); + await expect(guard.checkMessagingQuota('t1', 'free', 'sms')).rejects.toMatchObject({ + code: 'QUOTA_EXHAUSTED', + }); + }); + + it('a second consume is refused once the first create has landed', async () => { + // HONEST LIMITATION: better-sqlite3 is synchronous, so nothing in this + // file can overlap two calls; this asserts the statement's LOGIC, not + // concurrency. Real overlap is exercised under workerd in + // tests/workers/quota-cap-concurrency.spec.ts. + seedInspections(4); + await expect(guard.consumeInspection('t1')).resolves.toBeUndefined(); + seedInspections(1); // the caller's insert lands + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + }); + + it('two consumes that overlap BEFORE either row lands both pass — the accepted bound', async () => { + // Counting rows moves the cap from "serialized claim" to "steady-state + // invariant": the caller inserts its row after consumeInspection returns, + // so overlapping creates see the same count. The overshoot is bounded by + // in-flight concurrency and self-corrects — asserted below. This is the + // deliberate trade recorded in guard.ts, pinned so a future change that + // silently alters it has to come here and say so. + seedInspections(4); + const results = await Promise.allSettled([ + guard.consumeInspection('t1'), guard.consumeInspection('t1'), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(2); + + // ...and it self-corrects: with both rows landed the tenant is over the + // cap and cannot grow further. + seedInspections(2); + expect(countRows()).toBe(6); + await expect(guard.consumeInspection('t1')).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + }); +}); diff --git a/tests/unit/usage/plan-quota-guarded-services.spec.ts b/tests/unit/usage/plan-quota-guarded-services.spec.ts index 8cd878161..e46ab3749 100644 --- a/tests/unit/usage/plan-quota-guarded-services.spec.ts +++ b/tests/unit/usage/plan-quota-guarded-services.spec.ts @@ -193,11 +193,11 @@ describe('InspectionRequestService consumes the free-tier quota', () => { expect(reqRows).toHaveLength(1); // only the first (successful) request exists const inspRows = await testDb.select().from(schema.inspections).all(); expect(inspRows).toHaveLength(3); // only the first batch's 3 children exist - // The cap (5) was reached mid-loop — 2 of the 3 attempted consumes for - // the rejected batch succeeded before the 3rd hit the cap. The counter - // is monotonic (no refund), matching PlanQuotaGuard's documented - // semantics elsewhere (deletes/aborted batches never refund). - expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(5); + // The batch is consumed as one unit (`consumeInspection(tenantId, 3)`), + // so the rejected batch consumed NOTHING — the counter still reflects + // the 3 inspections that exist rather than being left inflated by two + // partial consumes. + expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(3); }); it('a rejected validation (unknown template) does not consume quota', async () => { diff --git a/tests/unit/usage/plan-quota.spec.ts b/tests/unit/usage/plan-quota.spec.ts index 5b2113be9..420beaa3d 100644 --- a/tests/unit/usage/plan-quota.spec.ts +++ b/tests/unit/usage/plan-quota.spec.ts @@ -30,6 +30,16 @@ describe('PlanQuotaGuard', () => { testD1 = toRawD1(sqlite); }); + /** The row the caller inserts right after a successful consume. The gate + * counts these, so a test that consumes without inserting is not modelling + * anything a real create path does. */ + function seedInspection(tenantId: string, i: number) { + sqlite.prepare( + `INSERT INTO inspections (id, tenant_id, property_address, date, created_at) + VALUES (?, ?, '1 Main St', '2026-08-05', ?)`, + ).run(`${tenantId}-insp-${i}`, tenantId, Date.now()); + } + async function seedTenant(id: string, opts: { tier: 'free' | 'pro' | 'enterprise' }) { await testDb.insert(tenants).values({ id, @@ -44,7 +54,10 @@ describe('PlanQuotaGuard', () => { it('allows and counts the first 5 creates for a free tenant, blocks the 6th', async () => { await seedTenant('t1', { tier: 'free' }); const g = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: 'https://x/billing' }); - for (let i = 0; i < 5; i++) await g.consumeInspection('t1'); + // The gate counts inspection ROWS, so each consume has to be paired + // with the insert its caller performs — see inspection-quota.spec.ts + // for the same thing through the real service. + for (let i = 0; i < 5; i++) { await g.consumeInspection('t1'); seedInspection('t1', i); } await expect(g.consumeInspection('t1')).rejects.toMatchObject({ status: 402, code: 'QUOTA_EXHAUSTED', @@ -67,11 +80,24 @@ describe('PlanQuotaGuard', () => { expect(await new MeteringService(testD1).lifetimeTotal('t3', 'inspections')).toBe(6); }); - it('is race-safe: the conditional increment never exceeds the cap', async () => { + it('a batch consume admits the whole batch or none of it', async () => { + // Since the gate counts rows the caller has not inserted yet, a + // caller creating N at once passes `count: N` instead of looping — + // otherwise all N calls read the same count and all pass. See + // consumeInspection's `count` parameter. await seedTenant('t4', { tier: 'free' }); const g = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); - const results = await Promise.allSettled(Array.from({ length: 8 }, () => g.consumeInspection('t4'))); - expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(5); + + await g.consumeInspection('t4', 3); + for (let i = 0; i < 3; i++) seedInspection('t4', i); + expect(await new MeteringService(testD1).lifetimeTotal('t4', 'inspections')).toBe(3); + + // 3 + 3 > 5 — refused outright, with nothing partially consumed. + await expect(g.consumeInspection('t4', 3)).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + expect(await new MeteringService(testD1).lifetimeTotal('t4', 'inspections')).toBe(3); + + // 3 + 2 fits exactly. + await expect(g.consumeInspection('t4', 2)).resolves.toBeUndefined(); expect(await new MeteringService(testD1).lifetimeTotal('t4', 'inspections')).toBe(5); }); }); From 95cb50963bc77539f5d78719ad104cc92308d950 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:07:28 +0800 Subject: [PATCH 03/77] test(quota): verify the inspection cap on real D1 under genuine concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit suite runs the guard against better-sqlite3, which is synchronous — Promise.all there overlaps nothing, so it can only assert the statement's logic. Two things are testable only under workerd: - That D1 accepts the statement at all. `INSERT ... SELECT ... WHERE` with an `ON CONFLICT ... DO UPDATE ... WHERE` whose predicate is a correlated subquery over another table, plus `excluded.` inside that predicate, is not a shape D1 code usually reaches for, and `meta.changes === 0` on the no-rows-selected path is the signal the whole gate reads. Confirmed. - What concurrent callers actually observe. At the cap, six genuinely overlapping consumes ALL fail — no phantom pass. Below the cap, two that overlap before either row lands both pass, which pins the bound recorded in guard.ts as real-engine behaviour rather than a test-driver artefact. --- tests/workers/quota-cap-concurrency.spec.ts | 166 ++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/workers/quota-cap-concurrency.spec.ts diff --git a/tests/workers/quota-cap-concurrency.spec.ts b/tests/workers/quota-cap-concurrency.spec.ts new file mode 100644 index 000000000..ae457d8dd --- /dev/null +++ b/tests/workers/quota-cap-concurrency.spec.ts @@ -0,0 +1,166 @@ +// Free-tier inspection cap — real-D1 (workerd/miniflare) coverage. +// +// The unit suite runs the guard against better-sqlite3, which is SYNCHRONOUS: +// `Promise.all` there does not overlap anything, so it can only ever assert the +// statement's logic. Two things are testable only here: +// +// 1. That D1 accepts and runs the statement at all. It is not a shape D1 code +// usually reaches for — `INSERT ... SELECT ... WHERE` with an `ON CONFLICT +// ... DO UPDATE ... WHERE` whose predicate is a correlated subquery over +// ANOTHER table, plus `excluded.` references inside that predicate — and +// `meta.changes === 0` on the no-rows-selected path is exactly the signal +// the whole gate reads. +// 2. What genuinely concurrent callers observe, rather than what a synchronous +// driver lets us pretend they observe. +import { env } from 'cloudflare:test'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { PlanQuotaGuard } from '../../server/features/plan-quota/guard'; +import { FREE_TIER_CAPS } from '../../server/features/plan-quota/policy'; + +interface TestBindings { DB: D1Database } +const b = env as unknown as TestBindings; + +const TENANT = 'tenant-quota'; +const OTHER = 'tenant-quota-other'; +const CAP = FREE_TIER_CAPS.inspections; + +const guard = () => new PlanQuotaGuard(b.DB, { enforced: true, billingPortalUrl: null }); + +async function seedSchema(): Promise { + await b.DB.exec( + "CREATE TABLE IF NOT EXISTS tenants (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL, tier TEXT NOT NULL DEFAULT 'free', created_at INTEGER NOT NULL);", + ); + await b.DB.exec( + 'CREATE TABLE IF NOT EXISTS inspections (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, property_address TEXT, date TEXT, created_at INTEGER NOT NULL);', + ); + await b.DB.exec( + 'CREATE TABLE IF NOT EXISTS usage_counters (tenant_id TEXT NOT NULL, metric TEXT NOT NULL, period_key TEXT NOT NULL, value INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (tenant_id, metric, period_key));', + ); +} + +async function reset(): Promise { + await b.DB.exec('DELETE FROM usage_counters;'); + await b.DB.exec('DELETE FROM inspections;'); + await b.DB.exec('DELETE FROM tenants;'); + await b.DB.prepare('INSERT INTO tenants (id, name, slug, tier, created_at) VALUES (?, ?, ?, ?, ?)') + .bind(TENANT, 'Acme', 'acme', 'free', Date.now()).run(); + await b.DB.prepare('INSERT INTO tenants (id, name, slug, tier, created_at) VALUES (?, ?, ?, ?, ?)') + .bind(OTHER, 'Other', 'other', 'free', Date.now()).run(); +} + +/** The row a caller inserts right after a successful consume. */ +async function addInspections(tenantId: string, n: number): Promise { + const existing = await rowCount(tenantId); + for (let i = 0; i < n; i++) { + await b.DB.prepare( + "INSERT INTO inspections (id, tenant_id, property_address, date, created_at) VALUES (?, ?, '1 Main St', '2026-08-05', ?)", + ).bind(`${tenantId}-insp-${existing + i}`, tenantId, Date.now()).run(); + } +} + +async function rowCount(tenantId: string): Promise { + const r = await b.DB.prepare('SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?') + .bind(tenantId).first<{ n: number }>(); + return r?.n ?? 0; +} + +async function counter(tenantId: string): Promise { + const r = await b.DB.prepare( + "SELECT value FROM usage_counters WHERE tenant_id = ? AND metric = 'inspections' AND period_key = 'lifetime'", + ).bind(tenantId).first<{ value: number }>(); + return r?.value ?? null; +} + +describe('free-tier inspection cap on real D1', () => { + beforeAll(seedSchema); + beforeEach(reset); + + it('runs the statement and gates on the row count', async () => { + await addInspections(TENANT, CAP - 1); + await expect(guard().consumeInspection(TENANT)).resolves.toBeUndefined(); + expect(await counter(TENANT)).toBe(CAP); + + await addInspections(TENANT, 1); // the caller's insert lands + await expect(guard().consumeInspection(TENANT)).rejects.toMatchObject({ + status: 402, code: 'QUOTA_EXHAUSTED', + }); + }); + + it('gates the INSERT branch too — over cap with no counter row is still refused', async () => { + // The reason the statement is `INSERT ... SELECT ... WHERE` and not + // `INSERT ... VALUES`: a conflict-free INSERT never reaches DO UPDATE, + // so its WHERE cannot refuse anyone. + await addInspections(TENANT, CAP + 4); + expect(await counter(TENANT)).toBeNull(); + + await expect(guard().consumeInspection(TENANT)).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + expect(await counter(TENANT)).toBeNull(); // nothing was written at all + }); + + it('excluded.tenant_id resolves per tenant inside DO UPDATE ... WHERE', async () => { + // A sibling tenant's rows must not count toward this one's cap. If + // `excluded.` were out of scope here D1 would raise, not miscount. + await addInspections(OTHER, CAP + 10); + await addInspections(TENANT, 1); + await guard().consumeInspection(TENANT); // creates the counter row + await addInspections(TENANT, 1); + await expect(guard().consumeInspection(TENANT)).resolves.toBeUndefined(); // UPDATE branch + expect(await counter(TENANT)).toBe(3); + }); + + it('a stale counter cannot block a tenant under the cap — the production defect', async () => { + // 2026-08-05: one tenant had a single inspection row and a counter of 4. + await addInspections(TENANT, 1); + await b.DB.prepare( + "INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at) VALUES (?, 'inspections', 'lifetime', ?, ?)", + ).bind(TENANT, CAP - 1, Date.now()).run(); + + await expect(guard().consumeInspection(TENANT)).resolves.toBeUndefined(); + expect(await counter(TENANT)).toBe(2); // healed down to the truth + }); + + it('AT the cap, genuinely concurrent consumes ALL fail — no phantom pass', async () => { + // Real overlap: workerd runs these D1 calls as genuine concurrent async + // I/O, unlike the synchronous better-sqlite3 unit harness. + await addInspections(TENANT, CAP); + const results = await Promise.allSettled( + Array.from({ length: 6 }, () => guard().consumeInspection(TENANT)), + ); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(0); + expect(await counter(TENANT)).toBeNull(); + }); + + it('a batch is admitted whole or not at all under real overlap', async () => { + await addInspections(TENANT, 3); + // 3 + 3 > 5 — both concurrent batch attempts must be refused outright; + // neither may consume part of its batch. + const results = await Promise.allSettled([ + guard().consumeInspection(TENANT, 3), guard().consumeInspection(TENANT, 3), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(0); + expect(await counter(TENANT)).toBeNull(); + + // 3 + 2 fits exactly. + await expect(guard().consumeInspection(TENANT, 2)).resolves.toBeUndefined(); + expect(await counter(TENANT)).toBe(CAP); + }); + + it('the documented bound: consumes overlapping BEFORE their rows land both pass', async () => { + // Pinned deliberately rather than left to be discovered. Counting rows + // makes the cap a steady-state invariant: the caller inserts its row + // after consumeInspection returns, so overlapping creates read the same + // count. Real workerd concurrency, so this is the true behaviour and not + // an artefact of a synchronous test driver. + await addInspections(TENANT, CAP - 1); + const results = await Promise.allSettled([ + guard().consumeInspection(TENANT), guard().consumeInspection(TENANT), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(2); + + // ...and it self-corrects: once both rows exist the tenant is over the + // cap and cannot grow further. + await addInspections(TENANT, 2); + expect(await rowCount(TENANT)).toBe(CAP + 1); + await expect(guard().consumeInspection(TENANT)).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' }); + }); +}); From c14c4207dcacedeeb8638e4a70947df25930f103 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:11:59 +0800 Subject: [PATCH 04/77] docs(quota)+fix(usage): the counter is a cache, and /api/usage says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires the 2026-08-05 manual production correction rather than leaving it to be repeated. guard.ts header now records that `usage_counters.value` for `inspections` is a cache and not the gate, so the next person who finds a value higher than the row count does not "fix" it. Deleting the row is worse than leaving it, and the header says why. sms/email are called out as the opposite case: consumed events whose counters ARE the source of truth. /api/usage decision (plan Task 4 Step 2), taken rather than left open: a tenant whose inspections are capped now gets the live row count — the number shown against a cap has to be the number the cap is enforced against, or a tenant who deletes three inspections is told "5 of 5 used" while creating works, which is the visible half of the defect. Uncapped tenants keep the cumulative lifetime counter: with `caps: null` it is measured against nothing, and silently redefining it from "ever created" to "currently have" would change an analytics figure nobody asked to change. --- server/api/usage.ts | 21 ++++++++++- server/features/plan-quota/guard.ts | 10 ++++++ tests/unit/usage/usage-summary-api.spec.ts | 42 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/server/api/usage.ts b/server/api/usage.ts index f3ba3b5f6..1bb7d0981 100644 --- a/server/api/usage.ts +++ b/server/api/usage.ts @@ -17,10 +17,13 @@ * directly rather than read off the context. */ import { createRoute } from '@hono/zod-openapi'; +import { drizzle } from 'drizzle-orm/d1'; +import { count, eq } from 'drizzle-orm'; import { createApiRouter } from '../lib/openapi-router'; import { Errors } from '../lib/errors'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { MeteringService } from '../services/metering.service'; +import { inspections as inspectionsTable } from '../lib/db/schema'; import { getSeatUsage } from '../features/seat-quota'; import { readTenantTier } from '../features/plan-quota/guard'; import { FREE_TIER_CAPS } from '../features/plan-quota/policy'; @@ -68,13 +71,29 @@ const usageRoutes = createApiRouter() const isFreeTierQuota = tier === 'free' && c.var.profile.hasUsageQuota; + // The number shown against a cap must be the number the cap is enforced + // against. `PlanQuotaGuard.consumeInspection` gates on the inspection + // ROWS a tenant has, and `usage_counters.value` is only a self-healing + // cache of that — it reads stale-high between a delete and the next + // create, which would tell a tenant "5 of 5 used" while creating still + // works. So capped tenants get the live count. + // + // Everyone else keeps the cumulative lifetime counter: with `caps: null` + // it is measured against nothing, and quietly redefining it from "how + // many they have ever created" to "how many they have now" would change + // an analytics figure nobody asked to change. + const inspectionsUsed = isFreeTierQuota + ? (await drizzle(c.env.DB).select({ n: count() }).from(inspectionsTable) + .where(eq(inspectionsTable.tenantId, tenantId)).get())?.n ?? 0 + : inspections; + return c.json({ success: true as const, data: { tier, caps: isFreeTierQuota ? FREE_TIER_CAPS : null, usage: { - inspections, sms, email, + inspections: inspectionsUsed, sms, email, smsByo, emailByo, aiTranslate, aiTranslateByo, aiAssist, aiAssistByo, diff --git a/server/features/plan-quota/guard.ts b/server/features/plan-quota/guard.ts index c449ea113..1582c65f0 100644 --- a/server/features/plan-quota/guard.ts +++ b/server/features/plan-quota/guard.ts @@ -16,6 +16,16 @@ import { FREE_TIER_CAPS, type AiCappedMetric, type AiTierCaps } from './policy'; * The actual meter increment stays at the existing send-site call (see * MeteringService.record in the sms/email pipelines) so a provider failure * never consumes quota it didn't actually spend. + * + * IMPORTANT — `usage_counters.value` for the `inspections` metric is a CACHE, + * not the gate. The cap counts the inspection rows a tenant has; the counter is + * written alongside it and heals itself on the next create. A value that looks + * wrong (higher than the row count, e.g. after a delete) is therefore not a + * defect and must NOT be "corrected" by hand — that is what the 2026-08-05 + * production hand-fix did, and it is exactly what this design makes + * unnecessary. Deleting the row is worse than leaving it: see the INSERT-branch + * gating below. `sms`/`email` are the opposite — consumed events with nothing + * to count, so their counters ARE the source of truth. */ /** * One-line tenant-tier lookup, defaulting to 'free' when the row is missing diff --git a/tests/unit/usage/usage-summary-api.spec.ts b/tests/unit/usage/usage-summary-api.spec.ts index 4d7240c57..33de42efb 100644 --- a/tests/unit/usage/usage-summary-api.spec.ts +++ b/tests/unit/usage/usage-summary-api.spec.ts @@ -72,6 +72,17 @@ describe('GET /api/usage/summary', () => { }); } + /** The `inspections` figure a capped tenant sees is the live row count, not + * the counter — see the comment in server/api/usage.ts. */ + async function seedInspections(tenantId: string, n: number) { + for (let i = 0; i < n; i++) { + await testDb.insert(schema.inspections).values({ + id: `${tenantId}-insp-${i}`, tenantId, propertyAddress: '1 Main St', + date: '2026-08-05', createdAt: new Date(), + }); + } + } + async function seedUser(tenantId: string, id: string) { await testDb.insert(users).values({ id, tenantId, email: `${id}@example.test`, passwordHash: 'x', createdAt: new Date(), @@ -82,6 +93,7 @@ describe('GET /api/usage/summary', () => { await seedTenant(TENANT, { tier: 'free', maxUsers: 5 }); await seedUser(TENANT, 'u1'); await seedUser(TENANT, 'u2'); + await seedInspections(TENANT, 3); const m = new MeteringService(testD1); await m.record(TENANT, 'inspections', 'lifetime', 3); await m.record(TENANT, 'sms', '2026-06', 10); @@ -115,6 +127,36 @@ describe('GET /api/usage/summary', () => { }); }); + it('a capped tenant sees the ROWS they have, not a stale counter', async () => { + // The production state on 2026-08-05: a counter of 4 against a single + // inspection. Reporting the counter would tell a tenant who deleted + // three inspections that they are still at 4 of 5 while creating works. + await seedTenant(TENANT, { tier: 'free' }); + await seedInspections(TENANT, 1); + await new MeteringService(testD1).record(TENANT, 'inspections', 'lifetime', 4); + + const app = buildApp(testDb, SAAS_PROFILE); + const env = { DB: testD1 } as unknown as HonoConfig['Bindings']; + const res = await app.request('/api/usage/summary', {}, env, makeExecCtx()); + const body = await res.json() as { data: { usage: { inspections: number } } }; + expect(body.data.usage.inspections).toBe(1); + }); + + it('an UNcapped tenant keeps the cumulative lifetime counter', async () => { + // With `caps: null` the figure is measured against nothing, so it stays + // "how many they have ever created" — redefining it to a row count would + // silently change an analytics number for paid tiers. + await seedTenant(TENANT, { tier: 'pro' }); + await seedInspections(TENANT, 1); + await new MeteringService(testD1).record(TENANT, 'inspections', 'lifetime', 9); + + const app = buildApp(testDb, SAAS_PROFILE); + const env = { DB: testD1 } as unknown as HonoConfig['Bindings']; + const res = await app.request('/api/usage/summary', {}, env, makeExecCtx()); + const body = await res.json() as { data: { usage: { inspections: number } } }; + expect(body.data.usage.inspections).toBe(9); + }); + it('caps is null for a pro tenant even on a hasUsageQuota profile', async () => { await seedTenant(TENANT, { tier: 'pro' }); const app = buildApp(testDb, SAAS_PROFILE); From 97635a6a05357c5a2e39c8a408e5507687399d79 Mon Sep 17 00:00:00 2001 From: important-new Date: Wed, 5 Aug 2026 23:29:56 +0800 Subject: [PATCH 05/77] fix(usage): route the inspection count through getDrizzle lint:provider-helpers hard-fails on `drizzle(c.env.DB)` in an API route; route handlers go through the helper. --- server/api/usage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/usage.ts b/server/api/usage.ts index 1bb7d0981..fa939ec01 100644 --- a/server/api/usage.ts +++ b/server/api/usage.ts @@ -17,9 +17,9 @@ * directly rather than read off the context. */ import { createRoute } from '@hono/zod-openapi'; -import { drizzle } from 'drizzle-orm/d1'; import { count, eq } from 'drizzle-orm'; import { createApiRouter } from '../lib/openapi-router'; +import { getDrizzle } from '../lib/route-helpers'; import { Errors } from '../lib/errors'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { MeteringService } from '../services/metering.service'; @@ -83,7 +83,7 @@ const usageRoutes = createApiRouter() // many they have ever created" to "how many they have now" would change // an analytics figure nobody asked to change. const inspectionsUsed = isFreeTierQuota - ? (await drizzle(c.env.DB).select({ n: count() }).from(inspectionsTable) + ? (await getDrizzle(c).select({ n: count() }).from(inspectionsTable) .where(eq(inspectionsTable.tenantId, tenantId)).get())?.n ?? 0 : inspections; From 0cb37ae8d3b20eb6d60f9af50eadbc7b076f480a Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 00:02:03 +0800 Subject: [PATCH 06/77] fix(qbo): book payments on the date the money moved, in the tenant zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordPayment stamped TxnDate with the push date (UTC today), discarding the ledger row's occurred_at — an inspector recording Tuesday's cash on Thursday booked it into the wrong accounting period. TxnDate is a bare calendar date, so it is now derived from occurred_at in the TENANT's timezone (epochMsToWallClockYmd + resolveTenantTimeZone): a 6pm Pacific payment is the next day in UTC, a real one-day period error at month end. All three push sites (mark-paid, offline recording, Stripe webhook) pass the appended ledger row's occurredAt. The check-tz-safety header no longer names QBO payment TxnDate as a legitimate UTC-today use. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- scripts/check-tz-safety.mjs | 9 ++++-- scripts/file-size-baseline.json | 2 +- server/api/invoices.ts | 2 ++ server/api/stripe-webhook.ts | 1 + server/services/qbo/invoice-sync.ts | 18 ++++++++++- tests/unit/qbo/payment-push.spec.ts | 48 +++++++++++++++++++++++------ 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/scripts/check-tz-safety.mjs b/scripts/check-tz-safety.mjs index aaf504b89..85275c22d 100644 --- a/scripts/check-tz-safety.mjs +++ b/scripts/check-tz-safety.mjs @@ -9,9 +9,12 @@ * and string-keyed cells (civilDateOf), never Date/UTC math in the views. * * SCOPED to the calendar surface on purpose: every real bug lives here, while - * legitimate `.toISOString().slice(0,10)` uses (server UTC-today, QBO TxnDate, - * report year) live elsewhere. A line opts out with a trailing — or immediately - * preceding — `// tz-lint-ok: ` comment. + * legitimate `.toISOString().slice(0,10)` uses (server UTC-today, report year, + * QBO document-creation dates) live elsewhere. QBO *payment* TxnDate is NOT on + * that list any more: it books an accounting period, so it derives from the + * ledger row's occurred_at in the tenant zone via epochMsToWallClockYmd (see + * recordPayment in server/services/qbo/invoice-sync.ts). A line opts out with a + * trailing — or immediately preceding — `// tz-lint-ok: ` comment. * * Flags: * P1 hardcoded-Z instant composed from a civil date + wall-clock time diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 9f95b9c9a..3275e40f7 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -34,7 +34,7 @@ "app/routes/settings-profile.tsx": 548, "server/api/calendar.ts": 547, "app/components/NewInspectionWizard.tsx": 539, - "server/api/invoices.ts": 533, + "server/api/invoices.ts": 535, "server/services/inspection/inspection-photo.service.ts": 531, "server/api/inspections/media-studio.ts": 530, "app/routes/settings-communication-templates.tsx": 525, diff --git a/server/api/invoices.ts b/server/api/invoices.ts index f1ab69d2b..24e4fa923 100644 --- a/server/api/invoices.ts +++ b/server/api/invoices.ts @@ -296,6 +296,7 @@ const invoiceRoutes = createApiRouter() c.executionCtx.waitUntil( c.var.services.qbo.recordPayment( tenantId, id, appended.amountCents / 100, qboPaymentKey(appended.id), + appended.occurredAt, ), ); } @@ -336,6 +337,7 @@ const invoiceRoutes = createApiRouter() c.executionCtx.waitUntil( c.var.services.qbo.recordPayment( tenantId, id, appended.amountCents / 100, qboPaymentKey(appended.id), + appended.occurredAt, ), ); } diff --git a/server/api/stripe-webhook.ts b/server/api/stripe-webhook.ts index dabb4bf19..44100eef6 100644 --- a/server/api/stripe-webhook.ts +++ b/server/api/stripe-webhook.ts @@ -117,6 +117,7 @@ api.post('/', async (c) => { try { await c.var.services.qbo.recordPayment( tenantId, settled.invoiceId, push.amountCents / 100, qboPaymentKey(push.id), + push.occurredAt, ); } catch (e) { logger.error('Stripe webhook: QBO payment push failed', diff --git a/server/services/qbo/invoice-sync.ts b/server/services/qbo/invoice-sync.ts index ccc982612..dea835678 100644 --- a/server/services/qbo/invoice-sync.ts +++ b/server/services/qbo/invoice-sync.ts @@ -1,6 +1,8 @@ import { eq, and } from 'drizzle-orm'; import { qboConnections, qboEntityMap } from '../../lib/db/schema/qbo'; import { invoices } from '../../lib/db/schema/invoice'; +import { tenantConfigs } from '../../lib/db/schema/tenant'; +import { epochMsToWallClockYmd, resolveTenantTimeZone } from '../../lib/tz'; import { logger } from '../../lib/logger'; import { getLedgerOpinion } from '../payment-ledger.service'; import type { @@ -237,6 +239,7 @@ export function withInvoiceSync>(Base: */ async recordPayment( tenantId: string, invoiceId: string, amountPaid: number, idempotencyKey: string, + occurredAt: Date, ): Promise { const db = this.getDrizzle(); const invoiceMap = await db.select().from(qboEntityMap).where( @@ -250,11 +253,24 @@ export function withInvoiceSync>(Base: return; } + // TxnDate is a calendar date with no timezone: QuickBooks books it + // into an accounting period as-is. It must be the day the money + // MOVED (the ledger row's occurred_at — the ledger separates it + // from created_at because Tuesday's cash gets recorded Thursday), + // in the TENANT's zone: a payment taken at 6pm Pacific is the same + // civil day locally and the next day in UTC, a real one-day period + // error at month end. + const cfg = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const txnDate = epochMsToWallClockYmd( + occurredAt.getTime(), resolveTenantTimeZone(cfg?.defaultTimezone), + ); + try { await this.apiCall(tenantId, 'POST', `payment?requestid=${encodeURIComponent(idempotencyKey)}`, { CustomerRef: { value: qboCustomerId }, TotalAmt: amountPaid, - TxnDate: new Date().toISOString().slice(0, 10), + TxnDate: txnDate, Line: [{ Amount: amountPaid, LinkedTxn: [{ TxnId: invoiceMap.qboId, TxnType: 'Invoice' }] }], }); } catch (e) { diff --git a/tests/unit/qbo/payment-push.spec.ts b/tests/unit/qbo/payment-push.spec.ts index 69b344e09..a3b1f8875 100644 --- a/tests/unit/qbo/payment-push.spec.ts +++ b/tests/unit/qbo/payment-push.spec.ts @@ -36,22 +36,25 @@ interface Call { method: string; path: string; body: unknown } * that rather than standing up sqlite, because what this spec is about is the * request we build, not the join that finds the id. */ -function stubDb(qboId: string | null) { +function stubDb(qboId: string | null, defaultTimezone?: string) { const chain = { select: () => chain, from: () => chain, where: () => chain, - get: async () => (qboId == null ? undefined : { qboId }), + get: async () => (qboId == null ? undefined : { qboId, defaultTimezone }), }; return chain; } class ProbeQbo extends withInvoiceSync(QBOServiceBase) { calls: Call[] = []; - constructor(private readonly mappedQboId: string | null = 'QBO-INV-1') { + constructor( + private readonly mappedQboId: string | null = 'QBO-INV-1', + private readonly tenantTz?: string, + ) { super({} as never, 'cid', 'secret', 'whsec', 'jwt'); } - protected override getDrizzle() { return stubDb(this.mappedQboId) as never; } + protected override getDrizzle() { return stubDb(this.mappedQboId, this.tenantTz) as never; } protected override async getQBOCustomerIdForInvoice(): Promise { return 'QBO-CUST-9'; } protected override async apiCall( _tenantId: string, method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, @@ -64,10 +67,13 @@ class ProbeQbo extends withInvoiceSync(QBOServiceBase) { const requestIdOf = (path: string) => new URLSearchParams(path.slice(path.indexOf('?') + 1)).get('requestid'); +/** The ledger row's occurred_at — any fixed instant will do for these probes. */ +const OCCURRED = new Date('2026-03-01T10:00:00Z'); + describe('recordPayment → QuickBooks', () => { it('carries a requestid derived from the OI record, not a random uuid', async () => { const qbo = new ProbeQbo(); - await qbo.recordPayment('t1', 'inv-abc12345', 450, 'pay-inv-abc12345'); + await qbo.recordPayment('t1', 'inv-abc12345', 450, 'pay-inv-abc12345', OCCURRED); expect(qbo.calls).toHaveLength(1); expect(qbo.calls[0].path.startsWith('payment')).toBe(true); @@ -76,8 +82,8 @@ describe('recordPayment → QuickBooks', () => { it('sends the same key twice for the same fact — QBO collapses the second', async () => { const qbo = new ProbeQbo(); - await qbo.recordPayment('t1', 'inv-abc12345', 450, 'pay-inv-abc12345'); - await qbo.recordPayment('t1', 'inv-abc12345', 450, 'pay-inv-abc12345'); + await qbo.recordPayment('t1', 'inv-abc12345', 450, 'pay-inv-abc12345', OCCURRED); + await qbo.recordPayment('t1', 'inv-abc12345', 450, 'pay-inv-abc12345', OCCURRED); expect(qbo.calls).toHaveLength(2); // both attempted expect(new Set(qbo.calls.map((c) => requestIdOf(c.path))).size).toBe(1); // one key @@ -85,7 +91,7 @@ describe('recordPayment → QuickBooks', () => { it('still posts the amount and the invoice link', async () => { const qbo = new ProbeQbo(); - await qbo.recordPayment('t1', 'inv-1', 450, 'pay-inv-1'); + await qbo.recordPayment('t1', 'inv-1', 450, 'pay-inv-1', OCCURRED); const body = qbo.calls[0].body as { TotalAmt: number; CustomerRef: { value: string }; @@ -98,9 +104,31 @@ describe('recordPayment → QuickBooks', () => { it('pushes nothing when the invoice has no QBO mapping', async () => { const qbo = new ProbeQbo(null); - await qbo.recordPayment('t1', 'inv-1', 450, 'pay-inv-1'); + await qbo.recordPayment('t1', 'inv-1', 450, 'pay-inv-1', new Date('2026-09-08T00:00:00Z')); expect(qbo.calls).toHaveLength(0); }); + + // TxnDate is a calendar date with no timezone: QuickBooks books it into an + // accounting period as-is. The ledger separates occurred_at from created_at + // because an inspector records Tuesday's cash on Thursday — the push date is + // the wrong accounting period. An occurredAt far from "now" is deliberate: + // a test that passes on today's date proves nothing. + it('books the payment on the date the money moved, not the push date', async () => { + const qbo = new ProbeQbo(); + await qbo.recordPayment( + 't1', 'inv-1', 200, 'pay-row-1', + new Date('2026-09-08T00:00:00Z'), // Tuesday; pushed some Thursday + ); + expect((qbo.calls[0].body as { TxnDate: string }).TxnDate).toBe('2026-09-08'); + }); + + it("derives the calendar date in the tenant's timezone, not UTC", async () => { + // 01:00 UTC on the 8th is still the evening of the 7th in Los Angeles — + // a payment taken at 6pm Pacific belongs to the 7th's books. + const qbo = new ProbeQbo('QBO-INV-1', 'America/Los_Angeles'); + await qbo.recordPayment('t1', 'inv-1', 200, 'pay-row-1', new Date('2026-09-08T01:00:00Z')); + expect((qbo.calls[0].body as { TxnDate: string }).TxnDate).toBe('2026-09-07'); + }); }); // --- the Stripe webhook must reach it at all ------------------------------ @@ -160,7 +188,7 @@ describe('a card payment reaches QuickBooks', () => { await Promise.all(settled); expect(res.status).toBe(200); - expect(recordPayment).toHaveBeenCalledWith('tA', 'inv-1', 450, 'pay-row-9'); + expect(recordPayment).toHaveBeenCalledWith('tA', 'inv-1', 450, 'pay-row-9', APPENDED.occurredAt); }); it('does not push when QuickBooks is not connected', async () => { From b0e0041f4f024d5c9615db185e4d7361922f1406 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 00:12:21 +0800 Subject: [PATCH 07/77] fix(services): refuse to remove a service line that a report delivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeInspectionService soft-deleted unconditionally. The deferral written at the function came due when the reports table landed: with no FKs by design, nothing else surfaces a report left pointing at a line that is no longer on the invoice. Removal now returns 409 Conflict naming the blocking report (in_progress and published both block — both are work the line paid for); reports on OTHER lines do not block. The pay-split clause stays deferred to the pay-splits plan as its own explicit step, since inspection_service_pay_splits still does not exist. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- server/api/inspections/services.ts | 3 +- server/lib/mcp/openapi-snapshot.json | 2 +- server/services/service.service.ts | 35 ++++++++++++++----- .../service-line-lifecycle.spec.ts | 34 ++++++++++++++++++ 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/server/api/inspections/services.ts b/server/api/inspections/services.ts index b1f3cb02b..1d107eb2a 100644 --- a/server/api/inspections/services.ts +++ b/server/api/inspections/services.ts @@ -86,9 +86,10 @@ const inspectionServiceRoutes = createApiRouter() request: { params: LineParam }, responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema } }, description: 'Line removed' }, + 409: { description: 'Refused: a report delivers this line, and removing it would strand that report' }, }, operationId: 'removeInspectionService', - description: 'Removes one booked service line from an inspection. Does not touch the tenant service catalog.', + description: 'Removes one booked service line from an inspection. Does not touch the tenant service catalog. Refuses with 409 when a report (in progress or published) delivers this line.', }, { scopes: ['write'], tier: 'primary' })), async (c) => { const tenantId = getTenantId(c); const { id, lineId } = c.req.valid('param'); diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index bc3b487d1..10ab6961d 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -15782,7 +15782,7 @@ "body": null }, "summary": "Remove a service line from an inspection", - "description": "Removes one booked service line from an inspection. Does not touch the tenant service catalog." + "description": "Removes one booked service line from an inspection. Does not touch the tenant service catalog. Refuses with 409 when a report (in progress or published) delivers this line." }, { "operationId": "removeRepairItem", diff --git a/server/services/service.service.ts b/server/services/service.service.ts index 1781b55e1..96d885935 100644 --- a/server/services/service.service.ts +++ b/server/services/service.service.ts @@ -1,6 +1,6 @@ import { drizzle } from 'drizzle-orm/d1'; import { eq, and, asc, inArray, sql } from 'drizzle-orm'; -import { services, inspectionServices, discountCodes, inspections, eventTypes } from '../lib/db/schema'; +import { services, inspectionServices, discountCodes, inspections, eventTypes, reports } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { getServiceInspectors, setServiceInspectors } from './service/qualification'; import { nanoid } from 'nanoid'; @@ -214,13 +214,17 @@ export class ServiceService { * the door — and so a `reports` row or a pay split that points at this line * still finds it. * - * The REFUSAL half of this guard is deliberately not stubbed here. Neither - * `reports` nor `inspection_service_pay_splits` exists yet, so a check - * against them would be a function that always returns "nothing blocks - * this" — a gate that passes vacuously, which this repo has shipped before. - * Each of those tasks adds its own clause, with a test, when it creates the - * table. What lands now is the column and the soft delete, which is what - * has to exist BEFORE the first row points here. + * The REFUSAL half of the guard: removal is allowed while a line is bare, + * and refused once something hangs off it that a soft delete would strand. + * Nothing here carries an FK (by design), so nothing else surfaces the + * dangle — this check is the only thing standing between a scope change at + * the door and a report delivering a line that is no longer on the invoice. + * + * Today that is a `reports` row (in_progress or published — both block: + * both are work the line paid for). The pay-split clause stays deferred to + * the pay-splits plan as its own explicit step — `inspection_service_pay_ + * splits` still does not exist, and a check against a table that does not + * exist is a gate that passes vacuously. */ async removeInspectionService(tenantId: string, inspectionId: string, lineId: string) { const db = this.getDrizzle(); @@ -234,6 +238,21 @@ export class ServiceService { .get(); if (!line || !line.active) throw Errors.NotFound('Service line not found'); + const blockingReport = await db.select({ id: reports.id, status: reports.status }) + .from(reports) + .where(and( + eq(reports.tenantId, tenantId), + eq(reports.inspectionServiceId, lineId), + )) + .limit(1).get(); + if (blockingReport) { + // The refusal names what blocked it — the UI disables the control + // with this reason rather than a silent no-op. + throw Errors.Conflict( + `Cannot remove this service line: a report (${blockingReport.status}) delivers it. Delete the report first.`, + ); + } + await db.update(inspectionServices).set({ active: false }) .where(and( eq(inspectionServices.id, lineId), diff --git a/tests/unit/inspections/service-line-lifecycle.spec.ts b/tests/unit/inspections/service-line-lifecycle.spec.ts index 25447bb65..4f7c38b16 100644 --- a/tests/unit/inspections/service-line-lifecycle.spec.ts +++ b/tests/unit/inspections/service-line-lifecycle.spec.ts @@ -112,6 +112,40 @@ describe('inspection service lines — scope changes at the door', () => { expect(await svc.getInspectionServices(TENANT, INSP)).toHaveLength(1); }); + it('refuses to remove a line that a report delivers, and says so', async () => { + // The deferral written at removeInspectionService came due when the + // `reports` table landed: a soft delete would leave the report pointing + // at a line that is no longer on the invoice, and nothing surfaces it + // (no FKs by design). The refusal must NAME what blocked it. + const line = await svc.addInspectionService(TENANT, INSP, SVC); + await db.insert(schema.reports).values({ + id: 'rep-sewer-1', tenantId: TENANT, inspectionId: INSP, + kind: 'ancillary', inspectionServiceId: line!.id, + title: 'Sewer Scope', status: 'in_progress', createdAt: new Date(), + }); + + await expect(svc.removeInspectionService(TENANT, INSP, line!.id)) + .rejects.toThrow(/report/i); + + // Refused means untouched — not a silent no-op that flipped the flag. + const rows = await lineRows(); + expect(rows[0].active).toBe(true); + }); + + it('still removes a line whose reports belong to other lines', async () => { + // The block is the line's OWN report. A published primary report for the + // main inspection must not freeze every other line on the order. + const line = await svc.addInspectionService(TENANT, INSP, SVC); + await db.insert(schema.reports).values({ + id: 'rep-primary-1', tenantId: TENANT, inspectionId: INSP, + kind: 'primary', inspectionServiceId: null, + title: 'Home Inspection', status: 'published', createdAt: new Date(), + }); + + await svc.removeInspectionService(TENANT, INSP, line!.id); + expect((await lineRows())[0].active).toBe(false); + }); + it('never touches another tenant\'s line', async () => { const line = await svc.addInspectionService(TENANT, INSP, SVC); await expect(svc.removeInspectionService('other-tenant', INSP, line!.id)) From 44cd4297a32c889b4706275fdac8b9449d6784c2 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 00:15:33 +0800 Subject: [PATCH 08/77] feat(ai): expose GET /api/integration/ai-provisioning for portal's tier console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-tier tenant counts bucketed managed/byo/unconfigured by the SAME resolveAi call the runtime meters with — resolveRuntimeAiSource is extracted from buildAiMeter so the still-false entitlement literal lives in exactly one place and the console can never disagree with the resolver. Wire contract pinned by portal's narrowAiProvisioning: a tier with no tenants is absent, never zeroed; all three counts finite. The /usage handler moves verbatim to server/portal/usage-report.ts — the file-size ratchet asked for an extracted unit, not a baseline bump. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- server/lib/ai/metering.ts | 48 ++++++-- server/lib/ai/resolve-provider.ts | 2 +- server/portal/ai-provisioning.ts | 65 +++++++++++ server/portal/integration.routes.ts | 53 ++------- server/portal/usage-report.ts | 52 +++++++++ .../integration-ai-provisioning.spec.ts | 108 ++++++++++++++++++ 6 files changed, 274 insertions(+), 54 deletions(-) create mode 100644 server/portal/ai-provisioning.ts create mode 100644 server/portal/usage-report.ts create mode 100644 tests/unit/integrations/integration-ai-provisioning.spec.ts diff --git a/server/lib/ai/metering.ts b/server/lib/ai/metering.ts index ea5264fb5..fd47bba37 100644 --- a/server/lib/ai/metering.ts +++ b/server/lib/ai/metering.ts @@ -15,26 +15,28 @@ */ import { MeteringService } from '../../services/metering.service'; import { aiUsageMetric, currentPeriodKey, type AiUsageKind } from '../usage/period'; -import { resolveAi } from './resolve-provider'; +import { resolveAi, type AiCredentialSource } from './resolve-provider'; import type { DeploymentProfile } from '../deployment-profile'; export interface AiMeter { record(kind: AiUsageKind): Promise; } -export function buildAiMeter(args: { - db: D1Database; +/** + * The runtime's answer to "whose credentials would this tenant's AI call run + * on?" — 'managed', 'byo', or null for "the feature is off / unconfigured". + * + * This is the ONE place the not-yet-granted entitlement literal lives, shared + * by the meter below and by `GET /api/integration/ai-provisioning` (portal's + * provisioning console read), so the count portal renders and the metric the + * meter tags can never come from two resolvers that disagree. + */ +export function resolveRuntimeAiSource(args: { 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; - +}): AiCredentialSource | null { const resolved = resolveAi({ profile: args.profile, tenantKey: args.tenantKey, @@ -48,7 +50,31 @@ export function buildAiMeter(args: { underCap: true, model: args.model, }); - const source = resolved?.source ?? 'byo'; + return resolved?.source ?? null; +} + +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; + + // null (feature off / unconfigured) tags as 'byo': an unresolvable call + // never reaches a provider, so the tag only matters for the defensive case + // — and the defensive choice is the metric that is the tenant's own bill. + const source = resolveRuntimeAiSource({ + profile: args.profile, + tenantKey: args.tenantKey, + managedKey: args.managedKey, + model: args.model, + }) ?? 'byo'; const metering = new MeteringService(db); return { diff --git a/server/lib/ai/resolve-provider.ts b/server/lib/ai/resolve-provider.ts index 2f9478799..ec9894b00 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. */ -type AiCredentialSource = 'managed' | 'byo'; +export type AiCredentialSource = 'managed' | 'byo'; export interface ResolvedAi { provider: AiProvider; diff --git a/server/portal/ai-provisioning.ts b/server/portal/ai-provisioning.ts new file mode 100644 index 000000000..9cd7efc3b --- /dev/null +++ b/server/portal/ai-provisioning.ts @@ -0,0 +1,65 @@ +import type { Context } from 'hono'; +import { drizzle } from 'drizzle-orm/d1'; +import type { HonoConfig } from '../types/hono'; +import { tenants } from '../lib/db/schema'; +import { logger } from '../lib/logger'; +import { loadTenantSecrets } from '../lib/secrets-cache'; +import { resolveRuntimeAiSource } from '../lib/ai/metering'; +import { getDeploymentProfile } from '../lib/deployment-profile'; + +/** + * GET /api/integration/ai-provisioning — AI provisioning status for portal's + * tier-quota console (managed-ai Task 5 follow-up (a)). + * + * Per tier, how many tenants would resolve to managed / BYO / unconfigured + * credentials RIGHT NOW. The bucketing is `resolveRuntimeAiSource` — the same + * `resolveAi` call, with the same still-false entitlement literal, that tags + * the usage meter — so this endpoint cannot drift from what the runtime would + * actually do (portal deliberately stores nothing and asks on every read). + * + * Wire contract (pinned by portal's `narrowAiProvisioning`): a tier with no + * tenants is ABSENT from `tiers`, never a zeroed row — portal renders absent + * as "core did not mention it". All three counts are finite numbers. The + * managed bucket is 0 everywhere until entitlement ships; that is the truth, + * not a gap. + * + * Cost note: one secrets read (KV-cached ciphertext + decrypt) per tenant per + * request. This is a console read, not a hot path; revisit only if tenant + * count makes it one. + */ +export async function aiProvisioningHandler(c: Context) { + try { + const profile = getDeploymentProfile(c.env); + const managedKey = c.env.AI_MANAGED_API_KEY ?? null; + const model = c.env.AI_MODEL ?? ''; + const rows = await drizzle(c.env.DB) + .select({ id: tenants.id, tier: tenants.tier }) + .from(tenants) + .all(); + + const tiers: Record = {}; + for (const t of rows) { + // Undecryptable/absent secrets → no tenant key, exactly as the + // runtime email/AI construction treats it (loadEmailSecrets + // swallows the same throw): the tenant resolves unconfigured + // rather than failing the whole report. + const dec = await loadTenantSecrets( + c.env.DB, c.env.TENANT_CACHE, t.id as string, c.env.JWT_SECRET, c.env.JWT_SECRET_PREVIOUS, + ).catch(() => null); + const source = resolveRuntimeAiSource({ + profile, + tenantKey: dec?.GEMINI_API_KEY || null, + managedKey, + model, + }); + const tier = t.tier as string; + const bucket = (tiers[tier] ??= { managed: 0, byo: 0, unconfigured: 0 }); + bucket[source ?? 'unconfigured'] += 1; + } + + return c.json({ success: true, data: { tiers } }); + } catch (error: unknown) { + logger.error('ai-provisioning read failed', {}, error instanceof Error ? error : undefined); + return c.json({ success: false, error: { message: 'Internal server error' } }, 500); + } +} diff --git a/server/portal/integration.routes.ts b/server/portal/integration.routes.ts index 103ebf8d0..20e4127b3 100644 --- a/server/portal/integration.routes.ts +++ b/server/portal/integration.routes.ts @@ -14,10 +14,9 @@ import { reencryptAllTenantSecrets } from '../lib/secrets-reencrypt'; import { secretsCacheKey } from '../lib/secrets-cache'; import { OutboxService } from './outbox.service'; import { requireServiceBinding } from './service-binding-guard'; +import { aiProvisioningHandler } from './ai-provisioning'; import { findGlobalAgentByEmail } from '../services/agent/account'; -import { aggregateUsage } from '../lib/usage/aggregate'; -import { usageCounters } from '../lib/db/schema/usage'; -import { FREE_TIER_CAPS } from '../features/plan-quota/policy'; +import { usageReportHandler } from './usage-report'; import { getSeatUsage } from '../features/seat-quota/usage'; const api = new Hono(); @@ -368,47 +367,17 @@ api.post('/secrets/reencrypt', requireServiceBinding, async (c) => { /** * GET /api/integration/usage - * Platform monitoring: aggregated usage counters across all tenants, for the - * portal console's usage dashboard. Per tenant: lifetime sums for every - * metered dimension (platform + bring-your-own sms/email, inspections), - * the r2_bytes gauge, the tenant's plan tier, and — for a free tenant only — - * the free-tier caps those platform metrics are measured against (`null` - * for pro/enterprise, since the cap never applies to them). - * M2M-guarded by the router mount (requireServiceBinding inherited). + * Platform usage dashboard read — handler + payload notes in ./usage-report.ts. */ -api.get('/usage', requireServiceBinding, async (c) => { - try { - const db = drizzle(c.env.DB); - const rows = await db.select().from(usageCounters).all(); - const usage = aggregateUsage(rows); - - const tenantIds = usage.map((u) => u.tenantId); - const tierRows = tenantIds.length - ? await db.select({ id: tenants.id, tier: tenants.tier }).from(tenants).where(inArray(tenants.id, tenantIds)).all() - : []; - const tierByTenant = new Map(tierRows.map((t) => [t.id as string, t.tier as string])); - - const data = usage.map((u) => { - const tier = tierByTenant.get(u.tenantId) ?? 'free'; - return { - tenantId: u.tenantId, - tier, - inspections: u.inspections, - sms: u.sms, - smsByo: u.smsByo, - email: u.email, - emailByo: u.emailByo, - r2Bytes: u.r2Bytes, - caps: tier === 'free' ? FREE_TIER_CAPS : null, - }; - }); +api.get('/usage', requireServiceBinding, usageReportHandler); - return c.json({ success: true, data }); - } catch (error: unknown) { - logger.error('usage aggregation failed', {}, error instanceof Error ? error : undefined); - return c.json({ success: false, error: { message: 'Internal server error' } }, 500); - } -}); +/** + * GET /api/integration/ai-provisioning + * Per-tier tenant counts bucketed by the runtime AI credential resolver + * (managed / byo / unconfigured) for portal's tier-quota console. Handler + + * contract notes live in ./ai-provisioning.ts. + */ +api.get('/ai-provisioning', requireServiceBinding, aiProvisioningHandler); /** * GET /api/integration/tenants/by-email?email= diff --git a/server/portal/usage-report.ts b/server/portal/usage-report.ts new file mode 100644 index 000000000..ef7e9f3e2 --- /dev/null +++ b/server/portal/usage-report.ts @@ -0,0 +1,52 @@ +import type { Context } from 'hono'; +import { drizzle } from 'drizzle-orm/d1'; +import { inArray } from 'drizzle-orm'; +import type { HonoConfig } from '../types/hono'; +import { tenants } from '../lib/db/schema'; +import { usageCounters } from '../lib/db/schema/usage'; +import { aggregateUsage } from '../lib/usage/aggregate'; +import { FREE_TIER_CAPS } from '../features/plan-quota/policy'; +import { logger } from '../lib/logger'; + +/** + * GET /api/integration/usage — platform monitoring: aggregated usage counters + * across all tenants, for the portal console's usage dashboard. Per tenant: + * lifetime sums for every metered dimension (platform + bring-your-own + * sms/email, inspections), the r2_bytes gauge, the tenant's plan tier, and — + * for a free tenant only — the free-tier caps those platform metrics are + * measured against (`null` for pro/enterprise, since the cap never applies to + * them). M2M-guarded at the mount (requireServiceBinding). + */ +export async function usageReportHandler(c: Context) { + try { + const db = drizzle(c.env.DB); + const rows = await db.select().from(usageCounters).all(); + const usage = aggregateUsage(rows); + + const tenantIds = usage.map((u) => u.tenantId); + const tierRows = tenantIds.length + ? await db.select({ id: tenants.id, tier: tenants.tier }).from(tenants).where(inArray(tenants.id, tenantIds)).all() + : []; + const tierByTenant = new Map(tierRows.map((t) => [t.id as string, t.tier as string])); + + const data = usage.map((u) => { + const tier = tierByTenant.get(u.tenantId) ?? 'free'; + return { + tenantId: u.tenantId, + tier, + inspections: u.inspections, + sms: u.sms, + smsByo: u.smsByo, + email: u.email, + emailByo: u.emailByo, + r2Bytes: u.r2Bytes, + caps: tier === 'free' ? FREE_TIER_CAPS : null, + }; + }); + + return c.json({ success: true, data }); + } catch (error: unknown) { + logger.error('usage aggregation failed', {}, error instanceof Error ? error : undefined); + return c.json({ success: false, error: { message: 'Internal server error' } }, 500); + } +} diff --git a/tests/unit/integrations/integration-ai-provisioning.spec.ts b/tests/unit/integrations/integration-ai-provisioning.spec.ts new file mode 100644 index 000000000..3b2a762a3 --- /dev/null +++ b/tests/unit/integrations/integration-ai-provisioning.spec.ts @@ -0,0 +1,108 @@ +/** + * Managed-AI provider tier, Task 5 follow-up (a) — `GET /api/integration/ + * ai-provisioning`. The M2M-guarded endpoint portal's tier-quota console reads + * to answer "how many tenants per tier are managed / BYO / unconfigured". + * + * The buckets must come from the SAME `resolveAi` resolver the runtime uses + * (via `resolveRuntimeAiSource`), not a re-derivation: if the console says + * "byo", it must be because the resolver would run that tenant's call on its + * own key. With managed entitlement still a literal `false`, the managed + * bucket is 0 everywhere — asserted, because faking a nonzero count would be + * the console lying about a path that cannot resolve yet. + * + * Wire contract pinned by portal's `narrowAiProvisioning` + * (apps/portal server/services/tier-quota.service.ts): `{ tiers: { : + * { managed, byo, unconfigured } } }`, all three numbers finite, and a tier + * with NO tenants ABSENT rather than zeroed (absent = "core did not mention + * it", which portal renders differently from a zero row). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import type { HonoConfig } from '../../../server/types/hono'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +// Partial mock: the router also imports `secretsCacheKey` from this module. +vi.mock('../../../server/lib/secrets-cache', async (importOriginal) => ({ + ...(await importOriginal>()), + loadTenantSecrets: vi.fn(async () => null), +})); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import { loadTenantSecrets } from '../../../server/lib/secrets-cache'; +import integrationRoutes from '../../../server/portal/integration.routes'; +import { signM2mHeader, M2M_HEADER } from '../../../server/lib/m2m-auth'; + +const FAKE_PEM = `-----BEGIN PRIVATE KEY-----\n${btoa('test-m2m-shared-key-material-0123456789')}\n-----END PRIVATE KEY-----`; +const ENV = { + DB: {}, JWT_CURRENT_KID: 'v1', JWT_PRIVATE_KEY_V1: FAKE_PEM, + APP_MODE: 'saas', JWT_SECRET: 'test-secret', + AI_MANAGED_API_KEY: 'platform-key', AI_MODEL: 'gemini-test', +} as Record; + +type TierCounts = { managed: number; byo: number; unconfigured: number }; + +describe('GET /api/integration/ai-provisioning', () => { + let testDb: BetterSQLite3Database; + let sqlite: ReturnType['sqlite']; + + function app() { const a = new OpenAPIHono(); a.route('/api/integration', integrationRoutes); return a; } + async function header() { return signM2mHeader(ENV as Record); } + + beforeEach(async () => { + const s = createTestDb(); testDb = s.db; sqlite = s.sqlite; await setupSchema(sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb); + vi.mocked(loadTenantSecrets).mockReset().mockResolvedValue(null); + }); + afterEach(() => { sqlite.close(); vi.clearAllMocks(); }); + + it('403 without M2M header', async () => { + const res = await app().request('/api/integration/ai-provisioning', {}, ENV); + expect(res.status).toBe(403); + }); + + it('buckets tenants per tier by the runtime resolver; absent tier stays absent; managed is 0 while entitlement is off', async () => { + await testDb.insert(schema.tenants).values([ + { id: 't-free-nokey', name: 'F1', slug: 'f1', tier: 'free', createdAt: new Date() }, + { id: 't-free-key', name: 'F2', slug: 'f2', tier: 'free', createdAt: new Date() }, + { id: 't-pro-key', name: 'P1', slug: 'p1', tier: 'pro', createdAt: new Date() }, + { id: 't-pro-nokey', name: 'P2', slug: 'p2', tier: 'pro', createdAt: new Date() }, + ] as never); + vi.mocked(loadTenantSecrets).mockImplementation(async (_db, _kv, tenantId) => + tenantId === 't-free-key' || tenantId === 't-pro-key' ? { GEMINI_API_KEY: 'tenant-own-key' } : null); + + const res = await app().request('/api/integration/ai-provisioning', { headers: { [M2M_HEADER]: await header() } }, ENV); + expect(res.status).toBe(200); + const body = await res.json() as { data: { tiers: Record } }; + + // AI_MANAGED_API_KEY is configured in ENV, yet managed stays 0: the + // entitlement is a literal `false` until granted as configuration. A + // nonzero managed count here would mean a SECOND resolution path invented + // an entitlement the runtime does not have. + expect(body.data.tiers).toEqual({ + free: { managed: 0, byo: 1, unconfigured: 1 }, + pro: { managed: 0, byo: 1, unconfigured: 1 }, + }); + expect(body.data.tiers).not.toHaveProperty('enterprise'); + }); + + it('a tenant whose secrets blob cannot be decrypted counts as unconfigured — the same shape the runtime resolves it to', async () => { + await testDb.insert(schema.tenants).values([ + { id: 't-broken', name: 'B', slug: 'b', tier: 'free', createdAt: new Date() }, + ] as never); + vi.mocked(loadTenantSecrets).mockRejectedValue(new Error('undecryptable')); + + const res = await app().request('/api/integration/ai-provisioning', { headers: { [M2M_HEADER]: await header() } }, ENV); + expect(res.status).toBe(200); + const body = await res.json() as { data: { tiers: Record } }; + expect(body.data.tiers).toEqual({ free: { managed: 0, byo: 0, unconfigured: 1 } }); + }); + + it('no tenants at all -> empty tiers map, not an error', async () => { + const res = await app().request('/api/integration/ai-provisioning', { headers: { [M2M_HEADER]: await header() } }, ENV); + expect(res.status).toBe(200); + const body = await res.json() as { data: { tiers: Record } }; + expect(body.data.tiers).toEqual({}); + }); +}); From 4a8cde13ebbe62f7c6ba458c02a922929d874775 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 07:32:25 +0800 Subject: [PATCH 09/77] gate(idempotency): ledger every mutating route's retry safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutating route with no idempotency story looks identical to a guarded one at the call site — the duplicate row only appears when a customer's network retries a POST. This freezes today's surface as a burn-down ledger and fails on any new mutating route that arrives without one. Ported from portal's equivalent gate rather than written fresh, keeping its four-category ledger (verified / pending / uncoveredByDesign / knownUnreachable), its fail-closed behaviour when zero routes are discovered, and its stale-entry check — a ratchet that only ever grows lies about progress. Discovery is rewritten for OI's layout: the walk starts at server/index.ts's .route() mounts and follows sub-routers, alias chains, ident statements and router-taking helpers recursively. Evidence files diverge from portal's: only `*-replay.spec.ts` under tests/unit/idempotency/ and `*-idempotency.test.ts` under app/ count. The mechanism's own unit specs live in that directory and quote real route paths as sample input — scanning them would mark routes verified on the strength of a hash test that never calls them. Seen red both ways before wiring: a throwaway `testHooks.post('/x-throwaway')` was named and exited 1, and a hand-added `POST /api/gone-away` pending entry failed as stale. 302 of 312 declared mutating routes resolve; 287 pending, 14 by design, 1 verified. --- scripts/check-idempotency-coverage.mjs | 532 +++++++++++++++++++++++++ scripts/idempotency-baseline.json | 324 +++++++++++++++ scripts/run-gates.mjs | 1 + 3 files changed, 857 insertions(+) create mode 100644 scripts/check-idempotency-coverage.mjs create mode 100644 scripts/idempotency-baseline.json diff --git a/scripts/check-idempotency-coverage.mjs b/scripts/check-idempotency-coverage.mjs new file mode 100644 index 000000000..6d5012daf --- /dev/null +++ b/scripts/check-idempotency-coverage.mjs @@ -0,0 +1,532 @@ +#!/usr/bin/env node +/** + * scripts/check-idempotency-coverage.mjs + * + * Retry safety is invisible at the call site: a mutating route with no + * idempotency story looks identical to a guarded one — every request gets a + * sensible response, and the duplicate row only shows up when a customer's + * network retries a POST. The middleware (`app.use('*', idempotencyGuard)` in + * server/index.ts, mounted AFTER the JWT middleware) covers a route ONLY when a + * tenant is on the context when it runs AND the client sends an + * `Idempotency-Key`; a request with no tenant passes through unguarded by + * design, because a bare key would be a global namespace two tenants could + * collide in. + * + * So this gate keeps a ledger. Every mutating route it discovers must be one of: + * + * 1. VERIFIED — a replay spec names the full route path as a string literal. + * The spec is the evidence that a retry of the route is contained (by the + * mounted middleware, or by the route's own dedup mechanism — the test + * does not care which, and neither do we). + * 2. In `pending` — the burn-down ratchet: known-unverified routes, shrunk one + * commit at a time (give the route coverage, add the replay spec, delete + * the entry). `--update` regenerates this list. + * 3. In `uncoveredByDesign` — hand-maintained judgement calls with a one-line + * reason (public endpoints whose retry story is the token itself, + * provider-signed webhooks with their own dedup, naturally idempotent + * writes). Supports a trailing `*` wildcard. + * 4. In `knownUnreachable` — routes that permanently 401 / are not mounted; + * printed on every run, never silently forgotten. + * + * Anything else fails the gate. So does a stale `pending` entry (the route was + * removed, or gained a replay spec without the entry being deleted) — a ratchet + * that only ever grows lies about progress. + * + * EVIDENCE FILES (the divergence from portal's gate, which scans every spec in + * its idempotency directory): here a replay spec is a file named + * `*-replay.spec.ts` under tests/unit/idempotency/, or `*-idempotency.test.ts` + * anywhere under app/. The mechanism's own unit specs (fingerprint/store/ + * middleware) live in that same directory and quote real route paths as sample + * input — scanning them would mark routes verified on the strength of a hash + * test that never calls them. + * + * DISCOVERY LIMITS, stated rather than left to be discovered: + * - Only `server/api/**` is walked, reached from the `.route()` mounts in + * server/index.ts and followed recursively through sub-router mounts. + * A router that server/index.ts never mounts is invisible here. + * - Routes registered INLINE in server/index.ts (`app.post(...)` written in + * that file rather than in a sub-router) are outside the walk. + * - Routes registered through a helper function whose first parameter is + * named `router` (`registerR2VideoRoutes(mediaStudioRoutes)`) are followed, + * but only when the call is a bare top-level `registerX(someRouter);` + * statement. A helper called conditionally, or with a router built inline, + * is invisible. + * - A router's chain body is delimited by indentation: it runs from the + * `const X = createApiRouter()` line to the next line that starts at column + * zero. A handler holding a template literal with column-zero content would + * truncate it — which is what the `coverage` counters in the baseline are + * for: `declaredMutating` counts the raw mutating declarations in the + * source, `resolvedMutating` counts the ones this walk actually resolved to + * a full path, and a DROP in the resolved count fails the gate. + * + * Usage: + * node scripts/check-idempotency-coverage.mjs # verify (exit 1 on drift) + * node scripts/check-idempotency-coverage.mjs --update # regenerate `pending` + * + * Exit 0 = OK; exit 1 = drift, or zero routes parsed (fails closed). + */ +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; +import { join, dirname, resolve as resolvePath, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const API_DIR = join(ROOT, 'server/api'); +const INDEX_FILE = join(ROOT, 'server/index.ts'); +const REPLAY_SPEC_DIR = join(ROOT, 'tests/unit/idempotency'); +const APP_DIR = join(ROOT, 'app'); +const BASELINE_PATH = join(__dirname, 'idempotency-baseline.json'); + +const MUTATING = new Set(['post', 'put', 'patch', 'delete']); + +function read(path) { + return readFileSync(path, 'utf8'); +} + +/** Blank comments out rather than removing them, so line numbers hold. */ +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/\/\/[^\n]*/g, ''); +} + +/** Every `.ts` under `dir`, recursively, as paths relative to `dir`. */ +function walkTs(dir, prefix = '') { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...walkTs(join(dir, entry.name), rel)); + else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) out.push(rel); + } + return out; +} + +function joinPaths(prefix, path) { + return (prefix + path).replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1'); +} + +/** + * Resolve a relative import specifier from `fromFile` (a path relative to + * API_DIR, or null for server/index.ts) to a file relative to API_DIR. + * Returns null when the target is outside server/api. + */ +function resolveImport(fromRel, spec) { + if (!spec.startsWith('.')) return null; + const baseDir = fromRel === null ? join(ROOT, 'server') : dirname(join(API_DIR, fromRel)); + const abs = resolvePath(baseDir, spec); + for (const candidate of [`${abs}.ts`, join(abs, 'index.ts')]) { + if (!existsSync(candidate)) continue; + const rel = relative(API_DIR, candidate).split('\\').join('/'); + if (rel.startsWith('..')) return null; + return rel; + } + return null; +} + +/** local ident -> { file, exported } where `exported` is a name or 'default'. */ +function parseImports(src, fromRel) { + const map = new Map(); + for (const m of src.matchAll(/import\s+([^;]+?)\s+from\s+'([^']+)'/g)) { + const clause = m[1]; + const file = resolveImport(fromRel, m[2]); + if (!file) continue; + const defaultMatch = clause.match(/^\s*(\w+)/); + if (defaultMatch) map.set(defaultMatch[1], { file, exported: 'default' }); + const named = clause.match(/\{([\s\S]*)\}/); + if (named) { + for (const part of named[1].split(',')) { + const t = part.trim(); + if (!t) continue; + const as = t.match(/^(\w+)\s+as\s+(\w+)$/); + if (as) map.set(as[2], { file, exported: as[1] }); + else if (/^\w+$/.test(t)) map.set(t, { file, exported: t }); + } + } + } + return map; +} + +/** + * `const NAME = createRoute(...)` declarations, keyed by const name. OI wraps + * most of them in `withMcpMetadata({...})`, so the body is read from the + * declaration up to the next top-level `const`/`export`/`function` line rather + * than by matching a fixed closing shape. + */ +function parseRouteConsts(src) { + const lines = src.split('\n'); + const out = new Map(); + for (let i = 0; i < lines.length; i++) { + const decl = lines[i].match(/^(?:export )?const (\w+) = createRoute\(/); + if (!decl) continue; + let body = lines[i]; + for (let j = i + 1; j < lines.length && !/^\S/.test(lines[j]); j++) body += `\n${lines[j]}`; + const method = body.match(/\bmethod:\s*'(\w+)'/)?.[1]; + const path = body.match(/\bpath:\s*'([^']*)'/)?.[1]; + if (!method || !path) continue; + out.set(decl[1], { method, path }); + } + return out; +} + +/** + * Routers declared in one file: for each, the route consts it chains via + * `.openapi()`, the verbs it registers inline, and the sub-routers it mounts. + * Two registration shapes are read — the chain that follows the declaration + * (delimited by indentation) and later `IDENT.verb(...)` / `IDENT.route(...)` + * statements written against the same ident. + */ +function parseRouters(src) { + const lines = src.split('\n'); + const routers = new Map(); + const ensure = (name) => { + if (!routers.has(name)) routers.set(name, { routeConsts: [], chained: [], mounts: [] }); + return routers.get(name); + }; + + const harvest = (target, body) => { + for (const m of body.matchAll(/\.openapi\(\s*(\w+)/g)) target.routeConsts.push(m[1]); + for (const m of body.matchAll(/\.route\(\s*'([^']*)'\s*,\s*(\w+)\s*\)/g)) { + target.mounts.push({ prefix: m[1], ident: m[2] }); + } + // The leading `/` separates a route registration from an unrelated + // method call (`ALLOWED.delete('x')`, `map.get('k')`). + for (const m of body.matchAll(/\.(post|put|patch|delete|get)\(\s*'(\/[^']*)'/g)) { + target.chained.push({ method: m[1], path: m[2] }); + } + }; + + const bodyFrom = (i) => { + let body = lines[i]; + for (let j = i + 1; j < lines.length && !/^\S/.test(lines[j]); j++) body += `\n${lines[j]}`; + return body; + }; + + for (let i = 0; i < lines.length; i++) { + const decl = lines[i].match(/^(?:export )?const (\w+)(?::[^=]+)? = (?:createApiRouter\(|new (?:OpenAPIHono|Hono))/); + if (!decl) continue; + harvest(ensure(decl[1]), bodyFrom(i)); + } + + // Alias chains: `const base = createApiRouter();` followed by + // `const exported = base.openapi(...)...` — portal.ts and the notice + // modules split the declaration from the chain that way, and reading only + // the factory line would resolve those routers to nothing. + for (let pass = 0; pass < 2; pass++) { + for (let i = 0; i < lines.length; i++) { + const decl = lines[i].match(/^(?:export )?const (\w+)(?::[^=]+)? = (\w+)\s*$/); + if (!decl || routers.has(decl[1]) || !routers.has(decl[2])) continue; + const target = ensure(decl[1]); + const base = routers.get(decl[2]); + target.routeConsts.push(...base.routeConsts); + target.chained.push(...base.chained); + target.mounts.push(...base.mounts); + harvest(target, bodyFrom(i)); + } + } + + // Statements written against the ident after the declaration: + // `clientMessageRoutes.post('/inspections/:id/messages', …)`. + for (const name of [...routers.keys()]) { + const stmt = new RegExp(`^${name}\\s*\\n?\\s*\\.[\\s\\S]*?(?=\\n\\S|$)`, 'gm'); + for (const m of src.matchAll(stmt)) harvest(routers.get(name), m[0]); + } + + return routers; +} + +/** + * `export function registerX(router, …) { router.post('/p', …) }` — a helper + * that takes a router and registers on it. Keyed by function name; the call + * site decides which router (and therefore which prefix) they land on. + */ +function parseRouterHelpers(src) { + const lines = src.split('\n'); + const out = new Map(); + for (let i = 0; i < lines.length; i++) { + const decl = lines[i].match(/^export function (\w+)\(\s*router\b/); + if (!decl) continue; + let body = ''; + for (let j = i + 1; j < lines.length && !/^\S/.test(lines[j]); j++) body += `\n${lines[j]}`; + const chained = [...body.matchAll(/\brouter\.(post|put|patch|delete|get)\(\s*'(\/[^']*)'/g)] + .map(m => ({ method: m[1], path: m[2] })); + out.set(decl[1], chained); + } + return out; +} + +/** `registerX(someRouter);` call sites — helper name paired with the router ident. */ +function parseHelperCalls(src) { + return [...src.matchAll(/^(\w+)\(\s*(\w+)\s*\);/gm)].map(m => ({ fn: m[1], ident: m[2] })); +} + +/** `export default someRouter;` — the local name behind a default import. */ +function parseDefaultExport(src) { + return src.match(/^export default (\w+);/m)?.[1] ?? null; +} + +function collect() { + const files = walkTs(API_DIR); + const parsed = new Map(); + let declaredMutating = 0; + + for (const f of files) { + const src = stripComments(read(join(API_DIR, f))); + // Declared-side tally: every mutating createRoute + every inline + // mutating verb with a quoted path. The resolved count is ratcheted + // against this, because a parser that quietly sees less than the + // surface reports OK either way. + declaredMutating += (src.match(/\bmethod:\s*'(?:post|put|patch|delete)'/g) ?? []).length; + declaredMutating += (src.match(/\.(?:post|put|patch|delete)\(\s*'\//g) ?? []).length; + parsed.set(f, { + imports: parseImports(src, f), + consts: parseRouteConsts(src), + routers: parseRouters(src), + helpers: parseRouterHelpers(src), + helperCalls: parseHelperCalls(src), + defaultExport: parseDefaultExport(src), + }); + } + + const indexSrc = stripComments(read(INDEX_FILE)); + const indexImports = parseImports(indexSrc, null); + + const routes = []; + const seen = new Set(); + const push = (method, fullPath, file) => { + if (!MUTATING.has(method)) return; + const route = `${method.toUpperCase()} ${fullPath}`; + if (seen.has(route)) return; + seen.add(route); + routes.push({ route, file }); + }; + + /** Follow one router ident inside one file, accumulating full paths. */ + const visit = (file, routerName, prefix, stack) => { + const entry = parsed.get(file); + if (!entry) return; + const router = entry.routers.get(routerName); + if (!router) return; + const key = `${file}#${routerName}#${prefix}`; + if (stack.has(key)) return; + stack.add(key); + + for (const constName of router.routeConsts) { + // Same-file consts win; route-const names collide across modules + // (several files declare `deleteRoute`), so the import table is + // consulted before any global lookup. + const imported = entry.imports.get(constName); + const rc = entry.consts.get(constName) + ?? (imported ? parsed.get(imported.file)?.consts.get(imported.exported) : undefined); + if (!rc) continue; + push(rc.method, joinPaths(prefix, rc.path), file); + } + for (const { method, path } of router.chained) { + push(method, joinPaths(prefix, path), file); + } + // Helper-registered verbs land on the router the helper was called with. + for (const { fn, ident } of entry.helperCalls) { + if (ident !== routerName) continue; + const imported = entry.imports.get(fn); + const source = imported ? parsed.get(imported.file) : entry; + const chained = source?.helpers.get(imported ? imported.exported : fn); + if (!chained) continue; + for (const { method, path } of chained) { + push(method, joinPaths(prefix, path), imported ? imported.file : file); + } + } + for (const { prefix: sub, ident } of router.mounts) { + const target = resolveRouter(entry, file, ident); + if (!target) continue; + visit(target.file, target.name, joinPaths(prefix, sub), stack); + } + }; + + /** An ident used in a mount → the file + local router name it names. */ + function resolveRouter(entry, file, ident) { + if (entry.routers.has(ident)) return { file, name: ident }; + const imported = entry.imports.get(ident); + if (!imported) return null; + const target = parsed.get(imported.file); + if (!target) return null; + const name = imported.exported === 'default' ? target.defaultExport : imported.exported; + if (!name || !target.routers.has(name)) return null; + return { file: imported.file, name }; + } + + for (const m of indexSrc.matchAll(/\.route\(\s*'([^']*)'\s*,\s*(\w+)\s*[),]/g)) { + const [, prefix, ident] = m; + const imported = indexImports.get(ident); + if (!imported) continue; + const target = parsed.get(imported.file); + if (!target) continue; + const name = imported.exported === 'default' ? target.defaultExport : imported.exported; + if (!name) continue; + visit(imported.file, name, prefix, new Set()); + } + + return Object.assign(routes, { declaredMutating }); +} + +/** Route paths named as string literals in the replay-evidence specs. */ +function evidenceText() { + const parts = []; + if (existsSync(REPLAY_SPEC_DIR)) { + for (const f of walkTs(REPLAY_SPEC_DIR)) { + if (f.endsWith('-replay.spec.ts')) parts.push(read(join(REPLAY_SPEC_DIR, f))); + } + } + if (existsSync(APP_DIR)) { + for (const f of walkTs(APP_DIR)) { + if (f.endsWith('-idempotency.test.ts') || f.endsWith('-idempotency.test.tsx')) { + parts.push(read(join(APP_DIR, f))); + } + } + } + return parts.join('\n'); +} + +/** Hono pattern match with the trailing `*` wildcard used in the baseline. */ +function pathMatches(pattern, path) { + if (pattern === path) return true; + if (pattern.endsWith('/*')) return path.startsWith(pattern.slice(0, -1)); + if (pattern === '*') return true; + return false; +} + +/** "METHOD /path" baseline-key match, wildcard-aware. */ +function routeMatches(pattern, route) { + const [pm, pp] = pattern.split(' '); + const [rm, rp] = route.split(' '); + return pm === rm && pathMatches(pp, rp); +} + +function main() { + const update = process.argv.includes('--update'); + const routes = collect(); + + // Fail closed. A parser that silently matches nothing reports a clean gate, + // which is the failure mode this repo keeps rediscovering. + if (routes.length === 0) { + console.error( + 'Idempotency-coverage gate: parsed ZERO mutating routes — this gate would pass vacuously.\n' + + 'The route-declaration shape in server/api/ has probably changed. Fix the parser.' + ); + process.exit(1); + } + + const specText = evidenceText(); + const isVerified = (route) => { + const path = route.split(' ')[1]; + return specText.includes(`'${path}'`) || specText.includes(`"${path}"`); + }; + + const prior = existsSync(BASELINE_PATH) ? JSON.parse(read(BASELINE_PATH)) : {}; + const uncoveredByDesign = prior.uncoveredByDesign ?? {}; + const knownUnreachable = prior.knownUnreachable ?? {}; + const priorComment = Array.isArray(prior.comment) ? prior.comment : null; + const priorCoverage = prior.coverage ?? null; + + const byDesignKeys = Object.keys(uncoveredByDesign); + const unreachableKeys = Object.keys(knownUnreachable); + const classify = (r) => { + if (unreachableKeys.some(p => routeMatches(p, r.route))) return 'unreachable'; + if (isVerified(r.route)) return 'verified'; + if (byDesignKeys.some(p => routeMatches(p, r.route))) return 'byDesign'; + return 'pending'; + }; + + const pendingNow = routes.filter(r => classify(r) === 'pending'); + const coverage = { declaredMutating: routes.declaredMutating, resolvedMutating: routes.length }; + + if (update) { + writeFileSync( + BASELINE_PATH, + JSON.stringify({ + comment: priorComment ?? [ + 'Burn-down ledger for mutating-route retry safety. `pending` lists routes', + 'with NO verified idempotency story yet: to remove one, give the route', + 'coverage (the mounted guard already covers every tenant-authenticated', + 'route when the client sends Idempotency-Key; tenant-less routes need', + 'their own mechanism) and add a replay spec — `*-replay.spec.ts` under', + 'tests/unit/idempotency/, or `*-idempotency.test.ts` under app/ — that', + 'names the full route path as a string literal. The spec is the exit', + 'evidence. uncoveredByDesign holds judgement calls with reasons and', + 'supports a trailing `*` wildcard; knownUnreachable is printed on every', + 'run so it is never silently forgotten.', + ], + coverage, + knownUnreachable, + uncoveredByDesign, + pending: pendingNow.map(r => r.route).sort(), + }, null, 4) + '\n', + 'utf8' + ); + console.log(`Updated ${BASELINE_PATH}: ${pendingNow.length} pending routes (${routes.length} mutating routes resolved of ${routes.declaredMutating} declared).`); + return; + } + + if (!existsSync(BASELINE_PATH)) { + console.error(`Idempotency-coverage gate: baseline missing at ${BASELINE_PATH}. Run with --update.`); + process.exit(1); + } + + const pendingBaseline = prior.pending ?? []; + let failed = false; + + if (priorCoverage && coverage.resolvedMutating < priorCoverage.resolvedMutating) { + failed = true; + console.error('Idempotency-coverage gate — route resolution DROPPED:'); + console.error( + ` x resolved ${coverage.resolvedMutating} mutating routes; the baseline recorded ` + + `${priorCoverage.resolvedMutating}. The parser stopped seeing part of the surface — ` + + `fix the parser, or if routes were genuinely deleted, run --update and say so in the commit.` + ); + console.error(''); + } + + const stillUnreachable = routes.filter(r => unreachableKeys.some(p => routeMatches(p, r.route))); + if (stillUnreachable.length > 0) { + console.warn('Idempotency-coverage gate — KNOWN UNREACHABLE (declared, not failing):'); + for (const r of stillUnreachable) { + const key = unreachableKeys.find(p => routeMatches(p, r.route)); + console.warn(` ! ${r.route} — ${knownUnreachable[key]}`); + } + console.warn(''); + } + + const newUncovered = pendingNow.filter(r => !pendingBaseline.includes(r.route)); + if (newUncovered.length > 0) { + failed = true; + console.error('Idempotency-coverage gate — mutating routes with NO verified retry safety:'); + for (const r of newUncovered) { + console.error(` x ${r.route} (server/api/${r.file})`); + console.error( + ' the mounted guard covers this when a tenant is on the context and the ' + + 'client sends Idempotency-Key — add a replay spec naming this path, or, if no ' + + 'tenant reaches it, give the route its own dedup mechanism first' + ); + } + console.error(''); + console.error('Either verify the route (replay spec), or add it to "uncoveredByDesign" in'); + console.error(`${BASELINE_PATH} with a one-line reason. Adding it back to "pending" is the`); + console.error('option of last resort — the list is a burn-down, not a dumping ground.'); + } + + const stale = pendingBaseline.filter(p => !pendingNow.some(r => r.route === p)); + if (stale.length > 0) { + failed = true; + console.error('Idempotency-coverage gate — STALE pending entries (route gone, or now verified):'); + for (const p of stale) console.error(` x ${p}`); + console.error(''); + console.error('Delete them from the baseline (or run --update). A ratchet with dead'); + console.error('entries overstates the remaining debt and hides real regressions.'); + } + + if (failed) process.exit(1); + console.log( + `Idempotency-coverage gate: OK (${routes.length} mutating routes resolved, ` + + `${pendingBaseline.length} pending, ${routes.filter(r => classify(r) === 'verified').length} verified by replay spec).` + ); +} + +main(); diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json new file mode 100644 index 000000000..cf436355b --- /dev/null +++ b/scripts/idempotency-baseline.json @@ -0,0 +1,324 @@ +{ + "comment": [ + "Burn-down ledger for mutating-route retry safety. `pending` lists routes", + "with NO verified idempotency story yet: to remove one, give the route", + "coverage (the mounted guard already covers every tenant-authenticated", + "route when the client sends Idempotency-Key; tenant-less routes need", + "their own mechanism) and add a replay spec — `*-replay.spec.ts` under", + "tests/unit/idempotency/, or `*-idempotency.test.ts` under app/ — that", + "names the full route path as a string literal. The spec is the exit", + "evidence. uncoveredByDesign holds judgement calls with reasons and", + "supports a trailing `*` wildcard; knownUnreachable is printed on every", + "run so it is never silently forgotten." + ], + "coverage": { + "declaredMutating": 312, + "resolvedMutating": 302 + }, + "knownUnreachable": {}, + "uncoveredByDesign": { + "POST /api/auth/login": "Mints the session the guard would key on; re-login is naturally idempotent.", + "POST /login": "Same handler as POST /api/auth/login — coreAuthRoutes is mounted at both prefixes.", + "POST /api/auth/login/2fa": "Second factor of the same login; consumes a one-time code, which is the dedup.", + "POST /login/2fa": "Same handler as POST /api/auth/login/2fa (dual mount).", + "POST /api/auth/logout": "Deletes the session cookie; naturally idempotent.", + "POST /logout": "Same handler as POST /api/auth/logout (dual mount).", + "POST /api/portal/{tenant}/logout": "Deletes the client-portal session cookie; naturally idempotent.", + "POST /api/auth/join": "Team-invite acceptance gated on a single-use invite token — consuming it is the dedup, and no session exists to key on.", + "POST /join": "Same handler as POST /api/auth/join (dual mount).", + "POST /api/auth/reset-password": "Gated on the single-use password-reset token; runs before any session exists.", + "POST /reset-password": "Same handler as POST /api/auth/reset-password (dual mount).", + "POST /api/auth/setup": "One-time first-run bootstrap gated on the SETUP_CODE secret; there is no tenant yet to key on.", + "POST /setup": "Same handler as POST /api/auth/setup (dual mount).", + "POST /api/__test__/calendar-connection": "E2E-only hook, fail-closed behind E2E_EMAIL_SINK (404 in every real deploy) — not a production surface." + }, + "pending": [ + "DELETE /api/admin/agreements/{id}", + "DELETE /api/admin/comments/{id}", + "DELETE /api/admin/custom-holidays/{id}", + "DELETE /api/admin/data", + "DELETE /api/admin/defect-categories/{id}", + "DELETE /api/admin/event-types/{id}", + "DELETE /api/admin/inspection-types/:id", + "DELETE /api/admin/stripe-connect", + "DELETE /api/agent/notices/{id}", + "DELETE /api/automations/{id}", + "DELETE /api/availability/overrides/{id}", + "DELETE /api/calendar/blocks/{id}", + "DELETE /api/calendar/disconnect", + "DELETE /api/contacts/{id}", + "DELETE /api/contractor-types/{id}", + "DELETE /api/credentials/{id}", + "DELETE /api/event-types/:id", + "DELETE /api/events/:id", + "DELETE /api/inspections/:id/documents/:docId", + "DELETE /api/inspections/templates/{id}", + "DELETE /api/inspections/{id}", + "DELETE /api/inspections/{id}/compliance/signoff/{role}", + "DELETE /api/inspections/{id}/cost-items/{itemId}", + "DELETE /api/inspections/{id}/items/{itemId}/tags/{tagId}", + "DELETE /api/inspections/{id}/media/pool/{poolId}", + "DELETE /api/inspections/{id}/media/video/{streamUid}", + "DELETE /api/inspections/{id}/people/{personId}", + "DELETE /api/inspections/{id}/units/{unitId}", + "DELETE /api/invoices/{id}", + "DELETE /api/mcp/grants/:id", + "DELETE /api/message-templates/{id}", + "DELETE /api/notifications/{id}", + "DELETE /api/portal/{tenant}/notices/{id}", + "DELETE /api/public/inspections/:id/documents/:docId", + "DELETE /api/public/repair-builder/{tenant}/{id}/lists/{rrId}/items/{itemId}", + "DELETE /api/rating-systems/{id}", + "DELETE /api/recommendations/{id}", + "DELETE /api/role-profiles/{id}", + "DELETE /api/tags/{id}", + "DELETE /api/team/invites/{token}", + "DELETE /api/team/members/{id}", + "PATCH /api/admin/attention-thresholds", + "PATCH /api/admin/communication", + "PATCH /api/admin/dashboard-columns", + "PATCH /api/admin/event-types/{id}", + "PATCH /api/admin/pdf-pipeline", + "PATCH /api/admin/tenant-config", + "PATCH /api/auth/profile", + "PATCH /api/automations/{id}", + "PATCH /api/calendar/blocks/{id}", + "PATCH /api/contractor-types/{id}", + "PATCH /api/credentials/{id}", + "PATCH /api/inspections/bulk", + "PATCH /api/inspections/{id}", + "PATCH /api/inspections/{id}/compliance/doc-review/{documentKey}", + "PATCH /api/inspections/{id}/cost-items/{itemId}", + "PATCH /api/inspections/{id}/pca-narrative", + "PATCH /api/inspections/{id}/property-facts", + "PATCH /api/inspections/{id}/schedule", + "PATCH /api/inspections/{id}/template-snapshot", + "PATCH /api/inspections/{id}/units/{unitId}", + "PATCH /api/message-templates/{id}", + "PATCH /api/profile", + "PATCH /api/public/repair-builder/{tenant}/{id}/lists/{rrId}", + "PATCH /api/public/repair-builder/{tenant}/{id}/lists/{rrId}/items/{itemId}", + "PATCH /api/team/members/{id}", + "PATCH /profile", + "POST /2fa/disable", + "POST /2fa/recovery-codes/regenerate", + "POST /2fa/setup", + "POST /2fa/verify", + "POST /api/admin/agreement-requests/{id}/inspector-sign", + "POST /api/admin/agreements", + "POST /api/admin/agreements/requests/{requestId}/signers/{signerId}/remind", + "POST /api/admin/agreements/send", + "POST /api/admin/audit-logs", + "POST /api/admin/branding", + "POST /api/admin/branding/logo", + "POST /api/admin/comments", + "POST /api/admin/comments/{id}/touch", + "POST /api/admin/config", + "POST /api/admin/custom-holidays", + "POST /api/admin/defect-categories", + "POST /api/admin/email-templates/{trigger}/preview", + "POST /api/admin/email-templates/{trigger}/reset", + "POST /api/admin/event-types", + "POST /api/admin/import", + "POST /api/admin/inspection-types", + "POST /api/admin/invite", + "POST /api/admin/migrate-finding-keys", + "POST /api/admin/secrets", + "POST /api/admin/sms/attest", + "POST /api/admin/sms/compliance/provision", + "POST /api/admin/sms/compliance/resubmit", + "POST /api/admin/sms/test", + "POST /api/agent-signup", + "POST /api/agent/concierge-book", + "POST /api/agent/login", + "POST /api/agent/login-link", + "POST /api/agent/magic-login/request", + "POST /api/agent/notices/mark-read", + "POST /api/agent/profile", + "POST /api/agent/report-context", + "POST /api/agents/{linkId}/revoke", + "POST /api/ai/auto-summary", + "POST /api/ai/comment-assist", + "POST /api/ai/comment/edit", + "POST /api/ai/suggest-comment", + "POST /api/auth/2fa/disable", + "POST /api/auth/2fa/recovery-codes/regenerate", + "POST /api/auth/2fa/setup", + "POST /api/auth/2fa/verify", + "POST /api/auth/change-password", + "POST /api/auth/checklist/dismiss", + "POST /api/auth/forgot-password", + "POST /api/auth/onboarding/flag", + "POST /api/auth/setup/skip", + "POST /api/automations", + "POST /api/availability/overrides", + "POST /api/calendar/blocks", + "POST /api/calendar/sync", + "POST /api/calendar/sync-events", + "POST /api/concierge/confirm", + "POST /api/contacts", + "POST /api/contacts/import", + "POST /api/contacts/import/preview", + "POST /api/contacts/{id}/access/revoke", + "POST /api/contacts/{id}/restore", + "POST /api/contractor-types", + "POST /api/contractor-types/reorder", + "POST /api/credentials", + "POST /api/credentials/{id}/image", + "POST /api/data/import/contacts", + "POST /api/event-types", + "POST /api/event-types/seed", + "POST /api/identities/account/delete", + "POST /api/identities/account/export", + "POST /api/inspection-requests", + "POST /api/inspection-requests/{id}/inspections", + "POST /api/inspections/:id/collab/restore", + "POST /api/inspections/:id/collab/restructure", + "POST /api/inspections/:id/collab/snapshots", + "POST /api/inspections/:id/media/video/r2-upload", + "POST /api/inspections/:id/media/video/r2-upload-poster", + "POST /api/inspections/:id/sign", + "POST /api/inspections/:inspectionId/events", + "POST /api/inspections/templates", + "POST /api/inspections/templates/import-spectora", + "POST /api/inspections/wizard", + "POST /api/inspections/{id}/agreement-requests", + "POST /api/inspections/{id}/clone", + "POST /api/inspections/{id}/complete", + "POST /api/inspections/{id}/compliance/doc-review/seed", + "POST /api/inspections/{id}/compliance/psq/status", + "POST /api/inspections/{id}/compliance/signoff", + "POST /api/inspections/{id}/concierge/approve", + "POST /api/inspections/{id}/cost-items", + "POST /api/inspections/{id}/cover", + "POST /api/inspections/{id}/export/word", + "POST /api/inspections/{id}/items/{itemId}/photos/reorder", + "POST /api/inspections/{id}/items/{itemId}/photos/{photoIndex}/annotation", + "POST /api/inspections/{id}/items/{itemId}/photos/{photoIndex}/crop", + "POST /api/inspections/{id}/items/{itemId}/photos/{photoIndex}/detach", + "POST /api/inspections/{id}/items/{itemId}/photos/{photoIndex}/move", + "POST /api/inspections/{id}/items/{itemId}/photos/{photoIndex}/revert", + "POST /api/inspections/{id}/items/{itemId}/tags", + "POST /api/inspections/{id}/media/attach", + "POST /api/inspections/{id}/media/upload", + "POST /api/inspections/{id}/media/video/create-upload", + "POST /api/inspections/{id}/media/video/finalize", + "POST /api/inspections/{id}/media/video/poster", + "POST /api/inspections/{id}/pdf/refresh", + "POST /api/inspections/{id}/people", + "POST /api/inspections/{id}/people/{personId}/make-primary", + "POST /api/inspections/{id}/people/{personId}/reset-access", + "POST /api/inspections/{id}/property-facts/autofill", + "POST /api/inspections/{id}/publish", + "POST /api/inspections/{id}/reinspect", + "POST /api/inspections/{id}/relock-report", + "POST /api/inspections/{id}/results/batch", + "POST /api/inspections/{id}/return", + "POST /api/inspections/{id}/send-report-pdf", + "POST /api/inspections/{id}/send-sms", + "POST /api/inspections/{id}/submit", + "POST /api/inspections/{id}/switch-rating-system", + "POST /api/inspections/{id}/unit-mode", + "POST /api/inspections/{id}/units", + "POST /api/inspections/{id}/units/bulk", + "POST /api/inspections/{id}/units/{unitId}/duplicate", + "POST /api/inspections/{id}/units/{unitId}/move", + "POST /api/inspections/{id}/unlock-report", + "POST /api/inspections/{id}/unpublish", + "POST /api/inspections/{id}/upload", + "POST /api/inspections/{inspectionId}/messages", + "POST /api/inspections/{inspectionId}/messages/upload", + "POST /api/integrations/email/validate", + "POST /api/integrations/gemini/test", + "POST /api/integrations/qbo/webhook", + "POST /api/integrations/resend/test", + "POST /api/integrations/stripe/test", + "POST /api/integrations/stripe/webhook", + "POST /api/integrations/stripe/webhook/:tenant", + "POST /api/invoices", + "POST /api/invoices/request-payment", + "POST /api/invoices/{id}/mark-paid", + "POST /api/invoices/{id}/mark-sent", + "POST /api/invoices/{id}/payments", + "POST /api/invoices/{id}/payments/{paymentId}/corrections", + "POST /api/message-templates", + "POST /api/message-templates/preview", + "POST /api/message-templates/test-send", + "POST /api/message-templates/{id}/duplicate", + "POST /api/messages/threads/{contactId}", + "POST /api/notifications/mark-all-read", + "POST /api/notifications/mark-read", + "POST /api/portal/{tenant}/notices/mark-read", + "POST /api/portal/{tenant}/request-link", + "POST /api/profile/photo", + "POST /api/public/agreements/:token/decline", + "POST /api/public/agreements/:token/sign", + "POST /api/public/book", + "POST /api/public/inspections/:id/messages", + "POST /api/public/inspections/:id/messages/upload", + "POST /api/public/inspections/{id}/pay-intent", + "POST /api/public/inspections/{id}/share-token", + "POST /api/public/repair-builder/{tenant}/{id}", + "POST /api/public/repair-builder/{tenant}/{id}/lists/{rrId}/items", + "POST /api/public/repair-request/share/{shareToken}/email", + "POST /api/public/sms/inbound", + "POST /api/public/sms/inbound/:tenant", + "POST /api/public/sms/optin-confirm", + "POST /api/public/widget/event", + "POST /api/rating-systems", + "POST /api/rating-systems/{id}/clone", + "POST /api/recommendations", + "POST /api/recommendations/seed-defaults", + "POST /api/role-profiles", + "POST /api/tags", + "POST /api/team/invite", + "POST /api/team/invites/{token}/resend", + "POST /api/templates/{oldId}/migrate-to/{newId}", + "POST /api/tenant/inspection-prefs/report-link-expiry", + "POST /api/users/me/onboarding", + "POST /api/users/me/signature", + "POST /change-password", + "POST /checklist/dismiss", + "POST /forgot-password", + "POST /onboarding/flag", + "POST /settings/integrations/qbo/contacts/:contactId/link", + "POST /settings/integrations/qbo/disconnect", + "POST /settings/integrations/qbo/errors/:id/retry", + "POST /settings/integrations/qbo/pause", + "POST /settings/integrations/qbo/sync", + "POST /setup/skip", + "PUT /api/admin/agreements/{id}", + "PUT /api/admin/comments/{id}", + "PUT /api/admin/defect-categories/{id}", + "PUT /api/admin/email-templates/{trigger}", + "PUT /api/admin/inspection-types/:id", + "PUT /api/admin/secrets", + "PUT /api/admin/stripe-connect", + "PUT /api/admin/widget/origins", + "PUT /api/agent/notification-preferences", + "PUT /api/agent/notification-preferences/bulk", + "PUT /api/agent/notification-preferences/sms-consent", + "PUT /api/availability", + "PUT /api/calendar/connections/:id/calendars", + "PUT /api/contacts/{id}", + "PUT /api/event-types/:id", + "PUT /api/events/:id", + "PUT /api/inspection-requests/{id}", + "PUT /api/inspections/:id/documents", + "PUT /api/inspections/templates/{id}", + "PUT /api/inspections/{id}/compliance/psq", + "PUT /api/inspections/{id}/media/{mediaId}/annotations", + "PUT /api/inspections/{id}/report-link-expiry", + "PUT /api/notification-preferences", + "PUT /api/notification-preferences/bulk", + "PUT /api/notification-preferences/sms-consent", + "PUT /api/portal/{tenant}/notification-preferences", + "PUT /api/portal/{tenant}/notification-preferences/bulk", + "PUT /api/portal/{tenant}/notification-preferences/sms-consent", + "PUT /api/public/inspections/:id/documents", + "PUT /api/rating-systems/{id}", + "PUT /api/recommendations/{id}", + "PUT /api/role-profiles/{id}", + "PUT /api/tags/{id}" + ] +} diff --git a/scripts/run-gates.mjs b/scripts/run-gates.mjs index ef673ac7e..c5bf1f771 100644 --- a/scripts/run-gates.mjs +++ b/scripts/run-gates.mjs @@ -36,6 +36,7 @@ const SCRIPT_GATES = [ { key: 'migrefs', label: 'Migration-reference hygiene', script: 'check-migration-refs.mjs', fix: 'npm run lint:migrefs' }, { key: 'filesize', label: 'Large-file ratchet', script: 'check-file-size.mjs', fix: 'npm run lint:filesize' }, { key: 'tz', label: 'Calendar timezone-safety', script: 'check-tz-safety.mjs', fix: 'npm run lint:tz' }, + { key: 'idempotency', label: 'Mutating-route retry safety', script: 'check-idempotency-coverage.mjs', fix: 'npm run lint:idempotency' }, ]; const DUP_GATE = { key: 'dup', label: 'Duplicate-code ceiling', fix: 'npm run lint:dup' }; From ebd626005a8da5bc83bc673d9fc316047111827c Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:10:56 +0800 Subject: [PATCH 10/77] chore(gates): wire lint:idempotency into both lint chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config-only, in its own commit: package.json in a diff escalates the pre-commit type-check to the full tier, so it does not ride along with code. Registered in `lint` AND `lint:gates-full` — the two chains are hand-duplicated, and a gate added to one drifts silently out of the other. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 02ab9b488..fbfbe0b9d 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:i18n-glossary && 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:idempotency && 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", @@ -58,6 +58,7 @@ "lint:deadcode": "node scripts/check-deadcode.mjs", "lint:timestamps": "node scripts/check-timestamps.mjs", "lint:tz": "node scripts/check-tz-safety.mjs", + "lint:idempotency": "node scripts/check-idempotency-coverage.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", @@ -95,7 +96,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:i18n-glossary && 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:idempotency && 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": { From 95f794315fa5bd64217ab24e9e32ea4cce6be946 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:13:44 +0800 Subject: [PATCH 11/77] feat(ai): receive delivered AI allowances, without a migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portal's tier console owns the numbers and records who set them; core needed somewhere to put them and a command case to receive them. Storage is the existing tenant_configs.integration_config blob under a reserved key, so this lands with no schema change. Two properties of that column make sharing it safe, and both are load-bearing, so both are asserted rather than described: the tenant-facing writer MERGES over the stored object (a Settings save cannot drop a key it does not know about), and the tenant-facing route validates against a closed Zod object that STRIPS unknown keys (a tenant cannot write their own allowance). No cap constant appears anywhere. An absent key, an absent tier, an absent metric and an unparseable blob all mean the same thing — unconfigured, so unenforced. Caps resolve through a loader rather than at guard construction, so the per-request site pays no read and no tenant's caps can bind to another tenant's check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- server/api/admin/admin-config.ts | 2 +- server/features/plan-quota/ai-caps.ts | 135 ++++++++++++++++++++ server/lib/sync-events/cmd-envelope.ts | 27 ++++ server/portal/apply-commands.ts | 24 ++++ server/portal/cmd-consumer.ts | 15 ++- tests/unit/usage/ai-caps-storage.spec.ts | 119 ++++++++++++++++++ tests/workers/cmd-ai-caps.spec.ts | 151 +++++++++++++++++++++++ 7 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 server/features/plan-quota/ai-caps.ts create mode 100644 tests/unit/usage/ai-caps-storage.spec.ts create mode 100644 tests/workers/cmd-ai-caps.spec.ts diff --git a/server/api/admin/admin-config.ts b/server/api/admin/admin-config.ts index 964dd813c..c48b5fc8a 100644 --- a/server/api/admin/admin-config.ts +++ b/server/api/admin/admin-config.ts @@ -23,7 +23,7 @@ import { getDrizzle } from '../../lib/route-helpers'; // ─── Integration Config & Secrets ──────────────────────────────────────────── -const IntegrationConfigSchema = z.object({ +export const IntegrationConfigSchema = z.object({ // closed on purpose: features/plan-quota/ai-caps.ts appBaseUrl: z.string().optional().describe('TODO describe appBaseUrl field for the OpenInspection MCP integration'), turnstileSiteKey: z.string().optional().describe('TODO describe turnstileSiteKey field for the OpenInspection MCP integration'), googleClientId: z.string().optional().describe('TODO describe googleClientId field for the OpenInspection MCP integration'), diff --git a/server/features/plan-quota/ai-caps.ts b/server/features/plan-quota/ai-caps.ts new file mode 100644 index 000000000..46fe8105d --- /dev/null +++ b/server/features/plan-quota/ai-caps.ts @@ -0,0 +1,135 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { eq } from 'drizzle-orm'; +import { tenantConfigs, tenants } from '../../lib/db/schema'; +import type { AiCappedMetric, AiTierCaps } from './policy'; + +/** + * Where a DELIVERED AI allowance lives on the core side, and the only two + * functions that touch it. + * + * The numbers are owned by portal's tier console (which records who set them + * and when) and reach core as per-tenant commands — the command queue is + * per-tenant by construction, so a tier cap arrives fanned out, one envelope + * per tenant, carrying the tier it was computed for. + * + * Storage is `tenant_configs.integration_config`, the existing per-tenant JSON + * config blob, under a reserved key. Two properties of that column make it + * safe to share, and both are load-bearing: + * 1. The tenant-facing writer (`BrandingService.updateIntegrationConfig`) + * MERGES over the stored object, so a Settings save cannot drop a key it + * does not know about. + * 2. The tenant-facing route validates its body against a closed Zod object + * (`IntegrationConfigSchema` in api/admin/admin-config.ts), which STRIPS + * unknown keys — so a tenant cannot write their own allowance. + * `tests/unit/usage/ai-caps-storage.spec.ts` asserts both; if either changes, + * this key needs a different home rather than a bigger comment. + * + * No cap constant appears in this file. An absent key, an absent tier, an + * absent metric and an unparseable blob all mean the same thing — nothing is + * configured, so nothing is enforced (see `PlanQuotaGuard.checkAiQuota`). + */ + +/** Reserved key inside `tenant_configs.integration_config`. */ +export const AI_CAPS_CONFIG_KEY = 'platformAiCaps'; + +/** The metrics a cap can be expressed against, as data. Anything else that + * arrives is dropped: a metric this build cannot enforce must not be stored + * as though it could be. */ +const CAPPED_METRICS: readonly AiCappedMetric[] = ['ai_translate', 'ai_assist']; + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** A cap is a non-negative integer. `0` is a real value ("no managed AI for + * this tier"), which is why the check is on finiteness and sign, not truth. */ +function isCapValue(v: unknown): v is number { + return typeof v === 'number' && Number.isInteger(v) && v >= 0; +} + +/** Narrow an arbitrary stored/delivered value to the caps the guard can read. + * Tolerant: unknown tiers pass through (tiers are open-ended strings), unknown + * metrics and malformed numbers are dropped, and a tier left with no metrics + * is dropped with them. Returns undefined when nothing survives — the + * "unconfigured" state, which must not be representable as an empty object + * that a caller might read as "configured to nothing". */ +export function narrowAiTierCaps(raw: unknown): AiTierCaps | undefined { + if (!isRecord(raw)) return undefined; + const out: Record>> = {}; + for (const [tier, metrics] of Object.entries(raw)) { + if (!isRecord(metrics)) continue; + const kept: Partial> = {}; + for (const metric of CAPPED_METRICS) { + const value = metrics[metric]; + if (isCapValue(value)) kept[metric] = value; + } + if (Object.keys(kept).length > 0) out[tier] = kept; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseConfig(raw: string | null | undefined): Record { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** The caps configured for a tenant, or undefined when none are. */ +export async function readTenantAiCaps(db: D1Database, tenantId: string): Promise { + const row = await drizzle(db) + .select({ integrationConfig: tenantConfigs.integrationConfig }) + .from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, tenantId)) + .get(); + return narrowAiTierCaps(parseConfig(row?.integrationConfig)[AI_CAPS_CONFIG_KEY]); +} + +/** The loader shape `PlanQuotaGuard` takes: resolution is deferred to the + * moment an AI quota is actually checked, so the guard's construction sites + * — one of which runs on every authenticated request, and one of which is + * reused across every tenant in a cron tick — pay no read for it and none of + * them can bind one tenant's caps to another tenant's check. */ +export function tenantAiCapsLoader(db: D1Database): (tenantId: string) => Promise { + return (tenantId: string) => readTenantAiCaps(db, tenantId); +} + +/** + * Replace a tenant's stored caps with `caps` (undefined clears them). + * + * Whole-set replacement, not a merge: the delivered command carries the + * complete set the tenant should have, so clearing a cap needs no tombstone. + * The surrounding `integration_config` object IS merged — everything else in + * that column belongs to the tenant. + * + * Returns 'tenant-not-found' when the tenant row is absent; the caller decides + * whether that is a retry (the caps raced ahead of the tenant upsert) or not. + */ +export async function writeTenantAiCaps( + db: D1Database, + tenantId: string, + caps: AiTierCaps | undefined, +): Promise<'applied' | 'tenant-not-found'> { + const d = drizzle(db); + const tenant = await d.select({ id: tenants.id }).from(tenants).where(eq(tenants.id, tenantId)).get(); + if (!tenant) return 'tenant-not-found'; + + const row = await d.select({ integrationConfig: tenantConfigs.integrationConfig }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const config = parseConfig(row?.integrationConfig); + if (caps) config[AI_CAPS_CONFIG_KEY] = caps; + else delete config[AI_CAPS_CONFIG_KEY]; + + const serialized = Object.keys(config).length > 0 ? JSON.stringify(config) : null; + const now = new Date(); + await d.insert(tenantConfigs) + .values({ tenantId, integrationConfig: serialized, updatedAt: now }) + .onConflictDoUpdate({ + target: tenantConfigs.tenantId, + set: { integrationConfig: serialized, updatedAt: now }, + }); + return 'applied'; +} diff --git a/server/lib/sync-events/cmd-envelope.ts b/server/lib/sync-events/cmd-envelope.ts index 293884dc5..bdfa07bde 100644 --- a/server/lib/sync-events/cmd-envelope.ts +++ b/server/lib/sync-events/cmd-envelope.ts @@ -17,6 +17,10 @@ const KNOWN_CMD_TYPES: Record = { // A-21 batch 3 — offboarding data plane. 'io.inspectorhub.cmd.tenant.data_export': ['cmd-tenant-data-export/v1'], 'io.inspectorhub.cmd.tenant.purge': ['cmd-tenant-purge/v1'], + // Managed-AI provider tier — per-tier AI allowances, fanned out per tenant + // (the queue has no platform-scoped command and adding one would change the + // envelope contract both sides validate). + 'io.inspectorhub.cmd.tenant.ai_caps': ['cmd-tenant-ai-caps/v1'], }; const cmdEnvelopeSchema = z.object({ @@ -67,6 +71,29 @@ export const cmdDataExportDataSchema = z.object({ export const cmdPurgeDataSchema = z.object({ tenantId: z.string(), }); +/** + * Managed-AI provider tier — the caps that apply to THIS tenant, plus the tier + * they were computed for. + * + * `caps` is the COMPLETE set: core replaces what it holds, so clearing a cap is + * sending it as null (or omitting it), never a tombstone. The tier travels with + * the numbers because the guard looks caps up as `caps[tier][metric]` — if the + * tenant is moved to another tier before the next fan-out reaches them, the + * lookup misses and they are simply unenforced, which is the safe direction. + * OI receives NUMBERS, never a plan name to interpret. + * + * `caps` is a loose record on purpose (tolerant reader): a newer portal may name + * a metric this build cannot enforce, and the applier drops those rather than + * rejecting the whole command. Values are validated where they are stored. + * Ordering rides the shared per-tenant `tenantseq` — an AI cap is ordinary + * tenant state, so last-writer-wins under `tenants.applied_cmd_seq` is exactly + * right and it needs no private sequence the way credentials do. + */ +export const cmdTenantAiCapsDataSchema = z.object({ + tenantId: z.string(), + tier: z.string().min(1), + caps: z.record(z.string(), z.unknown()), +}); export function parseCmdEnvelope(json: unknown): CmdEnvelope | null { let candidate: unknown = json; diff --git a/server/portal/apply-commands.ts b/server/portal/apply-commands.ts index 3f5427dc6..2c2265518 100644 --- a/server/portal/apply-commands.ts +++ b/server/portal/apply-commands.ts @@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm'; import { tenants } from '../lib/db/schema'; import { logger } from '../lib/logger'; import { PortalProvider } from './portal.provider'; +import { narrowAiTierCaps, writeTenantAiCaps } from '../features/plan-quota/ai-caps'; import type { TenantUpdateParams } from '../lib/integration'; /** @@ -32,6 +33,29 @@ export async function applySyncQuota( return 'applied'; } +/** AI-cap apply (managed-AI provider tier): store the delivered allowances + * where `PlanQuotaGuard` reads them, and invalidate the tenant KV cache for + * the same reason sync-quota does. The narrowing lives with the storage + * (features/plan-quota/ai-caps.ts) so the read and the write can never + * disagree about which metrics are real. */ +export async function applyAiCaps( + dbBinding: D1Database, + kv: KVNamespace | undefined, + p: { tenantId: string; tier: string; caps: Record }, +): Promise<'applied' | 'tenant-not-found'> { + const caps = narrowAiTierCaps({ [p.tier]: p.caps }); + const result = await writeTenantAiCaps(dbBinding, p.tenantId, caps); + if (result === 'tenant-not-found') return result; + try { + await kv?.delete(`tenant:${p.tenantId}`); + } catch { /* cache miss is fine — read-through repopulates */ } + // The numbers themselves are operator-set configuration, not tenant data, + // so they are safe to log and worth logging: an unexplained block is the + // failure mode this whole path exists to make explicable. + logger.info('ai-caps applied', { tenantId: p.tenantId, tier: p.tier, caps: caps?.[p.tier] ?? null }); + return 'applied'; +} + /** Tenant upsert apply — delegates to the same PortalProvider the DI container * wires behind AdminService.handleTenantUpdate in saas mode. */ export async function applyTenantUpdate( diff --git a/server/portal/cmd-consumer.ts b/server/portal/cmd-consumer.ts index 600285ca8..15115dc7a 100644 --- a/server/portal/cmd-consumer.ts +++ b/server/portal/cmd-consumer.ts @@ -5,10 +5,11 @@ import { logger } from '../lib/logger'; import { parseCmdEnvelope, isKnownCmd, cmdTenantUpdateDataSchema, cmdSyncQuotaDataSchema, cmdSeedStarterContentDataSchema, cmdDataExportDataSchema, cmdPurgeDataSchema, + cmdTenantAiCapsDataSchema, type CmdEnvelope, } from '../lib/sync-events/cmd-envelope'; import type { SyncEnvelope } from '../lib/sync-events/envelope'; -import { applySyncQuota, applyTenantUpdate, applySeedStarterContent } from './apply-commands'; +import { applySyncQuota, applyTenantUpdate, applySeedStarterContent, applyAiCaps } from './apply-commands'; import { applyCredentialIfFresh } from './admin-credential'; import { OutboxService, type OutboxRow } from './outbox.service'; @@ -235,6 +236,18 @@ async function applyKnownCmd( const result = await new TenantPurgeService(dbBinding, buckets.photos, kv).purge(data.tenantId); return { ...result }; } + case 'io.inspectorhub.cmd.tenant.ai_caps': { + const data = cmdTenantAiCapsDataSchema.parse(env.data); + const result = await applyAiCaps(dbBinding, kv, data); + if (result === 'tenant-not-found') { + // Same reasoning as sync_quota: the caps fan-out may have raced + // ahead of the tenant upsert, so throw and let the retry give it + // time to land rather than writing a config row for a tenant + // that does not exist. + throw new Error(`ai_caps: tenant not found ${data.tenantId}`); + } + return; + } case 'io.inspectorhub.cmd.tenant.sync_quota': { const data = cmdSyncQuotaDataSchema.parse(env.data); const result = await applySyncQuota(dbBinding, kv, data); diff --git a/tests/unit/usage/ai-caps-storage.spec.ts b/tests/unit/usage/ai-caps-storage.spec.ts new file mode 100644 index 000000000..4c269b371 --- /dev/null +++ b/tests/unit/usage/ai-caps-storage.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +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 { + AI_CAPS_CONFIG_KEY, + narrowAiTierCaps, + readTenantAiCaps, + writeTenantAiCaps, +} from '../../../server/features/plan-quota/ai-caps'; +import { BrandingService } from '../../../server/services/branding.service'; +import { IntegrationConfigSchema } from '../../../server/api/admin/admin-config'; + +/** + * Where a delivered AI allowance is stored, and the two properties that make it + * safe to keep in a column the tenant also writes to. + * + * The caps are a platform control on a paying customer: portal's console + * records who set one, and core enforces it. Sharing `integration_config` with + * tenant-owned settings is only defensible while a tenant can neither clobber + * the value by saving their own settings nor write one for themselves — so + * both are asserted here rather than described in a comment. + */ +describe('AI cap storage', () => { + let testDb: BetterSQLite3Database; + let testD1: D1Database; + const T = 'tenant-caps'; + + 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); + await testDb.insert(schema.tenants).values({ + id: T, name: 'Caps Co', slug: 'caps-co', tier: 'pro', status: 'active', createdAt: new Date(), + }); + }); + + describe('narrowing', () => { + it('keeps only cappable metrics with non-negative integer values', () => { + expect(narrowAiTierCaps({ + pro: { ai_translate: 500, ai_assist: 0, ai_hologram: 7, ai_translate_byo: 9 }, + })).toEqual({ pro: { ai_translate: 500, ai_assist: 0 } }); + }); + + it('drops values that are not a count', () => { + // A cap arriving as "500" or 12.5 or -1 is a producer bug. Coercing + // it would enforce a number nobody set; dropping it leaves the tier + // unenforced, which is the state the operator can see and fix. + expect(narrowAiTierCaps({ pro: { ai_translate: '500' } })).toBeUndefined(); + expect(narrowAiTierCaps({ pro: { ai_translate: 12.5 } })).toBeUndefined(); + expect(narrowAiTierCaps({ pro: { ai_translate: -1 } })).toBeUndefined(); + }); + + it('reads an empty set as unconfigured, not as "configured to nothing"', () => { + expect(narrowAiTierCaps({})).toBeUndefined(); + expect(narrowAiTierCaps({ pro: {} })).toBeUndefined(); + expect(narrowAiTierCaps(null)).toBeUndefined(); + }); + }); + + describe('the column', () => { + it('round-trips a cap, keyed by the tier it was computed for', async () => { + expect(await writeTenantAiCaps(testD1, T, { pro: { ai_translate: 500 } })).toBe('applied'); + expect(await readTenantAiCaps(testD1, T)).toEqual({ pro: { ai_translate: 500 } }); + }); + + it('reads a tenant with no config row, and a corrupt blob, as unconfigured', async () => { + expect(await readTenantAiCaps(testD1, T)).toBeUndefined(); + await testDb.insert(schema.tenantConfigs) + .values({ tenantId: T, integrationConfig: 'not json', updatedAt: new Date() }); + expect(await readTenantAiCaps(testD1, T)).toBeUndefined(); + }); + + it('refuses to write for a tenant that does not exist', async () => { + expect(await writeTenantAiCaps(testD1, 'ghost', { pro: { ai_assist: 1 } })).toBe('tenant-not-found'); + }); + + it('clears back to unconfigured', async () => { + await writeTenantAiCaps(testD1, T, { pro: { ai_translate: 500 } }); + await writeTenantAiCaps(testD1, T, undefined); + expect(await readTenantAiCaps(testD1, T)).toBeUndefined(); + }); + }); + + describe('the tenant cannot touch it', () => { + it('survives a tenant saving their own integration settings', async () => { + await writeTenantAiCaps(testD1, T, { pro: { ai_translate: 500 } }); + + // The Settings-UI write path, verbatim: it merges over the stored + // object. If it ever starts overwriting, the operator's cap + // disappears the next time the tenant saves an unrelated field — + // silently, and in the tenant's favour. + await new BrandingService(testD1).updateIntegrationConfig(T, { appBaseUrl: 'https://tenant.example' }); + + expect(await readTenantAiCaps(testD1, T)).toEqual({ pro: { ai_translate: 500 } }); + const cfg = await new BrandingService(testD1).getIntegrationConfig(T); + expect((cfg as Record)['appBaseUrl']).toBe('https://tenant.example'); + }); + + it('cannot be written through the tenant-facing config route', () => { + // The route validates its body against a CLOSED object, so the + // reserved key never reaches the merge. This is the assertion that + // keeps the shared column honest: make that schema permissive and a + // workspace owner can raise their own allowance. + const parsed = IntegrationConfigSchema.parse({ + appBaseUrl: 'https://tenant.example', + [AI_CAPS_CONFIG_KEY]: { pro: { ai_translate: 999_999 } }, + }); + expect(parsed).toEqual({ appBaseUrl: 'https://tenant.example' }); + expect(AI_CAPS_CONFIG_KEY in parsed).toBe(false); + }); + }); +}); diff --git a/tests/workers/cmd-ai-caps.spec.ts b/tests/workers/cmd-ai-caps.spec.ts new file mode 100644 index 000000000..78f8f205f --- /dev/null +++ b/tests/workers/cmd-ai-caps.spec.ts @@ -0,0 +1,151 @@ +// Per-tenant AI-cap delivery over the portal→core command queue (managed-AI +// provider tier, Task 5 Step 4) under real workerd. +// +// There is no producer yet — portal ships the tier console, the storage and +// the audit trail, and delivery was deferred to this side precisely so the +// receiver would exist before the first envelope was emitted (an unknown type +// parks, which looks like success from the producer). The contract is +// therefore pinned CONSUMER-FIRST, from fixture envelopes: type name, +// dataschema version, payload shape, and the dedup/stale semantics it +// inherits from the shared per-tenant sequence. +import { env } from 'cloudflare:test'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { applyCmdEnvelope } from '../../server/portal/cmd-consumer'; +import { readTenantAiCaps } from '../../server/features/plan-quota/ai-caps'; +import { TENANT_CONFIGS_TEST_DDL } from '../helpers/inline-ddl'; + +const b = env as unknown as { DB: D1Database }; + +const T = 'ct-caps'; + +function envelope( + over: Partial<{ id: string; type: string; dataschema: string; tenantseq: number; data: Record }> = {}, +) { + return { + specversion: '1.0', + id: over.id ?? crypto.randomUUID(), + type: over.type ?? 'io.inspectorhub.cmd.tenant.ai_caps', + source: 'portal', + time: '2026-08-06T00:00:00.000Z', + dataschema: over.dataschema ?? 'cmd-tenant-ai-caps/v1', + tenantseq: over.tenantseq ?? 1, + data: over.data ?? { tenantId: T, tier: 'pro', caps: { ai_translate: 500 } }, + }; +} + +async function seedSchema(): Promise { + await b.DB.exec( + "CREATE TABLE IF NOT EXISTS tenants (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, tier TEXT NOT NULL DEFAULT 'free', stripe_connect_account_id TEXT, status TEXT NOT NULL DEFAULT 'pending', max_users INTEGER NOT NULL DEFAULT 5, deployment_mode TEXT NOT NULL DEFAULT 'shared', applied_cmd_seq INTEGER NOT NULL DEFAULT 0, applied_cred_seq INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);", + ); + 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);', + ); + await b.DB.exec(TENANT_CONFIGS_TEST_DDL); +} + +async function reset(): Promise { + for (const t of ['processed_cmd_events', 'parked_cmd_events', 'tenant_configs', 'tenants']) { + await b.DB.exec(`DELETE FROM ${t};`); + } + await b.DB.prepare( + "INSERT INTO tenants (id, name, slug, tier, status, max_users, deployment_mode, applied_cmd_seq, applied_cred_seq, created_at) VALUES (?1, 'Caps Co', 'caps-co', 'pro', 'active', 5, 'shared', 0, 0, ?2)", + ).bind(T, Date.now()).run(); +} + +async function integrationConfigOf(tenantId: string): Promise | null> { + const row = await b.DB.prepare('SELECT integration_config AS c FROM tenant_configs WHERE tenant_id = ?1') + .bind(tenantId).first<{ c: string | null }>(); + return row?.c ? (JSON.parse(row.c) as Record) : null; +} + +describe('cmd.tenant.ai_caps — per-tenant AI cap delivery (real D1)', () => { + beforeAll(seedSchema); + beforeEach(reset); + + it('stores the delivered caps where the guard reads them, and advances applied_cmd_seq', async () => { + const res = await applyCmdEnvelope(b.DB, undefined, envelope({ tenantseq: 4 })); + expect(res).toBe('applied'); + // Keyed by TIER, because that is the shape the guard looks up + // (`aiCaps[tier][metric]`) — a per-tenant row carrying the tier it was + // computed for, not a bare number whose tier nobody recorded. + expect(await readTenantAiCaps(b.DB, T)).toEqual({ pro: { ai_translate: 500 } }); + const seq = await b.DB.prepare('SELECT applied_cmd_seq AS s FROM tenants WHERE id = ?1') + .bind(T).first<{ s: number }>(); + expect(seq?.s).toBe(4); + }); + + it('leaves the rest of the tenant config untouched (read-modify-write, not overwrite)', async () => { + await b.DB.prepare( + 'INSERT INTO tenant_configs (tenant_id, integration_config, updated_at) VALUES (?1, ?2, ?3)', + ).bind(T, JSON.stringify({ appBaseUrl: 'https://tenant.example' }), Date.now()).run(); + + await applyCmdEnvelope(b.DB, undefined, envelope({ tenantseq: 2 })); + + const cfg = await integrationConfigOf(T); + expect(cfg?.['appBaseUrl']).toBe('https://tenant.example'); + expect(await readTenantAiCaps(b.DB, T)).toEqual({ pro: { ai_translate: 500 } }); + }); + + it('replaces the whole cap set — an empty payload clears back to unenforced', async () => { + await applyCmdEnvelope(b.DB, undefined, envelope({ tenantseq: 1 })); + expect(await readTenantAiCaps(b.DB, T)).toEqual({ pro: { ai_translate: 500 } }); + + // Clearing is not a tombstone: the command carries the COMPLETE set the + // tenant should have, so "no caps" is an empty set. Absence has to read + // back as absence, or a cleared cap would keep enforcing. + const res = await applyCmdEnvelope(b.DB, undefined, envelope({ + tenantseq: 2, data: { tenantId: T, tier: 'pro', caps: {} }, + })); + expect(res).toBe('applied'); + expect(await readTenantAiCaps(b.DB, T)).toBeUndefined(); + }); + + it('drops a metric this build does not cap rather than storing it', async () => { + // Tolerant reader: a newer portal may name a metric this core has never + // heard of. Storing it would put an unenforceable number in the config + // that later reads would have to guess about. + const res = await applyCmdEnvelope(b.DB, undefined, envelope({ + tenantseq: 1, data: { tenantId: T, tier: 'pro', caps: { ai_translate: 300, ai_hologram: 7 } }, + })); + expect(res).toBe('applied'); + expect(await readTenantAiCaps(b.DB, T)).toEqual({ pro: { ai_translate: 300 } }); + }); + + it('a redelivered envelope id is a duplicate and changes nothing', async () => { + const id = 'cap-env-1'; + await applyCmdEnvelope(b.DB, undefined, envelope({ id, tenantseq: 3 })); + const res = await applyCmdEnvelope(b.DB, undefined, envelope({ + id, tenantseq: 9, data: { tenantId: T, tier: 'pro', caps: { ai_translate: 999 } }, + })); + expect(res).toBe('duplicate'); + expect(await readTenantAiCaps(b.DB, T)).toEqual({ pro: { ai_translate: 500 } }); + }); + + it('a stale tenantseq is dropped — an old cap cannot overwrite a newer one', async () => { + await applyCmdEnvelope(b.DB, undefined, envelope({ tenantseq: 5 })); + const res = await applyCmdEnvelope(b.DB, undefined, envelope({ + tenantseq: 4, data: { tenantId: T, tier: 'pro', caps: { ai_translate: 900 } }, + })); + expect(res).toBe('stale'); + expect(await readTenantAiCaps(b.DB, T)).toEqual({ pro: { ai_translate: 500 } }); + }); + + it('parks an unknown dataschema version instead of applying it', async () => { + const res = await applyCmdEnvelope(b.DB, undefined, envelope({ + dataschema: 'cmd-tenant-ai-caps/v2', tenantseq: 1, + })); + expect(res).toBe('parked'); + const parked = await b.DB.prepare('SELECT reason FROM parked_cmd_events').first<{ reason: string }>(); + expect(parked?.reason).toBe('unknown-type-or-version'); + expect(await readTenantAiCaps(b.DB, T)).toBeUndefined(); + }); + + it('throws for an unknown tenant so the queue retries rather than inventing a config row', async () => { + await expect(applyCmdEnvelope(b.DB, undefined, envelope({ + tenantseq: 1, data: { tenantId: 'no-such-tenant', tier: 'pro', caps: { ai_translate: 1 } }, + }))).rejects.toThrow(/tenant not found/); + }); +}); From dfa9e6d2913a9f2edef400fb83dc19769ec0a986 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:23:26 +0800 Subject: [PATCH 12/77] feat(ai): resolve delivered AI caps per check, at all seven guard sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard could already read an allowance; nothing gave it one. All seven production construction sites now pass tenantAiCapsLoader, and checkAiQuota resolves it at the moment of the check. A loader rather than a value, because two of those seven sites make an eager read wrong rather than merely wasteful: the DI middleware constructs a guard on every authenticated request, and the cron tick reuses one guard across every tenant — where a single resolved value would bind the first tenant's caps to everybody else's check. Tests keep passing an object, which is what makes a configured-cap control cheap to write. managedEntitled is still a literal false, so no cap is reachable in production yet; this is the path being ready, not switched on. No cap constant is introduced anywhere: an absent loader, an absent tier and an absent metric all mean unenforced, and FREE_TIER_CAPS is asserted to carry no AI entry so that "no managed allowance" cannot silently inherit one. Seen RED at "promise resolved undefined instead of rejecting" with the resolution removed — while the fourteen object-shaped cases stayed green, which is precisely why the loader case had to be added. Baseline +1 on sms.ts and di.ts: one import line each into files already grandfathered at 844 and 433. Splitting either is a refactor this change does not justify. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- scripts/file-size-baseline.json | 4 +- server/api/inspections/send-sms.ts | 3 +- server/api/message-templates.ts | 5 ++- server/api/sms.ts | 3 +- server/features/plan-quota/guard.ts | 17 ++++++-- server/lib/middleware/di.ts | 3 +- server/scheduled.ts | 3 +- server/workflows/sign-completion-workflow.ts | 3 +- tests/unit/usage/ai-quota.spec.ts | 42 ++++++++++++++++++++ 9 files changed, 71 insertions(+), 12 deletions(-) diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 9f95b9c9a..48d9dae5b 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -7,7 +7,7 @@ "server/durable-objects/inspection-doc.ts": 928, "app/routes/inspections.tsx": 879, "server/lib/collab/results-doc.ts": 874, - "server/api/sms.ts": 843, + "server/api/sms.ts": 844, "app/components/portal/sections/ReportView.tsx": 813, "app/routes/settings-communication.tsx": 777, "server/api/admin/admin-settings.ts": 754, @@ -61,7 +61,7 @@ "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, + "server/lib/middleware/di.ts": 433, "app/routes/public/portal-inspection.tsx": 430, "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, diff --git a/server/api/inspections/send-sms.ts b/server/api/inspections/send-sms.ts index e2d519eee..8d9458221 100644 --- a/server/api/inspections/send-sms.ts +++ b/server/api/inspections/send-sms.ts @@ -26,6 +26,7 @@ import { resolveRoleSmsTemplate } from '../../lib/people/role-template'; import { sendOneSms } from '../../services/automation/send-one-sms'; import { loadProviderForTenant } from '../../lib/sms/resolve-twilio'; import { PlanQuotaGuard } from '../../features/plan-quota/guard'; +import { tenantAiCapsLoader } from '../../features/plan-quota/ai-caps'; import { MeteringService } from '../../services/metering.service'; const DEFAULT_SMS_BODY = @@ -87,7 +88,7 @@ const sendSmsRoutes = createApiRouter() const deployProfile = c.var.profile; const quotaGuard = deployProfile?.hasUsageQuota - ? new PlanQuotaGuard(rawDb, { enforced: true, billingPortalUrl: deployProfile.billingPortalUrl }) + ? new PlanQuotaGuard(rawDb, { enforced: true, billingPortalUrl: deployProfile.billingPortalUrl, aiCaps: tenantAiCapsLoader(rawDb) }) : undefined; const metering = deployProfile?.hasUsageQuota ? new MeteringService(rawDb) : undefined; diff --git a/server/api/message-templates.ts b/server/api/message-templates.ts index b2c898163..65dfd3240 100644 --- a/server/api/message-templates.ts +++ b/server/api/message-templates.ts @@ -7,6 +7,7 @@ import { smsSegmentInfo } from '../lib/sms/segments'; import { interpolate } from '../services/automation/shared'; import { buildTenantEmailService } from '../lib/email/build-email-service'; import { PlanQuotaGuard, readTenantTier } from '../features/plan-quota/guard'; +import { tenantAiCapsLoader } from '../features/plan-quota/ai-caps'; import { loadProviderForTenant } from '../lib/sms/resolve-twilio'; import { normalizeE164 } from '../lib/sms/phone'; import { smsSendGate } from '../lib/sms/send-gate'; @@ -163,7 +164,7 @@ const messageTemplateRoutes = createApiRouter() // being exempt from whatever nobody remembered to copy across. const db = getDrizzle(c); const quotaGuard = c.var.profile.hasUsageQuota - ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }) + ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl, aiCaps: tenantAiCapsLoader(c.env.DB) }) : undefined; const gate = await smsSendGate({ db, tenantId, to: normalized, purpose: 'test', env: c.env, @@ -203,7 +204,7 @@ const messageTemplateRoutes = createApiRouter() // fall back to the one-shot tier lookup (mirrors di.ts's request-context // resolution). const quotaGuard = c.var.profile.hasUsageQuota - ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }) + ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl, aiCaps: tenantAiCapsLoader(c.env.DB) }) : undefined; const tenantTier = quotaGuard ? (c.get('tenantTier') ?? await readTenantTier(c.env.DB, tenantId)) diff --git a/server/api/sms.ts b/server/api/sms.ts index 8f422ac81..7e1a35fe4 100644 --- a/server/api/sms.ts +++ b/server/api/sms.ts @@ -42,6 +42,7 @@ import { resolveComplianceProvider } from '../lib/sms/resolve-compliance-provide import { recordIntegrationTest } from '../lib/integration-test-results'; import { smsSendGate } from '../lib/sms/send-gate'; import { PlanQuotaGuard, readTenantTier } from '../features/plan-quota/guard'; +import { tenantAiCapsLoader } from '../features/plan-quota/ai-caps'; import { complianceWebhookUrl } from '../lib/sms/compliance-webhook'; import { getBaseUrl } from '../lib/url'; import { resolveTenantLegalUrls } from '../lib/legal-links'; @@ -509,7 +510,7 @@ export const smsAdminRoutes = createApiRouter() // of the three with no STOP-revocation check. `tenantTier` is unset by // session-context here, so fall back to a one-shot lookup. const quotaGuard = c.var.profile.hasUsageQuota - ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }) + ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl, aiCaps: tenantAiCapsLoader(c.env.DB) }) : undefined; const gate = await smsSendGate({ db, tenantId, to: normalized, purpose: 'test', env: c.env, diff --git a/server/features/plan-quota/guard.ts b/server/features/plan-quota/guard.ts index 1582c65f0..521d4436a 100644 --- a/server/features/plan-quota/guard.ts +++ b/server/features/plan-quota/guard.ts @@ -48,8 +48,16 @@ export class PlanQuotaGuard { 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; + * Absent/empty means no AI enforcement — see `checkAiQuota`. + * + * Either the caps themselves, or a loader that fetches them for one + * tenant. Production passes the loader (`tenantAiCapsLoader`): two of the + * seven construction sites are hostile to an eager read — one runs on + * every authenticated request, and one is reused across every tenant in a + * cron tick, where a single resolved value would bind the first tenant's + * caps to everyone else's check. Tests pass the object, which is what + * keeps a configured-cap control cheap to write. */ + aiCaps?: AiTierCaps | ((tenantId: string) => Promise); }, ) {} @@ -138,7 +146,10 @@ export class PlanQuotaGuard { * 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]; + const caps = typeof this.opts.aiCaps === 'function' + ? await this.opts.aiCaps(tenantId) + : this.opts.aiCaps; + const cap = caps?.[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/lib/middleware/di.ts b/server/lib/middleware/di.ts index 3ea3f2194..28f9e8997 100644 --- a/server/lib/middleware/di.ts +++ b/server/lib/middleware/di.ts @@ -63,6 +63,7 @@ import { ComplianceService } from '../../services/compliance/pca-compliance.serv import { StandaloneProvider } from '../integration/standalone'; import { PortalProvider } from '../../portal/portal.provider'; import { PlanQuotaGuard, readTenantTier } from '../../features/plan-quota/guard'; +import { tenantAiCapsLoader } from '../../features/plan-quota/ai-caps'; /** * Middleware that injects a lazy-loaded service registry into the Hono context. @@ -137,7 +138,7 @@ export async function diMiddleware(c: Context, next: Next) { // InspectionRequestService (multi-service request + append-a-sub-inspection). const buildPlanQuota = (): PlanQuotaGuard | undefined => { if (!c.var.profile.hasUsageQuota) return undefined; - return new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }); + return new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl, aiCaps: tenantAiCapsLoader(c.env.DB) }); }; const services = {} as AppServices; diff --git a/server/scheduled.ts b/server/scheduled.ts index 0eade93a6..4d5d65812 100644 --- a/server/scheduled.ts +++ b/server/scheduled.ts @@ -6,6 +6,7 @@ import { AgreementService } from './services/agreement.service'; import { buildTenantEmailService } from './lib/email/build-email-service'; import type { EmailServiceEnv } from './lib/email/build-email-service'; import { PlanQuotaGuard, readTenantTier } from './features/plan-quota/guard'; +import { tenantAiCapsLoader } from './features/plan-quota/ai-caps'; import { getDeploymentProfile } from './lib/deployment-profile'; import type { AppEnv, BrowserRun } from './types/hono'; import { QBOService } from './services/qbo.service'; @@ -196,7 +197,7 @@ export async function scheduled( // `tenant.tier` column — no extra lookup needed there. const profile = getDeploymentProfile(env as unknown as AppEnv); const quotaGuard = profile.hasUsageQuota - ? new PlanQuotaGuard(env.DB, { enforced: true, billingPortalUrl: profile.billingPortalUrl }) + ? new PlanQuotaGuard(env.DB, { enforced: true, billingPortalUrl: profile.billingPortalUrl, aiCaps: tenantAiCapsLoader(env.DB) }) : undefined; const appBaseUrl = env.APP_BASE_URL || ''; // Spec 2 Task 2b — report.published PDF-email delivery deps. Guarded on diff --git a/server/workflows/sign-completion-workflow.ts b/server/workflows/sign-completion-workflow.ts index 6cdd522e9..c1df499e4 100644 --- a/server/workflows/sign-completion-workflow.ts +++ b/server/workflows/sign-completion-workflow.ts @@ -9,6 +9,7 @@ import { buildEvidencePack } from '../services/evidence-pack.service'; import { buildTenantEmailService } from '../lib/email/build-email-service'; import { getDeploymentProfile } from '../lib/deployment-profile'; import { PlanQuotaGuard, readTenantTier } from '../features/plan-quota/guard'; +import { tenantAiCapsLoader } from '../features/plan-quota/ai-caps'; import { drizzle } from 'drizzle-orm/d1'; import { and, eq } from 'drizzle-orm'; import * as schema from '../lib/db/schema'; @@ -230,7 +231,7 @@ export class SignCompletionWorkflow extends WorkflowEntrypoint { }); await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).resolves.toBeUndefined(); }); + + /** + * The DELIVERED path. Every case above hands the guard a caps OBJECT, + * which no production site does — the seven construction sites pass a + * LOADER, because two of them (the per-request DI middleware and the + * cron tick reused across tenants) must not resolve one tenant's caps + * eagerly and hand them to another tenant's check. + * + * Without these two cases the object-shaped suite above stays green + * against a guard that silently ignores a function. + */ + it('enforces caps that arrived as a LOADER, not just as an object', async () => { + await seedAdversely(new MeteringService(testD1)); + const loader = vi.fn(async (tenantId: string) => + tenantId === T ? { pro: { ai_translate: 10_000 } } : undefined); + const g = new PlanQuotaGuard(testD1, { + enforced: true, billingPortalUrl: null, aiCaps: loader, + }); + await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).rejects.toMatchObject({ + code: 'QUOTA_EXHAUSTED', + details: { metric: 'ai_translate', used: 10_000, cap: 10_000 }, + }); + // Resolved per check, with the tenant being checked — not once at + // construction. This is the assertion that makes the shared cron + // guard safe. + expect(loader).toHaveBeenCalledWith(T); + }); + + it('a loader that reports nothing configured enforces nothing', async () => { + // The unconfigured production state today: the deployment has been + // given no allowance, so AI metering runs and AI enforcement does + // not. FREE_TIER_CAPS is untouched by any of this — it governs + // inspections/sms/email and carries no AI entry at all, which is + // what keeps "no managed allowance" from silently inheriting one. + await seedAdversely(new MeteringService(testD1)); + const g = new PlanQuotaGuard(testD1, { + enforced: true, billingPortalUrl: null, aiCaps: async () => undefined, + }); + await expect(g.checkAiQuota(T, 'pro', 'ai_translate')).resolves.toBeUndefined(); + expect(FREE_TIER_CAPS).not.toHaveProperty('ai_translate'); + expect(FREE_TIER_CAPS).not.toHaveProperty('ai_assist'); + }); }); describe('call-site metering', () => { From 77651c29501ab952d4acc05ab60fcf03e6e0fc8d Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:38:06 +0800 Subject: [PATCH 13/77] test(idempotency): verify POST /api/invoices replay writes one row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 of the burn-down starts where a duplicate costs money. Creating an invoice twice does not just duplicate a record — it doubles what the tenant believes is owed, and pushes the duplicate into their QuickBooks where a human has to unpick it. The route is tenant-authenticated, so the global mount already sits in front of it. What the mount does not tell you is whether the endpoint's side effects all happen INSIDE the guard's span: the QBO upsert is scheduled through executionCtx.waitUntil from the handler, and anything scheduled outside the guarded window would repeat on a replay while the invoice row correctly did not. So the spec drives the real router behind the real middleware over a real in-memory D1 and counts rows and provider calls. Seen RED with the guard removed from the harness, on all five containment cases — "expected [ {...}, {...} ] to have a length of 1 but got 2", "expected vi.fn() to be called 1 times, but got 2 times", and "expected 201 to be 422" for the changed-payload case. Baseline: 287 -> 286 pending, 2 verified by replay spec. --- scripts/idempotency-baseline.json | 1 - .../idempotency/invoice-create-replay.spec.ts | 188 ++++++++++++++++++ 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 tests/unit/idempotency/invoice-create-replay.spec.ts diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index cf436355b..5ef27c649 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -235,7 +235,6 @@ "POST /api/integrations/stripe/test", "POST /api/integrations/stripe/webhook", "POST /api/integrations/stripe/webhook/:tenant", - "POST /api/invoices", "POST /api/invoices/request-payment", "POST /api/invoices/{id}/mark-paid", "POST /api/invoices/{id}/mark-sent", diff --git a/tests/unit/idempotency/invoice-create-replay.spec.ts b/tests/unit/idempotency/invoice-create-replay.spec.ts new file mode 100644 index 000000000..7f2ae1ac3 --- /dev/null +++ b/tests/unit/idempotency/invoice-create-replay.spec.ts @@ -0,0 +1,188 @@ +/** + * Tier 1 of the burn-down: creating an invoice is a money document. A retried + * POST that writes a second row does not just duplicate a record — it doubles + * what the tenant believes is owed, and (when QuickBooks is connected) pushes + * the duplicate out to their books where a human has to unpick it. + * + * The route is tenant-authenticated, so `app.use('*', idempotencyGuard)` in + * server/index.ts already sits in front of it (order pinned by + * tests/unit/platform/middleware-order.spec.ts). What that mount does NOT tell + * you is whether the endpoint's side effects all happen INSIDE the guard's + * span: the QBO push is fired through `executionCtx.waitUntil` from the + * handler, and a side effect scheduled outside the guarded window would repeat + * on every replay while the invoice row correctly did not. + * + * So this drives the REAL router behind the REAL middleware over a real + * (in-memory) D1 schema, and counts rows and provider calls rather than + * trusting the 201. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { and, eq } from 'drizzle-orm'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; + +// InvoiceService resolves its own drizzle handle off the D1 binding, so the +// fixture DB is injected the way the rest of the invoice suites do it. +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import invoiceRoutes from '../../../server/api/invoices'; +import { InvoiceService } from '../../../server/services/invoice.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const OTHER_TENANT = '00000000-0000-0000-0000-000000000002'; +const USER_ID = '00000000-0000-0000-0000-000000000300'; + +let db: BetterSQLite3Database; +let qboUpsertInvoice: ReturnType; + +/** + * The mounted shape: tenant on the context first (the JWT middleware's job in + * production), then the guard, then the real router. The guard reads the tenant + * off `c.var` — mounting it before the tenant exists is the cross-tenant leak + * the middleware header warns about, so the order here is the order that ships. + */ +function buildApp(tenantId = TENANT) { + const app = new OpenAPIHono(); + app.use('*', async (c, next) => { + c.set('userRole', 'manager' as never); + c.set('tenantId', tenantId); + c.set('user', { sub: USER_ID } as never); + c.set('services', { + invoice: new InvoiceService({} as D1Database), + qbo: { upsertInvoice: qboUpsertInvoice } as never, + } as never); + await next(); + }); + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + 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; + }); + return app; +} + +// QBO_CLIENT_ID present, so the push the handler schedules is live and a +// duplicate would be observable rather than compiled out. +const ENV = { DB: {}, QBO_CLIENT_ID: 'qbo-test-client' } as never; +const CTX = { waitUntil: (p: Promise) => void p, passThroughOnException: () => {} } as never; + +const BODY = { + inspectionId: null, + clientName: 'Dana Buyer', + amountCents: 45000, + lineItems: [{ description: 'Inspection', amountCents: 45000 }], + dueDate: null, + notes: null, +}; + +function createInvoice(key: string | null, body: unknown = BODY, tenantId = TENANT) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + const req = new Request('https://acme.example.com/api/invoices', { + method: 'POST', headers, body: JSON.stringify(body), + }); + return buildApp(tenantId).fetch(req, ENV, CTX); +} + +async function invoiceRows(tenantId = TENANT) { + return db.select().from(schema.invoices).where(eq(schema.invoices.tenantId, tenantId)).all(); +} + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + qboUpsertInvoice = vi.fn().mockResolvedValue(undefined); + + for (const [id, slug] of [[TENANT, 'acme'], [OTHER_TENANT, 'globex']] as const) { + await db.insert(schema.tenants).values({ + id, name: slug, slug, 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(), + }); +}); + +describe("POST '/api/invoices' — replay does not create a second invoice", () => { + it('writes ONE invoice row when the same key is posted twice', async () => { + const first = await createInvoice('key-1'); + const second = await createInvoice('key-1'); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(await invoiceRows()).toHaveLength(1); + }); + + it('returns the SAME invoice id on the replay, and marks it as replayed', async () => { + const first = await createInvoice('key-1'); + const second = await createInvoice('key-1'); + + const a = await first.json() as { data: { invoice: { id: string } } }; + const b = await second.json() as { data: { invoice: { id: string } } }; + // A fresh id would mean a second row was written after all — the count + // assertion above and this one fail for different reasons. + expect(b.data.invoice.id).toBe(a.data.invoice.id); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + }); + + it('does not push the duplicate to QuickBooks', async () => { + // The push is scheduled through executionCtx.waitUntil INSIDE the + // handler. A replay that never reaches the handler cannot schedule it — + // which is exactly what has to be proven, because a side effect fired + // outside the guarded span would repeat while the row did not. + await createInvoice('key-1'); + await createInvoice('key-1'); + expect(qboUpsertInvoice).toHaveBeenCalledTimes(1); + }); + + it('creates again under a fresh key — the guard is not a global mute', async () => { + await createInvoice('key-1'); + await createInvoice('key-2'); + expect(await invoiceRows()).toHaveLength(2); + }); + + it('creates again with no key at all', async () => { + await createInvoice(null); + await createInvoice(null); + expect(await invoiceRows()).toHaveLength(2); + }); + + it('refuses the key when the payload changed under it', async () => { + await createInvoice('key-1'); + const res = await createInvoice('key-1', { ...BODY, amountCents: 90000 }); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'IDEMPOTENCY_KEY_REUSED' } }); + // And critically: the $900 invoice was NOT written, and the $450 one was + // not overwritten. + const rows = await invoiceRows(); + expect(rows).toHaveLength(1); + expect(rows[0].amountCents).toBe(45000); + }); + + it('scopes the key to the tenant — the same key in another tenant still creates', async () => { + await createInvoice('key-1'); + await createInvoice('key-1', BODY, OTHER_TENANT); + + expect(await invoiceRows(TENANT)).toHaveLength(1); + expect(await invoiceRows(OTHER_TENANT)).toHaveLength(1); + const claims = await db.select().from(schema.idempotencyKeys) + .where(and(eq(schema.idempotencyKeys.key, 'key-1'), eq(schema.idempotencyKeys.tenantId, OTHER_TENANT))) + .all(); + expect(claims).toHaveLength(1); + }); +}); From a6fc7b905c221f7a5426f29d0fb5ab1d578f52b6 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:43:49 +0800 Subject: [PATCH 14/77] test(idempotency): verify payment-capture replay does not book cash twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers POST /api/invoices/{id}/payments and POST /api/invoices/{id}/mark-paid. Both fan out past the invoice row — the report's payment gate is opened and the movement is pushed to QuickBooks — so the 201 is not the thing to assert on. The offline ledger is append-only BY DESIGN (a correction is a new row, never an edit), so a retried POST conflicts with nothing and looks like an error from no angle. It just books the same cash twice. Two findings the red run forced into the spec: - Written around a FULL payment the duplicate is invisible: the overpayment refusal fires first, so the second row never lands and the guard cannot be seen to do anything. The duplicate proof therefore uses a partial deposit, which nothing refuses. The settling case is kept as its own test for what the guard actually changes there — the operator gets the original receipt back instead of a balance error about a cheque they have already banked. - mark-paid is append-once on its own (markPaid returns nothing when the ledger already covers the balance, and the QBO push is conditional on that return), so those two assertions pass unguarded. They are kept, labelled CHARACTERIZATION, so nobody later reads them as evidence. The route still needed the guard: the payment-gate call is unconditional. Six of eight cases seen RED with the guard removed from the harness — "expected [ {...}, {...} ] to have a length of 1 but got 2", "expected vi.fn() to be called 1 times, but got 2 times" (QBO push, and the payment gate on mark-paid), "expected 422 to be 201" (settling retry refused instead of replayed), and "expected 201 to be 422" (changed payload under one key). The two survivors are the fresh-key control and the characterization test. Baseline: 286 -> 284 pending, 4 verified by replay spec. --- scripts/idempotency-baseline.json | 2 - .../invoice-payment-replay.spec.ts | 219 ++++++++++++++++++ 2 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 tests/unit/idempotency/invoice-payment-replay.spec.ts diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index 5ef27c649..e96788259 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -236,9 +236,7 @@ "POST /api/integrations/stripe/webhook", "POST /api/integrations/stripe/webhook/:tenant", "POST /api/invoices/request-payment", - "POST /api/invoices/{id}/mark-paid", "POST /api/invoices/{id}/mark-sent", - "POST /api/invoices/{id}/payments", "POST /api/invoices/{id}/payments/{paymentId}/corrections", "POST /api/message-templates", "POST /api/message-templates/preview", diff --git a/tests/unit/idempotency/invoice-payment-replay.spec.ts b/tests/unit/idempotency/invoice-payment-replay.spec.ts new file mode 100644 index 000000000..5d7c11fb4 --- /dev/null +++ b/tests/unit/idempotency/invoice-payment-replay.spec.ts @@ -0,0 +1,219 @@ +/** + * Tier 1: capturing money. Two endpoints record that a payment happened — + * `/api/invoices/{id}/payments` (the append-only offline ledger) and + * `/api/invoices/{id}/mark-paid` (settle in one shot) — and both fan out past + * the invoice row: the report's payment gate is opened, and the movement is + * pushed to QuickBooks. + * + * That fan-out is why the 201 is not the thing to assert on. The ledger is + * append-only BY DESIGN — a correction is a new row, never an edit — so a + * retried POST does not conflict with anything and does not look like an error + * from any angle. It just books the same cash twice, and the operator finds out + * at reconciliation. + * + * Both routes are tenant-authenticated, so `app.use('*', idempotencyGuard)` + * already spans them; what is proven here is that the whole fan-out sits inside + * that span, including the `executionCtx.waitUntil` push the handler schedules. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { and, eq } from 'drizzle-orm'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; + +// InvoiceService resolves its own drizzle handle off the D1 binding. +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import invoiceRoutes from '../../../server/api/invoices'; +import { InvoiceService } from '../../../server/services/invoice.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +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'; +const TUESDAY = new Date('2026-03-03T09:00:00.000Z'); + +let db: BetterSQLite3Database; +let markPaymentReceived: ReturnType; +let qboRecordPayment: ReturnType; + +function buildApp() { + const app = new OpenAPIHono(); + app.use('*', async (c, next) => { + c.set('userRole', 'manager' 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.use('*', idempotencyMiddleware({ getDb: () => db as never })); + 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; + }); + return app; +} + +// QBO connected, so the push is live and a duplicate is observable. +const ENV = { DB: {}, QBO_CLIENT_ID: 'qbo-test-client' } as never; +const CTX = { waitUntil: (p: Promise) => void p, passThroughOnException: () => {} } as never; + +function post(path: string, body: unknown, key: string | null) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().fetch( + new Request(`https://acme.example.com/api/invoices/${INV_ID}${path}`, { + method: 'POST', headers, body: JSON.stringify(body), + }), + ENV, CTX, + ); +} + +/** + * A PARTIAL payment, deliberately. A retried payment that would settle the + * invoice runs into the overpayment refusal first, so a spec written around a + * full payment cannot see whether the guard did anything — the domain masks it. + * A deposit retried against an unsettled balance is refused by nothing. + */ +const DEPOSIT = { amountCents: 20000, method: 'cash', occurredAt: TUESDAY.toISOString(), note: 'at the door' }; +const FULL_PAYMENT = { ...DEPOSIT, amountCents: 45000 }; + +const recordPayment = (key: string | null, body: unknown = DEPOSIT) => post('/payments', body, key); +const markPaid = (key: string | null, method = 'check') => post('/mark-paid', { method }, key); + +async function ledgerRows() { + return db.select().from(schema.orderPayments) + .where(and(eq(schema.orderPayments.tenantId, TENANT), eq(schema.orderPayments.invoiceId, INV_ID))) + .all(); +} + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + markPaymentReceived = vi.fn().mockResolvedValue(undefined); + qboRecordPayment = vi.fn().mockResolvedValue(undefined); + + 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' — replay does not book the cash twice", () => { + it('appends ONE ledger row when the same deposit is posted twice', async () => { + const first = await recordPayment('pay-1'); + const second = await recordPayment('pay-1'); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + // Append-only is the point: without containment the retry is a second + // legitimate-looking $200 in the ledger, and nothing anywhere errors. + const rows = await ledgerRows(); + expect(rows).toHaveLength(1); + expect(rows.reduce((sum, r) => sum + r.amountCents, 0)).toBe(20000); + }); + + it('returns the SAME payment id on the replay, flagged as replayed', async () => { + const a = await (await recordPayment('pay-1')).json() as { data: { id: string } }; + const replay = await recordPayment('pay-1'); + const b = await replay.json() as { data: { id: string } }; + + expect(b.data.id).toBe(a.data.id); + expect(replay.headers.get('Idempotency-Replayed')).toBe('true'); + }); + + it('pushes the movement to QuickBooks once, not twice', async () => { + await recordPayment('pay-1'); + await recordPayment('pay-1'); + expect(qboRecordPayment).toHaveBeenCalledTimes(1); + }); + + it('replays the original receipt when a SETTLING payment is retried', async () => { + // The other half of the story. Where the retry would settle the invoice, + // the overpayment refusal already stops the second row — so the guard is + // not what saves the ledger here. What it changes is what the operator + // is told: with it, the retry is the original 201 and the same payment + // id; without it, someone who has banked one cheque is shown a balance + // error and has to work out which of the two attempts counted. The + // report payment gate is likewise opened once either way, which is why + // there is no separate gate assertion on this route. + const first = await recordPayment('pay-1', FULL_PAYMENT); + const second = await recordPayment('pay-1', FULL_PAYMENT); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(markPaymentReceived).toHaveBeenCalledTimes(1); + expect(await ledgerRows()).toHaveLength(1); + }); + + it('records again under a fresh key — a genuine second payment still lands', async () => { + await recordPayment('pay-1', DEPOSIT); + await recordPayment('pay-2', { ...DEPOSIT, amountCents: 25000 }); + const rows = await ledgerRows(); + expect(rows).toHaveLength(2); + expect(rows.reduce((sum, r) => sum + r.amountCents, 0)).toBe(45000); + }); + + it('refuses the key when the amount changed under it', async () => { + await recordPayment('pay-1', DEPOSIT); + const res = await recordPayment('pay-1', { ...DEPOSIT, amountCents: 25000 }); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'IDEMPOTENCY_KEY_REUSED' } }); + expect(await ledgerRows()).toHaveLength(1); + }); +}); + +describe("POST '/api/invoices/{id}/mark-paid' — replay does not re-settle", () => { + it('opens the report payment gate once', async () => { + // This is the guard's evidence on this route. The gate call is + // UNCONDITIONAL on the handler's path — it does not consult whether a + // ledger row was appended — so a replay that reached the handler fires + // it a second time on an invoice that was already settled. + const first = await markPaid('paid-1'); + const second = await markPaid('paid-1'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(markPaymentReceived).toHaveBeenCalledTimes(1); + }); + + it('CHARACTERIZATION: markPaid is append-once on its own, guard or no guard', async () => { + // Not evidence for the guard — stated so nobody later reads it as such. + // markPaid returns the row it appended and nothing when the ledger + // already covers the balance, and the QuickBooks push is conditional on + // that return. Both therefore survive an unguarded replay; the payment + // gate above does not, which is why the route still needed the guard. + await markPaid('paid-1'); + await markPaid('paid-1'); + expect(await ledgerRows()).toHaveLength(1); + expect(qboRecordPayment).toHaveBeenCalledTimes(1); + }); +}); From 05c0de57d00abc6426c55e1d1f8c1cb012295b73 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:46:20 +0800 Subject: [PATCH 15/77] test(idempotency): verify agreement send does not re-mail on replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/admin/agreements/send. An email that has left cannot be recalled, and this one carries a per-signer signing link — a retried send puts a second "please sign" in the client's inbox with a link that also works, and nobody can tell afterwards which one they used. The envelope is NOT the exposure: AgreementService.findOrCreate is find-or-create, so an unguarded duplicate returns the same requestId and leaves one envelope. That is kept as a labelled CHARACTERIZATION test so the requestId assertion is never mistaken for the containment proof. What actually repeats is the tail — one outbound email per signer, and the request.sent entry in the tamper-evident audit chain, which is supposed to be the record of how many times the envelope was mailed. The changed-payload case turned out to be worse than a wasted email: unguarded, a retry carrying a different signer list is MERGED into the live envelope ("findOrCreate merged signers", added: 1), adding a party to an agreement already out for signature. The spec now asserts the signer rows, not just that no mail went out. Seen RED with the guard removed: "expected vi.fn() to be called 2 times, but got 4 times", "expected [ [...], [...] ] to have a length of 1 but got 2" (audit chain), "expected null to be 'true'" (no replay flag), and "expected 200 to be 422" (changed signer list accepted under the used key). Baseline: 284 -> 283 pending, 5 verified by replay spec. --- scripts/idempotency-baseline.json | 1 - .../idempotency/agreement-send-replay.spec.ts | 188 ++++++++++++++++++ 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 tests/unit/idempotency/agreement-send-replay.spec.ts diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index e96788259..232c11016 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -107,7 +107,6 @@ "POST /api/admin/agreement-requests/{id}/inspector-sign", "POST /api/admin/agreements", "POST /api/admin/agreements/requests/{requestId}/signers/{signerId}/remind", - "POST /api/admin/agreements/send", "POST /api/admin/audit-logs", "POST /api/admin/branding", "POST /api/admin/branding/logo", diff --git a/tests/unit/idempotency/agreement-send-replay.spec.ts b/tests/unit/idempotency/agreement-send-replay.spec.ts new file mode 100644 index 000000000..4d802937b --- /dev/null +++ b/tests/unit/idempotency/agreement-send-replay.spec.ts @@ -0,0 +1,188 @@ +/** + * Tier 1: sending an agreement for signature. An email that has left cannot be + * recalled, and this one carries a per-signer signing link — so a retried send + * puts a second "please sign your inspection agreement" in a client's inbox + * with a link that also works, and the operator has no way to tell which one + * the client used. + * + * The envelope itself is NOT the exposure. `AgreementService.findOrCreate` is + * find-or-create by construction, so a duplicate send returns the same + * requestId and writes no second envelope even with nothing guarding it. What + * repeats is everything AFTER it: one outbound email per signer, and the + * `request.sent` entry in the tamper-evident audit chain, which is supposed to + * be the record of how many times this envelope was actually mailed out. + * + * The route is tenant-authenticated, so the global mount in server/index.ts + * already spans it. These specs prove the whole tail sits inside that span. + */ +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 { OpenAPIHono } from '@hono/zod-openapi'; +import adminRoutes from '../../../server/api/admin'; +import { AgreementService } from '../../../server/services/agreement.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; + +const TENANT = '11111111-1111-4111-8111-111111111111'; +const INSP_ID = '22222222-2222-4222-8222-222222222222'; +const AGR_ID = '33333333-3333-4333-8333-333333333333'; + +let db: BetterSQLite3Database; +let emailSend: ReturnType; +let auditAppend: ReturnType; + +function buildApp() { + 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); + }); + const services = { + agreement: new AgreementService({} as D1Database, { jwtSecret: 'test-secret' }), + email: { sendAgreementRequest: emailSend }, + auditLog: { append: auditAppend, verifyChain: vi.fn(async () => ({ valid: true })) }, + } as unknown as HonoConfig['Variables']['services']; + app.use('*', async (c, next) => { + c.set('userRole', 'owner'); + c.set('tenantId', TENANT); + c.set('user', { sub: 'u1' } as never); + c.set('services', services); + await next(); + }); + // The mounted shape: tenant on the context (the JWT middleware's job in + // production), then the guard, then the router. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/admin', adminRoutes); + return app; +} + +const ENV = { DB: {}, JWT_SECRET: 'test-secret', APP_BASE_URL: 'https://app.test' }; +const EXEC = { + waitUntil: (p: Promise) => { void Promise.resolve(p).catch(() => {}); }, + passThroughOnException: () => {}, +} as ExecutionContext; + +const BODY = { + agreementId: AGR_ID, + inspectionId: INSP_ID, + completionPolicy: 'all', + signers: [ + { name: 'Jane', email: 'jane@test.com', role: 'client' }, + { name: 'John', email: 'john@test.com', role: 'co_client' }, + ], +}; + +function send(key: string | null, body: unknown = BODY) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().request('/api/admin/agreements/send', { + method: 'POST', headers, body: JSON.stringify(body), + }, ENV, EXEC); +} + +const sentEvents = () => auditAppend.mock.calls.filter(([, , event]) => event === 'request.sent'); + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + emailSend = vi.fn(async () => {}); + auditAppend = vi.fn(async () => {}); + + await db.insert(schema.tenants).values({ + id: TENANT, name: 'A', slug: 'a', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.inspections).values({ + id: INSP_ID, tenantId: TENANT, propertyAddress: '1 Main St', + clientName: 'Jane', clientEmail: 'jane@test.com', date: '2026-06-01', + status: 'requested', paymentStatus: 'unpaid', price: 50000, + agreementRequired: true, paymentRequired: false, createdAt: new Date(), + }); + await db.insert(schema.agreements).values({ + id: AGR_ID, tenantId: TENANT, name: 'Standard Agreement', + content: 'Agreement text...', version: 1, createdAt: new Date(), + }); +}); + +describe("POST '/api/admin/agreements/send' — replay does not re-mail the signers", () => { + it('emails each signer exactly once across two sends under one key', async () => { + const first = await send('send-1'); + const second = await send('send-1'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + // Two signers, one send: two emails, not four. + expect(emailSend).toHaveBeenCalledTimes(2); + expect(emailSend.mock.calls.map(([to]) => to).sort()) + .toEqual(['jane@test.com', 'john@test.com']); + }); + + it('writes ONE request.sent entry into the audit chain', async () => { + // The chain is the record of how many times this envelope was mailed. + // A second entry says the client was contacted twice, which — if the + // send really was a duplicate — is a lie in a tamper-evident log. + await send('send-1'); + await send('send-1'); + expect(sentEvents()).toHaveLength(1); + }); + + it('replays the original response, flagged, with the same requestId', async () => { + const first = await send('send-1'); + const second = await send('send-1'); + const a = await first.json() as { data: { requestId: string } }; + const b = await second.json() as { data: { requestId: string } }; + + expect(b.data.requestId).toBe(a.data.requestId); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + }); + + it('CHARACTERIZATION: the envelope is find-or-create, guard or no guard', async () => { + // Not evidence for the guard — stated so nobody later reads it as such. + // AgreementService.findOrCreate is why a duplicate send does not leave + // two envelopes behind. It is also why the requestId assertion above is + // NOT the containment proof: the emails and the audit entry are. + await send('send-1'); + await send('send-2'); + const envelopes = await db.select().from(schema.agreementRequests) + .where(eq(schema.agreementRequests.tenantId, TENANT)).all(); + expect(envelopes).toHaveLength(1); + }); + + it('sends again under a fresh key — a deliberate re-send still reaches the signers', async () => { + await send('send-1'); + await send('send-2'); + expect(emailSend).toHaveBeenCalledTimes(4); + expect(sentEvents()).toHaveLength(2); + }); + + it('refuses the key when the signer list changed under it', async () => { + await send('send-1'); + const res = await send('send-1', { + ...BODY, + signers: [{ name: 'Mallory', email: 'mallory@test.com', role: 'client' }], + }); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'IDEMPOTENCY_KEY_REUSED' } }); + // And Mallory was never mailed a signing link. Unguarded this is not a + // no-op that merely wastes an email: findOrCreate MERGES the new signer + // into the live envelope ("findOrCreate merged signers", added: 1), so a + // mis-keyed retry adds a party to an agreement already out for signature. + expect(emailSend.mock.calls.map(([to]) => to)).not.toContain('mallory@test.com'); + const signers = await db.select().from(schema.agreementSigners) + .where(eq(schema.agreementSigners.tenantId, TENANT)).all(); + expect(signers.map(s => s.email).sort()).toEqual(['jane@test.com', 'john@test.com']); + }); +}); From 063e5b78490cbf4b46f14ad5c5987dd4fb16d8d2 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:48:44 +0800 Subject: [PATCH 16/77] test(idempotency): verify manual SMS send does not text twice on replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/inspections/{id}/send-sms. The ledger's claim was checked before writing anything, and it holds: Task 4's title said "email and SMS" but its Files list only ever named the email builder, so there is NO service-level dedupe on the SMS path — nothing resembling buildEmailDedupe exists under server/lib/sms/ or in send-one-sms.ts. Reading the handler end to end confirms it: every call mints a fresh automation_logs id, inserts a pending row, and hands it to sendOneSms without consulting whether this message already went out. The claim needs one refinement, because the two halves are easy to conflate. The HTTP route IS tenant-authenticated, so the global mount does span it — the guard contains a retried REQUEST. What the missing service-level dedupe means is that anything reaching sendOneSms by another path (the automation flush, an Outbox resend) is still unprotected. This commit closes the first, not the second. A duplicate here costs twice: the carrier charges per segment and the send meters against the tenant's quota. Unlike email it also lands on a phone. Seen RED with the guard removed: "expected vi.fn() to be called 1 times, but got 2 times" (the Twilio seam), "expected [ {...}, {...} ] to have a length of 1 but got 2" (the SMS ledger the Outbox and metering read), "expected null to be 'true'", and "expected 200 to be 422" for a changed recipient under a used key. The survivor is the fresh-key control. Baseline: 283 -> 282 pending, 6 verified by replay spec. --- scripts/idempotency-baseline.json | 1 - .../unit/idempotency/sms-send-replay.spec.ts | 202 ++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/unit/idempotency/sms-send-replay.spec.ts diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index 232c11016..9626b4e2b 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -214,7 +214,6 @@ "POST /api/inspections/{id}/results/batch", "POST /api/inspections/{id}/return", "POST /api/inspections/{id}/send-report-pdf", - "POST /api/inspections/{id}/send-sms", "POST /api/inspections/{id}/submit", "POST /api/inspections/{id}/switch-rating-system", "POST /api/inspections/{id}/unit-mode", diff --git a/tests/unit/idempotency/sms-send-replay.spec.ts b/tests/unit/idempotency/sms-send-replay.spec.ts new file mode 100644 index 000000000..14597477a --- /dev/null +++ b/tests/unit/idempotency/sms-send-replay.spec.ts @@ -0,0 +1,202 @@ +/** + * Tier 1: outbound SMS. The ledger's claim, verified before this was written: + * Task 4's title said "email and SMS" but its Files list only ever named the + * email builder, so there is NO service-level dedupe on the SMS path — nothing + * resembling `buildEmailDedupe`. Confirmed by reading + * server/api/inspections/send-sms.ts end to end: each call mints a fresh + * `automation_logs` id, inserts a pending row, and hands it to `sendOneSms`. + * Nothing consults whether this message already went out. + * + * The HTTP route, though, IS tenant-authenticated, so the global mount in + * server/index.ts does span it — the two facts are not in conflict, and the + * distinction matters: the guard contains a RETRIED REQUEST, while the missing + * service-level dedupe means anything that reaches `sendOneSms` by another path + * (the automation flush, a resend from the Outbox) is still on its own. + * + * A duplicate here costs money twice over — the carrier charges per segment and + * the send meters against the tenant's quota — and, unlike email, it lands on a + * phone that may be someone's personal number at 7am. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { and, eq, isNull } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +/** The Twilio seam. "One SMS left the building" is counted here, not inferred. */ +const sendMessage = vi.fn(); +vi.mock('../../../server/lib/sms/resolve-twilio', () => ({ + loadProviderForTenant: vi.fn(async () => ({ + provider: { sendMessage }, + from: '+15550001111', + messagingServiceSid: null, + })), +})); + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { inspectionsRoutes } from '../../../server/api/inspections'; +import { PeopleService } from '../../../server/services/people.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; + +const TENANT = '00000000-0000-0000-0000-0000000000c1'; +const AGENT = 'ct-sms-agent'; +const AGENT_2 = 'ct-sms-agent2'; +const INSP_ID = '550e8400-e29b-41d4-a716-4466554400c1'; +const SLUG = 'acme-sms'; + +const roleProfileId = (key: string) => `crp_${TENANT}_${key}`; + +let db: BetterSQLite3Database; + +function buildApp() { + const app = new OpenAPIHono(); + app.use('*', async (c, next) => { + c.set('userRole', 'manager' as never); + c.set('tenantId', TENANT); + c.set('user', { sub: 'user-1' } as never); + c.set('requestedTenantSlug', SLUG as never); + c.set('profile', { hasUsageQuota: false } as never); + c.set('services', { + inspection: { + getInspection: vi.fn().mockResolvedValue({ + inspection: { + id: INSP_ID, propertyAddress: '1 Main St', date: '2026-07-30', + status: 'scheduled', reportStatus: 'draft', paymentStatus: 'unpaid', + inspectorId: null, + }, + }), + }, + people: new PeopleService({ DB: {} as D1Database }), + } as never); + await next(); + }); + // The mounted shape: tenant on the context first, then the guard. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/inspections', inspectionsRoutes); + 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: {}, APP_BASE_URL: 'https://acme.example.com', + APP_NAME: 'Acme Inspect', JWT_SECRET: 'test-secret', +} as never; +const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; + +function sendSms(key: string | null, contactId = AGENT) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().fetch( + new Request(`https://acme.example.com/api/inspections/${INSP_ID}/send-sms`, { + method: 'POST', headers, + body: JSON.stringify({ recipients: [{ contactId, roleKey: 'buyer_agent' }] }), + }), + ENV, CTX, + ); +} + +async function smsLedgerRows() { + return db.select().from(schema.automationLogs).where(and( + eq(schema.automationLogs.inspectionId, INSP_ID), + isNull(schema.automationLogs.automationId), + eq(schema.automationLogs.channel, 'sms'), + )).all(); +} + +async function seat(contactId: string, roleKey: string) { + await db.insert(schema.inspectionPeople).values({ + id: `ip-${contactId}`, tenantId: TENANT, inspectionId: INSP_ID, + contactId, roleProfileId: roleProfileId(roleKey), createdAt: new Date(), + }); +} + +beforeEach(async () => { + sendMessage.mockReset(); + sendMessage.mockResolvedValue({ ok: true, id: 'SM1' }); + 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: SLUG, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT, companyPhone: '+15550009999', + reviewUrl: 'https://reviews.example', smsMode: 'platform', updatedAt: new Date(), + } as never); + await seedRoleProfiles(db, TENANT, new Date(1)); + // Agents carry implied consent, so the send is not gated on a ledger grant + // and the duplicate is the only thing under test. + await db.insert(schema.contacts).values([ + { id: AGENT, tenantId: TENANT, type: 'agent', name: 'Ray', email: 'r@x.com', phone: '+15551110003', createdAt: new Date() }, + { id: AGENT_2, tenantId: TENANT, type: 'agent', name: 'Rita', email: 'rita@x.com', phone: '+15551110004', createdAt: new Date() }, + ]); + await db.insert(schema.inspections).values({ + id: INSP_ID, tenantId: TENANT, propertyAddress: '1 Main St', status: 'scheduled', + reportStatus: 'draft', paymentStatus: 'unpaid', date: '2026-07-30', createdAt: new Date(), + } as never); + await seat(AGENT, 'buyer_agent'); + await seat(AGENT_2, 'buyer_agent'); +}); + +describe("POST '/api/inspections/{id}/send-sms' — replay does not text twice", () => { + it('reaches the carrier ONCE when the same key is posted twice', async () => { + const first = await sendSms('sms-1'); + const second = await sendSms('sms-1'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + it('leaves ONE row in the SMS ledger', async () => { + // The row is what the Outbox shows and what metering counts. A second + // pending-then-sent row is a second billable message on the tenant's + // account, presented to them as two separate sends. + await sendSms('sms-1'); + await sendSms('sms-1'); + expect(await smsLedgerRows()).toHaveLength(1); + }); + + it('replays the original outcome, flagged', async () => { + const first = await sendSms('sms-1'); + const second = await sendSms('sms-1'); + + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + }); + + it('texts again under a fresh key — a deliberate second message still goes', async () => { + await sendSms('sms-1'); + await sendSms('sms-2'); + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(await smsLedgerRows()).toHaveLength(2); + }); + + it('refuses the key when the recipient changed under it', async () => { + await sendSms('sms-1', AGENT); + const res = await sendSms('sms-1', AGENT_2); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'IDEMPOTENCY_KEY_REUSED' } }); + // Rita's phone was never dialled, and no ledger row claims it was. + expect(sendMessage).toHaveBeenCalledTimes(1); + const rows = await smsLedgerRows(); + expect(rows.map(r => r.recipient)).toEqual(['+15551110003']); + }); +}); From de802f5d5872b8771eeb2f20cf1f754940babd89 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:51:13 +0800 Subject: [PATCH 17/77] test(idempotency): verify workspace agreement send does not re-mail on replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/inspections/{id}/agreement-requests — the sibling of the admin send, and the reason it gets its own commit rather than a footnote: the inspection workspace's "Send agreement" button posts HERE, not to /api/admin, and the two share emailSignersTheirLinks precisely because they had already drifted into mailing different links once (IA-65). A shared helper is not shared containment. Verifying one route and calling the behaviour covered would have left the button most inspectors actually press outside the ledger, with the gate reporting progress for it. Seen RED with the guard removed: "expected [ 'jane@example.com', ...(1) ] to deeply equal [ 'jane@example.com' ]", "expected null to be 'true'", and "expected 200 to be 422" — the last one again logging "findOrCreate merged signers, added: 1", i.e. a mis-keyed retry adding a party to an agreement already out for signature. Baseline: 282 -> 281 pending, 7 verified by replay spec. --- scripts/idempotency-baseline.json | 1 - ...nspection-agreement-request-replay.spec.ts | 160 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/unit/idempotency/inspection-agreement-request-replay.spec.ts diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index 9626b4e2b..241066d00 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -181,7 +181,6 @@ "POST /api/inspections/templates", "POST /api/inspections/templates/import-spectora", "POST /api/inspections/wizard", - "POST /api/inspections/{id}/agreement-requests", "POST /api/inspections/{id}/clone", "POST /api/inspections/{id}/complete", "POST /api/inspections/{id}/compliance/doc-review/seed", diff --git a/tests/unit/idempotency/inspection-agreement-request-replay.spec.ts b/tests/unit/idempotency/inspection-agreement-request-replay.spec.ts new file mode 100644 index 000000000..2e728cf9e --- /dev/null +++ b/tests/unit/idempotency/inspection-agreement-request-replay.spec.ts @@ -0,0 +1,160 @@ +/** + * The sibling of POST /api/admin/agreements/send, and the reason it is its own + * commit rather than a footnote: the inspection workspace's "Send agreement" + * button posts HERE, not to the admin route, and the two share + * `emailSignersTheirLinks` precisely because they had already drifted into + * mailing different links once (IA-65). + * + * A shared helper is not shared containment. Covering one endpoint and calling + * the behaviour verified would leave the button most inspectors actually press + * unguarded — so the same replay evidence is asserted against this route + * directly, through the real mounted router. + * + * As on the admin route, the envelope is find-or-create and therefore not the + * exposure. What repeats is the outbound email carrying each signer's personal + * signing link. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { inspectionsRoutes } from '../../../server/api/inspections'; +import { AgreementService } from '../../../server/services/agreement.service'; +import { PeopleService } from '../../../server/services/people.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +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 CLIENT_CONTACT_ID = '00000000-0000-0000-0000-0000000000c1'; +const AGR_ID = '11111111-1111-4111-8111-111111111111'; +const SLUG = 'acme'; + +let db: BetterSQLite3Database; +let sendAgreementRequest: ReturnType; + +function buildApp() { + const app = new OpenAPIHono(); + app.use('*', async (c, next) => { + c.set('userRole', 'manager' as never); + c.set('tenantId', TENANT); + c.set('user', { sub: USER_ID } as never); + c.set('requestedTenantSlug', SLUG as never); + c.set('services', { + agreement: new AgreementService({} as D1Database, { jwtSecret: 'test-secret' }), + people: new PeopleService({ DB: {} as D1Database }), + email: { sendAgreementRequest } as never, + } as never); + await next(); + }); + // The mounted shape: tenant on the context first, then the guard. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/inspections', inspectionsRoutes); + 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: {}, APP_BASE_URL: 'https://acme.example.com' } as never; +const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; + +function requestSignature(key: string | null, body: unknown = {}) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().fetch( + new Request(`https://acme.example.com/api/inspections/${INSP_ID}/agreement-requests`, { + method: 'POST', headers, body: JSON.stringify(body), + }), + ENV, CTX, + ); +} + +const mailedTo = () => sendAgreementRequest.mock.calls.map(([to]) => to as string); + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + sendAgreementRequest = vi.fn().mockResolvedValue(undefined); + + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: SLUG, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.inspections).values({ + id: INSP_ID, tenantId: TENANT, propertyAddress: '1 Main St', + clientName: 'Jane', clientEmail: 'jane@example.com', date: '2026-06-01', + status: 'requested', paymentStatus: 'unpaid', price: 50000, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + }); + await seedRoleProfiles(db, TENANT, new Date(1)); + await db.insert(schema.contacts).values({ + id: CLIENT_CONTACT_ID, tenantId: TENANT, type: 'client', name: 'Jane', + email: 'jane@example.com', createdAt: new Date(), + }); + await db.insert(schema.inspectionPeople).values({ + id: `ip_${INSP_ID}_client`, tenantId: TENANT, inspectionId: INSP_ID, + contactId: CLIENT_CONTACT_ID, roleProfileId: `crp_${TENANT}_client`, createdAt: new Date(), + }); + await db.insert(schema.agreements).values({ + id: AGR_ID, tenantId: TENANT, name: 'Standard Agreement', + content: 'AGREEMENT BODY', version: 1, createdAt: new Date(), + }); +}); + +describe("POST '/api/inspections/{id}/agreement-requests' — replay does not re-mail", () => { + it('emails the client once across two sends under one key', async () => { + const first = await requestSignature('agr-1'); + const second = await requestSignature('agr-1'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(mailedTo()).toEqual(['jane@example.com']); + }); + + it('replays the original response, flagged', async () => { + const first = await requestSignature('agr-1'); + const second = await requestSignature('agr-1'); + + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + }); + + it('sends again under a fresh key — a deliberate re-send still reaches the client', async () => { + await requestSignature('agr-1'); + await requestSignature('agr-2'); + expect(mailedTo()).toEqual(['jane@example.com', 'jane@example.com']); + }); + + it('refuses the key when the recipient changed under it', async () => { + await requestSignature('agr-1'); + const res = await requestSignature('agr-1', { + signers: [{ name: 'Mallory', email: 'mallory@example.com', role: 'client' }], + }); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'IDEMPOTENCY_KEY_REUSED' } }); + // Unguarded, findOrCreate MERGES the new signer into the envelope that + // is already out for signature — so this is not a wasted email, it is a + // party added to a live agreement. + expect(mailedTo()).not.toContain('mallory@example.com'); + const signers = await db.select().from(schema.agreementSigners) + .where(eq(schema.agreementSigners.tenantId, TENANT)).all(); + expect(signers.map(s => s.email)).toEqual(['jane@example.com']); + }); +}); From 5d4d88c9ba2569dfa95451a93cb592152b4402b5 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:53:46 +0800 Subject: [PATCH 18/77] test(idempotency): verify request-payment replay does not re-bill the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/invoices/request-payment — the hub's "Request payment" button. The endpoint resolves or CREATES the inspection's invoice, marks it sent, mints the recipient's portal token, and emails a working link to the public payment page. Two of those are self-limiting and are asserted as CHARACTERIZATION rather than as evidence: the invoice is reused when one exists, and issueToken is deliberately idempotent so older copies of the email keep working. The email is not self-limiting — a retry sends the client a second "please pay for your inspection", the message in this system most likely to be read as a second bill. The red run turned up one effect that was not obvious from reading the handler: unguarded, the replay reruns markSent and the returned sentAt MOVES. A retry nobody made rewrites the invoice's own record of when it was sent, so that assertion is now explicit rather than incidental to a deep-equal. Seen RED with the guard removed: "expected vi.fn() to be called 1 times, but got 2 times", the sentAt drift above ("2026-08-06T00:52:52.722Z" vs "...52.753Z"), and "expected 200 to be 422" — that last one meaning a key reused against a DIFFERENT inspection raised an invoice on the wrong job. Baseline: 281 -> 280 pending, 8 verified by replay spec. --- scripts/idempotency-baseline.json | 1 - .../invoice-request-payment-replay.spec.ts | 179 ++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 tests/unit/idempotency/invoice-request-payment-replay.spec.ts diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index 241066d00..941d1900e 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -232,7 +232,6 @@ "POST /api/integrations/stripe/test", "POST /api/integrations/stripe/webhook", "POST /api/integrations/stripe/webhook/:tenant", - "POST /api/invoices/request-payment", "POST /api/invoices/{id}/mark-sent", "POST /api/invoices/{id}/payments/{paymentId}/corrections", "POST /api/message-templates", diff --git a/tests/unit/idempotency/invoice-request-payment-replay.spec.ts b/tests/unit/idempotency/invoice-request-payment-replay.spec.ts new file mode 100644 index 000000000..f003b20e6 --- /dev/null +++ b/tests/unit/idempotency/invoice-request-payment-replay.spec.ts @@ -0,0 +1,179 @@ +/** + * Tier 1, the last of the money-moving invoice surfaces: asking the client to + * pay. The hub's "Request payment" button posts here. The endpoint resolves or + * CREATES the inspection's invoice, marks it sent, mints the recipient's + * portal token, and emails them a working link to the public payment page. + * + * Two of those are already self-limiting and are asserted as characterization, + * not as evidence: the invoice is reused when one exists, and the portal token + * is minted idempotently on purpose so older copies of the email keep working. + * The email is not. A retry sends the client a second "please pay for your + * inspection" — the one message in this system most likely to be read as a + * second bill. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles'; + +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 { PeopleService } from '../../../server/services/people.service'; +import { PortalAccessService } from '../../../server/services/portal-access.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +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 INSP_ID_2 = '550e8400-e29b-41d4-a716-4466554400aa'; +const CLIENT = 'contact-client-1'; +const SLUG = 'acme'; +const JWT_SECRET = 'test-jwt-secret'; +const roleProfileId = (key: string) => `crp_${TENANT}_${key}`; + +let db: BetterSQLite3Database; +let sendInvoiceRequest: ReturnType; + +function buildApp() { + const app = new OpenAPIHono(); + app.use('*', async (c, next) => { + c.set('userRole', 'manager' as never); + c.set('tenantId', TENANT); + c.set('user', { sub: USER_ID } as never); + c.set('requestedTenantSlug', SLUG as never); + c.set('services', { + invoice: new InvoiceService({} as D1Database), + people: new PeopleService({ DB: {} as D1Database }), + portalAccess: new PortalAccessService({} as D1Database, { jwtSecret: JWT_SECRET }), + email: { sendInvoiceRequest } as never, + qbo: { upsertInvoice: vi.fn() } as never, + } as never); + await next(); + }); + // The mounted shape: tenant on the context first, then the guard. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + 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; + }); + return app; +} + +const ENV = { DB: {}, APP_BASE_URL: 'https://acme.example.com', JWT_SECRET } as never; +const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; + +function requestPayment(key: string | null, inspectionId = INSP_ID) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().fetch( + new Request('https://acme.example.com/api/invoices/request-payment', { + method: 'POST', headers, body: JSON.stringify({ inspectionId }), + }), + ENV, CTX, + ); +} + +const payUrls = () => sendInvoiceRequest.mock.calls.map(([, , , url]) => url as string); + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + sendInvoiceRequest = vi.fn().mockResolvedValue(undefined); + + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: SLUG, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await seedRoleProfiles(db, TENANT, new Date(1)); + await db.insert(schema.contacts).values({ + id: CLIENT, tenantId: TENANT, type: 'client', name: 'Jane', + email: 'jane@example.com', phone: null, createdAt: new Date(), + }); + for (const id of [INSP_ID, INSP_ID_2]) { + await db.insert(schema.inspections).values({ + id, tenantId: TENANT, propertyAddress: '1 Main St', + clientName: null, clientEmail: null, date: '2026-06-01', + status: 'requested', paymentStatus: 'unpaid', price: 50000, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + }); + } + const people = new PeopleService({ DB: {} as D1Database }); + await people.addPerson(TENANT, INSP_ID, CLIENT, roleProfileId('client')); + await people.addPerson(TENANT, INSP_ID_2, CLIENT, roleProfileId('client')); +}); + +describe("POST '/api/invoices/request-payment' — replay does not re-bill the client", () => { + it('emails the payment request once across two posts under one key', async () => { + const first = await requestPayment('req-1'); + const second = await requestPayment('req-1'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(sendInvoiceRequest).toHaveBeenCalledTimes(1); + expect(sendInvoiceRequest.mock.calls[0][0]).toBe('jane@example.com'); + }); + + it('replays the original response, flagged — including the original sentAt', async () => { + const first = await requestPayment('req-1'); + const second = await requestPayment('req-1'); + + // Not just cosmetic. Unguarded, the replay reruns markSent and the + // returned sentAt MOVES, so the invoice's own record of when it was + // sent is rewritten by a retry the operator never made. + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + }); + + it('CHARACTERIZATION: the invoice and the pay token are reused, guard or no guard', async () => { + // Not evidence for the guard — stated so nobody later reads it as such. + // The handler reuses an existing invoice for the inspection, and + // PortalAccessService.issueToken is deliberately idempotent so older + // copies of the email keep working. Both survive an unguarded replay. + // The EMAIL is the side effect that does not, which is what the first + // test asserts. + await requestPayment('req-1'); + await requestPayment('req-2'); + + const invoices = await db.select().from(schema.invoices) + .where(eq(schema.invoices.inspectionId, INSP_ID)).all(); + expect(invoices).toHaveLength(1); + const urls = payUrls(); + expect(urls).toHaveLength(2); + expect(urls[1]).toBe(urls[0]); + }); + + it('re-sends under a fresh key — a deliberate chase-up still reaches the client', async () => { + await requestPayment('req-1'); + await requestPayment('req-2'); + expect(sendInvoiceRequest).toHaveBeenCalledTimes(2); + }); + + it('refuses the key when the inspection changed under it', async () => { + await requestPayment('req-1', INSP_ID); + const res = await requestPayment('req-1', INSP_ID_2); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ error: { code: 'IDEMPOTENCY_KEY_REUSED' } }); + // The second inspection was never invoiced off the back of the first + // one's key — an invoice raised against the wrong job is a correction + // the operator has to make by hand. + const invoices = await db.select().from(schema.invoices) + .where(eq(schema.invoices.inspectionId, INSP_ID_2)).all(); + expect(invoices).toHaveLength(0); + expect(sendInvoiceRequest).toHaveBeenCalledTimes(1); + }); +}); From 5ff07c3982015bd19e14f9435666fd4a589d7771 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 08:56:37 +0800 Subject: [PATCH 19/77] test(idempotency): order the in-flight overlap instead of racing for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency case fired its second request immediately after the first and assumed the first had already claimed the key. It usually had. Under load it sometimes had not — and then BOTH requests claim, both enter the handler, and both park on a gate that is only released after the second returns. The deadlock surfaces as a 5s timeout, which reads like a slow test rather than the ordering bug it is. Observed for real: adding six replay specs to this directory raised the parallel load enough to trip it ("Test timed out in 5000ms" on tests/unit/idempotency/middleware.spec.ts:113), while the file on its own passed every time. Found by running the directory, not the file. The fix waits for the handler to be ENTERED, which is strictly after the claim, so the overlap is ordered rather than hoped for. It does not weaken the test: `ran` becoming 2 still fails, and a missing in-flight branch still parks the second request and times out. --- tests/unit/idempotency/middleware.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/idempotency/middleware.spec.ts b/tests/unit/idempotency/middleware.spec.ts index 4d12cac15..99fe1b608 100644 --- a/tests/unit/idempotency/middleware.spec.ts +++ b/tests/unit/idempotency/middleware.spec.ts @@ -121,6 +121,14 @@ describe('idempotencyMiddleware', () => { const app = buildApp(async () => { ran++; await gate; return { status: 200, body: { id: 'abc' } }; }); const first = post(app, 'same-key'); + // ...and the second must meet a claim that ALREADY EXISTS. Firing it + // straight after the first is a race the test used to lose under load: + // if the first has not reached claimKey yet, BOTH claim, both enter the + // handler, and both park on a gate that is only released after the + // second returns — a deadlock that surfaces as a 5s timeout rather than + // as a wrong answer. Waiting for the handler to be ENTERED is strictly + // after the claim, so the overlap is ordered instead of hoped for. + while (ran === 0) await new Promise((r) => setImmediate(r)); const second = await post(app, 'same-key'); release(); const firstRes = await first; From 7011a1ee359b56f1d0c939c8e34e5d2267e79e9c Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 09:16:40 +0800 Subject: [PATCH 20/77] feat(pay-splits): pay rules and split records (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits are RECORDS, not a calculation: a tenant rule populates a row once, then only a deliberate edit moves it. A derived split would rewrite what someone was already paid whenever the rule changed. Grain is the billing line (`inspection_services.id`), the same key `reports.inspection_service_id` uses, so "what did this line earn and what did it produce" stays joinable. Two partial unique indexes rather than the obvious plain ones: - `service_pay_rules` — SQLite treats NULLs as distinct, so a single unique over (tenant, service, user) silently accepts TWO default rules for one service and the populate step would then pick one arbitrarily. - `inspection_service_pay_splits` — one PRIMARY split per (line, user), but correction rows against a locked split must be insertable, and an unconditional unique makes that path impossible to write. Both tables are payroll records about staff, so they are declared in ERASURE_OUT_OF_SCOPE with a reason: a client's erasure request does not reach them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- migrations/0040_lovely_rockslide.sql | 30 + migrations/meta/0040_snapshot.json | 10797 ++++++++++++++++++++ migrations/meta/_journal.json | 7 + server/lib/compliance/erasure-manifest.ts | 11 + server/lib/db/schema/index.ts | 3 + server/lib/db/schema/pay-split.ts | 102 + tests/unit/pay-splits/schema.spec.ts | 94 + 7 files changed, 11044 insertions(+) create mode 100644 migrations/0040_lovely_rockslide.sql create mode 100644 migrations/meta/0040_snapshot.json create mode 100644 server/lib/db/schema/pay-split.ts create mode 100644 tests/unit/pay-splits/schema.spec.ts diff --git a/migrations/0040_lovely_rockslide.sql b/migrations/0040_lovely_rockslide.sql new file mode 100644 index 000000000..7be0a668c --- /dev/null +++ b/migrations/0040_lovely_rockslide.sql @@ -0,0 +1,30 @@ +CREATE TABLE `inspection_service_pay_splits` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `inspection_service_id` text NOT NULL, + `user_id` text NOT NULL, + `amount_cents` integer NOT NULL, + `source` text NOT NULL, + `locked_at` integer, + `corrects_split_id` text, + `reason` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_pay_split_line_user` ON `inspection_service_pay_splits` (`tenant_id`,`inspection_service_id`,`user_id`) WHERE corrects_split_id IS NULL;--> statement-breakpoint +CREATE INDEX `idx_pay_split_user` ON `inspection_service_pay_splits` (`tenant_id`,`user_id`);--> statement-breakpoint +CREATE INDEX `idx_pay_split_line` ON `inspection_service_pay_splits` (`tenant_id`,`inspection_service_id`);--> statement-breakpoint +CREATE TABLE `service_pay_rules` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `service_id` text NOT NULL, + `user_id` text, + `type` text NOT NULL, + `value` integer NOT NULL, + `deduction_cents` integer, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_service_pay_rules_user` ON `service_pay_rules` (`tenant_id`,`service_id`,`user_id`) WHERE user_id IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `uq_service_pay_rules_default` ON `service_pay_rules` (`tenant_id`,`service_id`) WHERE user_id IS NULL; \ No newline at end of file diff --git a/migrations/meta/0040_snapshot.json b/migrations/meta/0040_snapshot.json new file mode 100644 index 000000000..a83bdd47a --- /dev/null +++ b/migrations/meta/0040_snapshot.json @@ -0,0 +1,10797 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "98f66aa2-1619-4acc-8d2f-b4916cfec2a5", + "prevId": "b29a9fe3-4496-40de-93d2-ba1d2a816822", + "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_service_pay_splits": { + "name": "inspection_service_pay_splits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_service_id": { + "name": "inspection_service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrects_split_id": { + "name": "corrects_split_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "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_pay_split_line_user": { + "name": "uq_pay_split_line_user", + "columns": [ + "tenant_id", + "inspection_service_id", + "user_id" + ], + "isUnique": true, + "where": "corrects_split_id IS NULL" + }, + "idx_pay_split_user": { + "name": "idx_pay_split_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_pay_split_line": { + "name": "idx_pay_split_line", + "columns": [ + "tenant_id", + "inspection_service_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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": {} + }, + "service_pay_rules": { + "name": "service_pay_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_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 + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deduction_cents": { + "name": "deduction_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_service_pay_rules_user": { + "name": "uq_service_pay_rules_user", + "columns": [ + "tenant_id", + "service_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "uq_service_pay_rules_default": { + "name": "uq_service_pay_rules_default", + "columns": [ + "tenant_id", + "service_id" + ], + "isUnique": true, + "where": "user_id IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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 bba1aa907..ff360e4b9 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1785901808286, "tag": "0039_nasty_random", "breakpoints": true + }, + { + "idx": 40, + "version": "6", + "when": 1785978827419, + "tag": "0040_lovely_rockslide", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index eb008273d..ce72451cd 100644 --- a/server/lib/compliance/erasure-manifest.ts +++ b/server/lib/compliance/erasure-manifest.ts @@ -254,4 +254,15 @@ export const ERASURE_OUT_OF_SCOPE: ErasureOutOfScopeEntry[] = [ 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' }, + // Pay splits (#278). A split is a payroll record about a STAFF member, held + // under accounting and employment obligations. A client's erasure request + // never reaches it — the client is not the data subject here. Declared + // rather than left silent: the PII heuristic flags none of these columns, + // and silence is not the same as a decision. + { table: 'inspection_service_pay_splits', column: 'user_id', + reason: 'payroll record for a staff member, retained under accounting and employment obligations; not client data, so a client erasure request does not reach it' }, + { table: 'inspection_service_pay_splits', column: 'reason', + reason: 'free text a manager writes about a payout adjustment to a staff member — payroll audit trail, not consumer-DSAR scope' }, + { table: 'service_pay_rules', column: 'user_id', + reason: 'staff compensation rule — not consumer-DSAR scope' }, ]; diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts index ceb976551..0a2f23f02 100644 --- a/server/lib/db/schema/index.ts +++ b/server/lib/db/schema/index.ts @@ -93,3 +93,6 @@ export type { NotificationPreference, NewNotificationPreference } from './notifi // Generic idempotency ledger (portal #107) — one row per (tenant, key). export { idempotencyKeys } from './idempotency'; export type { IdempotencyKey, NewIdempotencyKey } from './idempotency'; +// Pay splits (#278) — per-service-line inspector earnings, recorded not derived. +export { servicePayRules, inspectionServicePaySplits } from './pay-split'; +export type { ServicePayRule, InspectionServicePaySplit } from './pay-split'; diff --git a/server/lib/db/schema/pay-split.ts b/server/lib/db/schema/pay-split.ts new file mode 100644 index 000000000..52dad2edd --- /dev/null +++ b/server/lib/db/schema/pay-split.ts @@ -0,0 +1,102 @@ +/** + * Pay splits — what each inspector earns on one billing line of one inspection. + * + * The design decision this whole file exists to hold: a split is a RECORD, not + * a calculation. Rules populate a row once, the row freezes, and after that + * only a deliberate edit moves it. A derived split would change retroactively + * whenever the rule changed, which means a tenant editing "60%" to "55%" would + * silently rewrite what someone was already paid. + * + * Grain is the BILLING LINE (`inspection_services.id`) — the same key + * `reports.inspection_service_id` uses — not the inspection. An inspector who + * only ran the radon test earns from the radon line, not a share of the job. + * + * Splits sum to <= the line's effective price, never forced to equal it: the + * remainder is company margin. An invoice overriding the ORDER total (tier 1 of + * the money authority chain) does not redistribute pay — pay attaches to tier 2. + * + * App-layer integrity — no DB FKs (Schema Rules). + */ +import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; + +/** + * Tenant rule: what an inspector earns on a catalogue service. Read only when + * a split row is created; never consulted again once one exists. + */ +export const servicePayRules = sqliteTable('service_pay_rules', { + id: text('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + serviceId: text('service_id').notNull(), + // NULL = the default for this service, applied to any inspector without a + // specific rule. That is what makes "60% to whoever runs it" expressible + // without a row per employee. + userId: text('user_id'), + // Three types: a straight percentage, a flat amount, and a percentage + // applied AFTER a deduction off the top (materials, a franchise fee). The + // third is not a variant of the first — the deduction comes out before the + // percentage, so it cannot be restated as a smaller percentage of the gross. + type: text('type', { enum: ['percent', 'fixed', 'percent_after_deduction'] }).notNull(), + // Basis points when `type` is a percentage, integer cents when it is + // `fixed`. Deliberately NOT named `_cents`: the unit is decided by `type` + // and a `_cents` suffix would be a lie half the time. + value: integer('value').notNull(), + // Only meaningful for `percent_after_deduction`. + deductionCents: integer('deduction_cents'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}, (t) => [ + // Two partial indexes, not one three-column unique: SQLite treats NULLs as + // distinct, so a single unique over (tenant, service, user) would happily + // accept a second default rule for the same service and the populate step + // would then pick one arbitrarily. + uniqueIndex('uq_service_pay_rules_user').on(t.tenantId, t.serviceId, t.userId) + .where(sql`user_id IS NOT NULL`), + uniqueIndex('uq_service_pay_rules_default').on(t.tenantId, t.serviceId) + .where(sql`user_id IS NULL`), +]); + +/** + * The agreed amount for one inspector on one service line of one inspection. + * Frozen at creation. `source` records whether a human moved it, because that + * is the first question asked when a payout is disputed. + * + * NOT `service_inspectors`. That table already exists, is `(service_id, + * user_id)`, and means QUALIFICATION — which inspectors are able to perform a + * CATALOGUE service, read by `booking.service.ts` to auto-assign. It has no + * inspection dimension and no money. The names are close enough that "reuse the + * existing table" is a plausible-sounding wrong turn; this is the note that + * stops it. + */ +export const inspectionServicePaySplits = sqliteTable('inspection_service_pay_splits', { + id: text('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + inspectionServiceId: text('inspection_service_id').notNull(), + userId: text('user_id').notNull(), + amountCents: integer('amount_cents').notNull(), + source: text('source', { enum: ['rule', 'manual'] }).notNull(), + // Set when this split was included in a payroll export. From that moment + // the row is read-only: editing it would desynchronise the books from what + // was actually paid, with nothing surfacing the divergence. A correction + // after this point is a NEW row (see `correctsSplitId`), matching how the + // payment ledger treats money that has already moved. + lockedAt: integer('locked_at', { mode: 'timestamp_ms' }), + // Set on a correction row; points at the locked split being adjusted. The + // correction carries the DELTA (often negative), so the two rows sum to + // what the inspector is actually owed and neither one has been rewritten. + correctsSplitId: text('corrects_split_id'), + // Why a human moved this number — the audit answer for a disputed payout. + reason: text('reason'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}, (t) => [ + // Partial: one PRIMARY split per (line, user), but any number of correction + // rows against it. An unconditional unique here would make the correction + // path in the comment above impossible to write. + uniqueIndex('uq_pay_split_line_user').on(t.tenantId, t.inspectionServiceId, t.userId) + .where(sql`corrects_split_id IS NULL`), + index('idx_pay_split_user').on(t.tenantId, t.userId), + index('idx_pay_split_line').on(t.tenantId, t.inspectionServiceId), +]); + +export type ServicePayRule = typeof servicePayRules.$inferSelect; +export type InspectionServicePaySplit = typeof inspectionServicePaySplits.$inferSelect; diff --git a/tests/unit/pay-splits/schema.spec.ts b/tests/unit/pay-splits/schema.spec.ts new file mode 100644 index 000000000..8986063d6 --- /dev/null +++ b/tests/unit/pay-splits/schema.spec.ts @@ -0,0 +1,94 @@ +/** + * Pay-split schema, asserted at the DB level (#278). + * + * These are not "does drizzle work" tests. Each one pins a constraint that the + * money design depends on and that nothing else in the codebase would notice + * losing: + * + * - exactly ONE default rule per service (SQLite treats NULLs as distinct, so + * the obvious three-column unique silently allows two, and the populate + * step would then pick one arbitrarily); + * - exactly ONE primary split per (line, user), while still allowing the + * correction rows a locked split requires. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { servicePayRules, inspectionServicePaySplits } from '../../../server/lib/db/schema/pay-split'; +import { createTestDb, setupSchema } from '../db'; + +const T = Date.now(); + +describe('pay-split schema', () => { + let sqlite: import('better-sqlite3').Database; + + beforeEach(async () => { + const fixture = createTestDb(); + sqlite = fixture.sqlite; + await setupSchema(fixture.sqlite); + }); + + const insertRule = (id: string, userId: string | null) => + sqlite.prepare( + `INSERT INTO service_pay_rules (id, tenant_id, service_id, user_id, type, value, created_at) + VALUES (?,?,?,?,?,?,?)`, + ).run(id, 't1', 'svc1', userId, 'percent', 6000, T); + + const insertSplit = (id: string, userId: string, corrects: string | null) => + sqlite.prepare( + `INSERT INTO inspection_service_pay_splits + (id, tenant_id, inspection_service_id, user_id, amount_cents, source, corrects_split_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + ).run(id, 't1', 'line1', userId, 9000, 'rule', corrects, T, T); + + it('stores money in integer cents and timestamps in epoch ms', () => { + const cols = inspectionServicePaySplits as unknown as Record; + expect(cols.amountCents.name).toBe('amount_cents'); + expect(cols.createdAt.name).toBe('created_at'); + expect(cols.lockedAt.name).toBe('locked_at'); + const ddl = sqlite.prepare( + `SELECT sql FROM sqlite_master WHERE name = 'inspection_service_pay_splits'`, + ).get() as { sql: string }; + expect(ddl.sql).toMatch(/`amount_cents`\s+integer\s+NOT NULL/i); + expect(ddl.sql).toMatch(/`created_at`\s+integer\s+NOT NULL/i); + }); + + it('carries tenant_id NOT NULL and declares no foreign keys', () => { + // Schema Rules: new tables are tenant-scoped and app-layer-integrity + // only. A DB-level FK here would make the table impossible to rebuild + // on D1 for the rest of its life. + for (const table of ['service_pay_rules', 'inspection_service_pay_splits']) { + const ddl = (sqlite.prepare( + `SELECT sql FROM sqlite_master WHERE name = ?`, + ).get(table) as { sql: string }).sql; + expect(ddl, table).toMatch(/`tenant_id`\s+text\s+NOT NULL/i); + expect(ddl, table).not.toMatch(/REFERENCES/i); + } + }); + + it('allows exactly one DEFAULT rule per service', () => { + // The load-bearing one. A plain unique over (tenant, service, user) + // does NOT catch this — SQLite considers two NULL user_ids distinct. + insertRule('r1', null); + expect(() => insertRule('r2', null)).toThrow(/UNIQUE constraint/); + }); + + it('still allows a per-inspector rule alongside the default', () => { + insertRule('r1', null); + expect(() => insertRule('r2', 'u1')).not.toThrow(); + expect(() => insertRule('r3', 'u1')).toThrow(/UNIQUE constraint/); + const t = servicePayRules as unknown as Record; + expect(t.deductionCents.name).toBe('deduction_cents'); + }); + + it('allows exactly one PRIMARY split per line and user', () => { + insertSplit('s1', 'u1', null); + expect(() => insertSplit('s2', 'u1', null)).toThrow(/UNIQUE constraint/); + }); + + it('allows correction rows against a split without tripping that unique', () => { + // A locked split is never edited; a correction is a new row. If the + // unique index were unconditional, that path could not be written. + insertSplit('s1', 'u1', null); + expect(() => insertSplit('s2', 'u1', 's1')).not.toThrow(); + expect(() => insertSplit('s3', 'u1', 's1')).not.toThrow(); + }); +}); From 5a014058c88747329b307bdfde102f5b21e5b1c5 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 09:32:04 +0800 Subject: [PATCH 21/77] feat(pay-splits): populate splits from rules, once (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules populate a split row; after that only an explicit edit moves it. `populateSplits` is additive — an existing (line, user) pair is skipped whatever the rules now say — and `refreshSplits` is the single, explicit path that re-derives, behind `previewRefresh` so nobody's pay moves without someone deciding it should. Three rules that are easy to get wrong and are pinned by tests: - The computed amount is DIVIDED by the number of inspectors eligible for that line. Without it, attaching a second inspector pays out 120% of the service. - Reads filter `inspection_services.is_active`. A line declined at the door survives because a report or a split may point at it; paying against it anyway is what that column exists to prevent. A split whose line is deactivated later surfaces as an orphan, the same treatment a removed inspector's split gets. - `percent_after_deduction` takes the deduction off the top BEFORE the percentage, so it is not restatable as a smaller percentage. The roster is read through `getInspectionRoster` only, and assignment writes now go through `syncAssignmentsAndSplits` rather than `syncInspectionAssignments` directly — the roster write and the pay reconciliation move together so a new assignment path cannot record who worked the job while leaving the money on whoever worked it before. The reconciliation is deliberately quiet: a misconfigured pay rule must not make saving an assignment fail. Once payroll exports a split it locks, and a later adjustment is a new row carrying the delta. An in-place edit after money has moved desynchronises the books from what was actually paid with nothing surfacing the divergence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- server/api/inspections/core.ts | 4 +- server/api/inspections/schedule.ts | 4 +- server/services/pay-split.service.ts | 344 +++++++++++++++++++++++++ server/services/pay-split/core.ts | 172 +++++++++++++ server/services/service.service.ts | 9 + tests/unit/pay-splits/populate.spec.ts | 294 +++++++++++++++++++++ 6 files changed, 823 insertions(+), 4 deletions(-) create mode 100644 server/services/pay-split.service.ts create mode 100644 server/services/pay-split/core.ts create mode 100644 tests/unit/pay-splits/populate.spec.ts diff --git a/server/api/inspections/core.ts b/server/api/inspections/core.ts index 00dbca608..5873e914b 100644 --- a/server/api/inspections/core.ts +++ b/server/api/inspections/core.ts @@ -19,7 +19,7 @@ import { CreateInspectionFromWizardSchema } from '../../lib/validations/wizard.s import { inspections as inspectionTable, inspectionResults, users } from '../../lib/db/schema'; import { datePatchValues } from '../../services/inspection/reschedule-date'; import { deleteInspectionCascade } from '../../services/inspection/inspection-cascade'; -import { syncInspectionAssignments } from '../../lib/db/assignment-links'; +import { syncAssignmentsAndSplits } from '../../services/pay-split.service'; import { eq, and, isNull } from 'drizzle-orm'; import { withMcpMetadata } from '../../lib/route-metadata-standards'; import type { HonoConfig } from '../../types/hono'; @@ -364,7 +364,7 @@ const coreRoutes = createApiRouter() // here to avoid wiping "team mode" rows. They are frozen dead and were // NULL / '[]' on every row, so there was never anything to preserve. if ('inspectorId' in body) { - await syncInspectionAssignments(db, tenantId, id, { + await syncAssignmentsAndSplits(db, tenantId, id, { inspectorId: body.inspectorId ?? null, }); } diff --git a/server/api/inspections/schedule.ts b/server/api/inspections/schedule.ts index 303a3f6bb..5026d10cd 100644 --- a/server/api/inspections/schedule.ts +++ b/server/api/inspections/schedule.ts @@ -21,7 +21,7 @@ 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 { syncAssignmentsAndSplits } from '../../services/pay-split.service'; import { findScheduleConflicts } from '../../lib/schedule-conflicts'; import { resolveInternalHolidayEffect } from '../../lib/holidays/load-tenant-holidays'; import { epochMsToWallClockHm, epochMsToWallClockYmd, resolveTenantTimeZone } from '../../lib/tz'; @@ -205,7 +205,7 @@ const scheduleRoutes = createApiRouter() .where(and(eq(inspectionTable.id, id), eq(inspectionTable.tenantId, tenantId))); if (touchesAssignment) { - await syncInspectionAssignments(db, tenantId, id, { + await syncAssignmentsAndSplits(db, tenantId, id, { leadInspectorId: leadId, helperInspectorIds: helperIds, }); diff --git a/server/services/pay-split.service.ts b/server/services/pay-split.service.ts new file mode 100644 index 000000000..6964da398 --- /dev/null +++ b/server/services/pay-split.service.ts @@ -0,0 +1,344 @@ +/** + * Pay splits — populate once from tenant rules, then never re-derive (#278). + * + * The invariant this module exists to protect: **a split row is never + * recomputed after creation.** Rules populate it; after that only an explicit + * edit moves it. Recomputing on read means a rule change rewrites history, in + * money — the tenant who edits "60%" to "55%" would silently restate what + * people were already paid. + * + * Three consequences that look like bugs and are not: + * - `populateSplits` is ADDITIVE. A pair that already has a row is skipped, + * not overwritten (the partial unique index is the backstop). + * - A roster change never re-divides existing rows. The inspector already on + * the line keeps their amount; a new one gets a row derived against the + * current roster size. That leaves the line stale on purpose — + * `refreshSplits` is how a human resolves it and `previewRefresh` is how + * they see what it would do first. + * - Splits sum to <= the line's effective price, never forced to equal it. + * The remainder is company margin; forcing 100% would model a co-op. + * + * Splits attach to `inspection_services` lines, tier 2 of the money authority + * chain. An invoice overriding the ORDER total does not redistribute pay. + * + * Internals (reads + arithmetic) live in `./pay-split/core`. + */ +import { and, eq, isNull, gte, lte } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { inspectionServicePaySplits } from '../lib/db/schema'; +import type { InspectionServicePaySplit } from '../lib/db/schema'; +import { syncInspectionAssignments } from '../lib/db/assignment-links'; +import type { AssignmentOpts } from '../lib/db/assignment-links'; +import { Errors } from '../lib/errors'; +import { logger } from '../lib/logger'; +import { + activeLines, allLines, computeGross, eligibleFor, exceedError, linePriceCents, + loadQuals, loadRules, pickRule, requireSplit, rosterIds, splitsForLines, +} from './pay-split/core'; +import type { Db } from './pay-split/core'; + +export interface RefreshChange { + splitId: string; + userId: string; + inspectionServiceId: string; + from: number; + to: number; +} + +export interface OrphanSplit { + split: InspectionServicePaySplit; + reason: 'inspector_removed' | 'line_inactive'; +} + +/** Splits on one billing line, oldest first. */ +export async function getSplitsForLine( + db: Db, tenantId: string, inspectionServiceId: string, +): Promise { + return await splitsForLines(db, tenantId, [inspectionServiceId]); +} + +/** Splits across every ACTIVE line of an inspection. */ +export async function getSplitsForInspection( + db: Db, tenantId: string, inspectionId: string, +): Promise { + const lines = await activeLines(db, tenantId, inspectionId); + return await splitsForLines(db, tenantId, lines.map(l => l.id)); +} + +/** + * Create the split rows that do not exist yet. Idempotent and additive: an + * existing (line, user) pair is left exactly as it is, whatever the rules now + * say. + * + * Throws when the derived rows would push a line's splits past its effective + * price. Validation completes before ANY insert — a partial write here would + * leave a line half-paid with nothing surfacing it. + */ +export async function populateSplits(db: Db, tenantId: string, inspectionId: string): Promise { + const lines = await activeLines(db, tenantId, inspectionId); + if (lines.length === 0) return 0; + const roster = await rosterIds(db, tenantId, inspectionId); + if (roster.length === 0) return 0; + + const serviceIds = [...new Set(lines.map(l => l.serviceId))]; + const [rules, quals, existing] = await Promise.all([ + loadRules(db, tenantId, serviceIds), + loadQuals(db, tenantId, serviceIds), + splitsForLines(db, tenantId, lines.map(l => l.id)), + ]); + + const now = new Date(); + const pending: (typeof inspectionServicePaySplits.$inferInsert)[] = []; + + for (const line of lines) { + const eligible = eligibleFor(line.serviceId, roster, quals); + if (eligible.length === 0) continue; + const onLine = existing.filter(s => s.inspectionServiceId === line.id); + let total = onLine.reduce((sum, s) => sum + s.amountCents, 0); + + for (const userId of eligible) { + if (onLine.some(s => s.userId === userId && s.correctsSplitId === null)) continue; + const rule = pickRule(rules, line.serviceId, userId); + if (!rule) continue; + // The divisor is the competitor's mandatory, non-disableable rule: + // a 60% rule on a $500 service with two inspectors pays 30% each. + // Without it, a second inspector pays out 120% of the service. + const amountCents = Math.floor(computeGross(rule, line.priceCents) / eligible.length); + if (amountCents <= 0) continue; + if (total + amountCents > line.priceCents) throw exceedError(total + amountCents, line.priceCents); + total += amountCents; + pending.push({ + id: nanoid(), tenantId, inspectionServiceId: line.id, userId, + amountCents, source: 'rule', lockedAt: null, correctsSplitId: null, + reason: null, createdAt: now, updatedAt: now, + }); + } + } + + for (const row of pending) await db.insert(inspectionServicePaySplits).values(row).run(); + return pending.length; +} + +/** Rows the current roster and line set no longer justify. */ +export async function findOrphanSplits(db: Db, tenantId: string, inspectionId: string): Promise { + const lines = await allLines(db, tenantId, inspectionId); + if (lines.length === 0) return []; + const inactive = new Set(lines.filter(l => !l.active).map(l => l.id)); + const roster = new Set(await rosterIds(db, tenantId, inspectionId)); + const splits = await splitsForLines(db, tenantId, lines.map(l => l.id)); + + const out: OrphanSplit[] = []; + for (const split of splits) { + if (inactive.has(split.inspectionServiceId)) out.push({ split, reason: 'line_inactive' }); + else if (!roster.has(split.userId)) out.push({ split, reason: 'inspector_removed' }); + } + return out; +} + +/** + * Reconcile after a roster or service-line change: drop the rule-derived rows + * nobody is owed any more, then create the ones now missing. + * + * A MANUALLY edited or locked orphan is deliberately left standing. Someone + * agreed that number, or it has already been paid; deleting it because a + * roster changed would erase a decision, so it surfaces as an orphan for an + * admin to resolve instead. + */ +export async function syncSplitsForInspection( + db: Db, tenantId: string, inspectionId: string, +): Promise<{ removed: number; created: number }> { + const orphans = await findOrphanSplits(db, tenantId, inspectionId); + const removable = orphans.filter(o => + o.split.source === 'rule' && o.split.lockedAt === null && o.split.correctsSplitId === null); + for (const o of removable) { + await db.delete(inspectionServicePaySplits) + .where(and( + eq(inspectionServicePaySplits.tenantId, tenantId), + eq(inspectionServicePaySplits.id, o.split.id), + )) + .run(); + } + const created = await populateSplits(db, tenantId, inspectionId); + return { removed: removable.length, created }; +} + +/** + * The entry point for assignment and service-line writes. + * + * Swallows a bad pay rule deliberately: a misconfigured percentage must not + * make SAVING AN ASSIGNMENT fail. The splits stay stale, the warning is + * logged, and an explicit refresh resolves it — the same posture orphans get. + */ +export async function syncSplitsQuietly(db: Db, tenantId: string, inspectionId: string): Promise { + try { + await syncSplitsForInspection(db, tenantId, inspectionId); + } catch (err) { + logger.warn('pay-split sync skipped', { + tenantId, inspectionId, + reason: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Record who is assigned AND reconcile what they are owed, in that order. + * + * The two move together on purpose: an assignment path that writes the roster + * without touching splits leaves the money silently attributed to whoever was + * on the job before. Call this from assignment writes rather than + * `syncInspectionAssignments` directly, so a new path cannot forget the second + * half. Bulk importers stay on the bare roster writer — they run before any + * service line exists, so there is nothing to populate. + */ +export async function syncAssignmentsAndSplits( + db: Db, tenantId: string, inspectionId: string, opts: AssignmentOpts, +): Promise { + await syncInspectionAssignments(db, tenantId, inspectionId, opts); + await syncSplitsQuietly(db, tenantId, inspectionId); +} + +/** What `refreshSplits` would change, without changing it. */ +export async function previewRefresh(db: Db, tenantId: string, inspectionId: string): Promise { + const lines = await activeLines(db, tenantId, inspectionId); + if (lines.length === 0) return []; + const roster = await rosterIds(db, tenantId, inspectionId); + const serviceIds = [...new Set(lines.map(l => l.serviceId))]; + const [rules, quals, existing] = await Promise.all([ + loadRules(db, tenantId, serviceIds), + loadQuals(db, tenantId, serviceIds), + splitsForLines(db, tenantId, lines.map(l => l.id)), + ]); + + const out: RefreshChange[] = []; + for (const line of lines) { + const eligible = eligibleFor(line.serviceId, roster, quals); + if (eligible.length === 0) continue; + for (const split of existing.filter(s => s.inspectionServiceId === line.id)) { + // A manual amount is a human decision and a correction is a ledger + // entry; neither is re-derivable. + if (split.source !== 'rule' || split.correctsSplitId !== null) continue; + if (!eligible.includes(split.userId)) continue; + const rule = pickRule(rules, line.serviceId, split.userId); + if (!rule) continue; + const to = Math.floor(computeGross(rule, line.priceCents) / eligible.length); + if (to !== split.amountCents) { + out.push({ + splitId: split.id, userId: split.userId, + inspectionServiceId: line.id, from: split.amountCents, to, + }); + } + } + } + return out; +} + +/** + * Re-derive the rule-sourced splits from the CURRENT rules and roster. This is + * the only path that moves an existing amount, and it is deliberately + * explicit: re-deriving four amounts silently is how someone's pay changes + * without anyone deciding it should, and the person affected is the last to + * know. + */ +export async function refreshSplits(db: Db, tenantId: string, inspectionId: string): Promise { + const existing = await getSplitsForInspection(db, tenantId, inspectionId); + if (existing.some(s => s.lockedAt !== null)) { + throw Errors.Conflict( + 'This inspection has splits locked by a payroll export. Record a correction instead of refreshing.', + ); + } + const changes = await previewRefresh(db, tenantId, inspectionId); + const now = new Date(); + for (const change of changes) { + await db.update(inspectionServicePaySplits) + .set({ amountCents: change.to, updatedAt: now }) + .where(and( + eq(inspectionServicePaySplits.tenantId, tenantId), + eq(inspectionServicePaySplits.id, change.splitId), + )) + .run(); + } + await populateSplits(db, tenantId, inspectionId); + return changes.length; +} + +/** Set an agreed amount by hand. Marks the row `manual`, which exempts it from refresh. */ +export async function setSplitManually( + db: Db, tenantId: string, splitId: string, amountCents: number, reason?: string, +): Promise { + const split = await requireSplit(db, tenantId, splitId); + if (split.lockedAt !== null) { + throw Errors.Conflict('This split is locked by a payroll export. Record a correction instead.'); + } + const price = await linePriceCents(db, tenantId, split.inspectionServiceId); + const others = (await getSplitsForLine(db, tenantId, split.inspectionServiceId)) + .filter(s => s.id !== splitId) + .reduce((sum, s) => sum + s.amountCents, 0); + if (others + amountCents > price) throw exceedError(others + amountCents, price); + await db.update(inspectionServicePaySplits) + .set({ amountCents, source: 'manual', reason: reason ?? split.reason, updatedAt: new Date() }) + .where(and( + eq(inspectionServicePaySplits.tenantId, tenantId), + eq(inspectionServicePaySplits.id, splitId), + )) + .run(); + return await requireSplit(db, tenantId, splitId); +} + +/** + * Lock every unlocked split created in the period and hand them back as the + * payroll run. Locking IS the export: once money has moved, an edit would + * desynchronise the books from what was actually paid, with nothing surfacing + * the divergence. + */ +export async function exportPayroll( + db: Db, tenantId: string, period: { fromMs: number; toMs: number }, +): Promise { + const rows = await db.select().from(inspectionServicePaySplits) + .where(and( + eq(inspectionServicePaySplits.tenantId, tenantId), + isNull(inspectionServicePaySplits.lockedAt), + gte(inspectionServicePaySplits.createdAt, new Date(period.fromMs)), + lte(inspectionServicePaySplits.createdAt, new Date(period.toMs)), + )) + .all(); + const now = new Date(); + for (const row of rows) { + await db.update(inspectionServicePaySplits) + .set({ lockedAt: now, updatedAt: now }) + .where(and( + eq(inspectionServicePaySplits.tenantId, tenantId), + eq(inspectionServicePaySplits.id, row.id), + )) + .run(); + } + return rows.map(r => ({ ...r, lockedAt: now, updatedAt: now })); +} + +/** + * Adjust an already-exported split by writing a NEW row carrying the delta. + * The original survives untouched, so "what was paid" and "what was owed" are + * both still answerable — which an in-place edit destroys. + */ +export async function correctSplit( + db: Db, tenantId: string, splitId: string, input: { amountCents: number; reason: string }, +): Promise { + const split = await requireSplit(db, tenantId, splitId); + if (split.lockedAt === null) { + throw Errors.BadRequest('This split has not been exported yet — edit it directly instead of correcting it.'); + } + if (split.correctsSplitId !== null) { + throw Errors.BadRequest('Corrections are recorded against the original split, not against another correction.'); + } + const price = await linePriceCents(db, tenantId, split.inspectionServiceId); + const total = (await getSplitsForLine(db, tenantId, split.inspectionServiceId)) + .reduce((sum, s) => sum + s.amountCents, 0); + if (total + input.amountCents > price) throw exceedError(total + input.amountCents, price); + const now = new Date(); + const id = nanoid(); + await db.insert(inspectionServicePaySplits).values({ + id, tenantId, inspectionServiceId: split.inspectionServiceId, userId: split.userId, + amountCents: input.amountCents, source: 'manual', lockedAt: null, + correctsSplitId: split.id, reason: input.reason, createdAt: now, updatedAt: now, + }).run(); + return await requireSplit(db, tenantId, id); +} diff --git a/server/services/pay-split/core.ts b/server/services/pay-split/core.ts new file mode 100644 index 000000000..6be8851b8 --- /dev/null +++ b/server/services/pay-split/core.ts @@ -0,0 +1,172 @@ +/** + * Pay-split internals — the reads and the arithmetic, shared by every public + * operation in `pay-split.service.ts`. + * + * Extracted to keep that file under the size ratchet, and the seam is a real + * one: everything here is a pure derivation or a scoped read, and nothing here + * writes. The invariants that make pay splits correct (populate once, never + * re-derive, sum to <= the line price) live with the operations that enforce + * them, not here. + */ +import { and, eq, inArray } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { + inspectionServices, + serviceInspectors, + servicePayRules, + inspectionServicePaySplits, +} from '../../lib/db/schema'; +import type { ServicePayRule, InspectionServicePaySplit } from '../../lib/db/schema'; +import { getInspectionRoster } from '../../lib/inspection/roster'; +import { Errors } from '../../lib/errors'; + +export type Db = DrizzleD1Database; + +export interface Line { + id: string; + serviceId: string; + /** Effective line price — tier 2 of the money chain: `priceOverride ?? priceSnapshot`. */ + priceCents: number; +} + +/** + * The lines a split may attach to. + * + * Filters `is_active`, which is not optional: that column's own comment says + * every reader must filter on it and names pay splits as the reason it exists. + * A line declined at the door stays in the table because a report or a split + * may already point at it — paying against it anyway is the failure this + * filter prevents. + */ +export async function activeLines(db: Db, tenantId: string, inspectionId: string): Promise { + const rows = await db.select({ + id: inspectionServices.id, + serviceId: inspectionServices.serviceId, + priceOverride: inspectionServices.priceOverride, + priceSnapshot: inspectionServices.priceSnapshot, + }) + .from(inspectionServices) + .where(and( + eq(inspectionServices.tenantId, tenantId), + eq(inspectionServices.inspectionId, inspectionId), + eq(inspectionServices.active, true), + )) + .all(); + return rows.map(r => ({ id: r.id, serviceId: r.serviceId, priceCents: r.priceOverride ?? r.priceSnapshot })); +} + +/** Every line of an inspection, active or not — the orphan scan needs both. */ +export async function allLines(db: Db, tenantId: string, inspectionId: string) { + return await db.select({ id: inspectionServices.id, active: inspectionServices.active }) + .from(inspectionServices) + .where(and( + eq(inspectionServices.tenantId, tenantId), + eq(inspectionServices.inspectionId, inspectionId), + )) + .all(); +} + +/** + * Roster user ids, lead first. Read through `getInspectionRoster` only — never + * `inspections.inspector_id`, which is how "who worked this" acquired two + * disagreeing answers. Note the member key is `id`, not `userId`. + */ +export async function rosterIds(db: Db, tenantId: string, inspectionId: string): Promise { + const roster = await getInspectionRoster(db, tenantId, inspectionId); + return [...(roster.lead ? [roster.lead.id] : []), ...roster.helpers.map(h => h.id)]; +} + +/** + * Who may be paid on this line. ZERO qualification rows for a service means + * every staff member is qualified (the `service_inspectors` MVP default); + * rows restrict it. Mirrors the competitor's auto-assign, which pays every + * inspector on the job for the services they are not excluded from. + */ +export function eligibleFor(serviceId: string, roster: string[], quals: Map>): string[] { + const restricted = quals.get(serviceId); + if (!restricted || restricted.size === 0) return roster; + return roster.filter(u => restricted.has(u)); +} + +/** Gross amount for ONE line, before the per-inspector divide. */ +export function computeGross(rule: ServicePayRule, priceCents: number): number { + if (rule.type === 'fixed') return rule.value; + // The deduction comes out BEFORE the percentage, which is why this is its + // own type rather than a smaller percentage of the gross. + const base = rule.type === 'percent_after_deduction' + ? Math.max(0, priceCents - (rule.deductionCents ?? 0)) + : priceCents; + return Math.floor((base * rule.value) / 10000); +} + +/** A rule written for this inspector wins over the service default (`user_id IS NULL`). */ +export function pickRule(rules: ServicePayRule[], serviceId: string, userId: string): ServicePayRule | undefined { + return rules.find(r => r.serviceId === serviceId && r.userId === userId) + ?? rules.find(r => r.serviceId === serviceId && r.userId === null); +} + +export async function loadQuals(db: Db, tenantId: string, serviceIds: string[]): Promise>> { + const out = new Map>(); + if (serviceIds.length === 0) return out; + const rows = await db.select().from(serviceInspectors) + .where(and(eq(serviceInspectors.tenantId, tenantId), inArray(serviceInspectors.serviceId, serviceIds))) + .all(); + for (const r of rows) { + const set = out.get(r.serviceId) ?? new Set(); + set.add(r.userId); + out.set(r.serviceId, set); + } + return out; +} + +export async function loadRules(db: Db, tenantId: string, serviceIds: string[]): Promise { + if (serviceIds.length === 0) return []; + return await db.select().from(servicePayRules) + .where(and(eq(servicePayRules.tenantId, tenantId), inArray(servicePayRules.serviceId, serviceIds))) + .all(); +} + +export async function splitsForLines( + db: Db, tenantId: string, lineIds: string[], +): Promise { + if (lineIds.length === 0) return []; + const rows = await db.select().from(inspectionServicePaySplits) + .where(and( + eq(inspectionServicePaySplits.tenantId, tenantId), + inArray(inspectionServicePaySplits.inspectionServiceId, lineIds), + )) + .all(); + // Deterministic order: the original before the corrections written against it. + return rows.sort((a, b) => Number(a.createdAt) - Number(b.createdAt) || a.id.localeCompare(b.id)); +} + +export async function requireSplit(db: Db, tenantId: string, splitId: string): Promise { + const row = await db.select().from(inspectionServicePaySplits) + .where(and( + eq(inspectionServicePaySplits.tenantId, tenantId), + eq(inspectionServicePaySplits.id, splitId), + )) + .limit(1).get(); + if (!row) throw Errors.NotFound('Pay split not found'); + return row; +} + +export async function linePriceCents(db: Db, tenantId: string, lineId: string): Promise { + const line = await db.select({ + priceOverride: inspectionServices.priceOverride, + priceSnapshot: inspectionServices.priceSnapshot, + }) + .from(inspectionServices) + .where(and(eq(inspectionServices.tenantId, tenantId), eq(inspectionServices.id, lineId))) + .limit(1).get(); + if (!line) throw Errors.NotFound('Service line not found'); + return line.priceOverride ?? line.priceSnapshot; +} + +/** One message, one shape — the exceed guard is asserted by name in the tests. */ +export function exceedError(total: number, priceCents: number) { + return Errors.BadRequest( + `Pay splits would exceed the line price (${total} > ${priceCents} cents). ` + + 'Adjust the pay rule or the agreed amount for this service.', + ); +} diff --git a/server/services/service.service.ts b/server/services/service.service.ts index 96d885935..f0dbd0289 100644 --- a/server/services/service.service.ts +++ b/server/services/service.service.ts @@ -3,6 +3,7 @@ import { eq, and, asc, inArray, sql } from 'drizzle-orm'; import { services, inspectionServices, discountCodes, inspections, eventTypes, reports } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { getServiceInspectors, setServiceInspectors } from './service/qualification'; +import { syncSplitsQuietly } from './pay-split.service'; import { nanoid } from 'nanoid'; import type { z } from 'zod'; import type { CreateServiceSchema, UpdateServiceSchema, CreateDiscountCodeSchema } from '../lib/validations/service.schema'; @@ -166,6 +167,7 @@ export class ServiceService { eq(inspectionServices.id, existing.id), eq(inspectionServices.tenantId, tenantId), )); + await syncSplitsQuietly(db, tenantId, inspectionId); return { ...existing, active: true }; } @@ -179,6 +181,10 @@ export class ServiceService { nameSnapshot: svc.name, priceSnapshot: svc.price, }); + // A new billing line is a new thing to be paid for (#278). Additive and + // quiet: no existing amount moves, and a bad pay rule must not make + // adding the line fail. + await syncSplitsQuietly(db, tenantId, inspectionId); const rows = await db.select().from(inspectionServices) .where(and(eq(inspectionServices.id, id), eq(inspectionServices.tenantId, tenantId))); return rows[0]; @@ -259,6 +265,9 @@ export class ServiceService { eq(inspectionServices.tenantId, tenantId), eq(inspectionServices.inspectionId, inspectionId), )); + // Unpaid rule-derived splits on the dropped line go with it; anything a + // human agreed or payroll locked survives as an orphan to resolve (#278). + await syncSplitsQuietly(db, tenantId, inspectionId); } async listDiscountCodes(tenantId: string) { diff --git a/tests/unit/pay-splits/populate.spec.ts b/tests/unit/pay-splits/populate.spec.ts new file mode 100644 index 000000000..0193ec228 --- /dev/null +++ b/tests/unit/pay-splits/populate.spec.ts @@ -0,0 +1,294 @@ +/** + * Pay splits: populated once from tenant rules, then frozen (#278). + * + * The fixtures below are real writes against the real schema — in particular + * the roster goes through `syncInspectionAssignments`, which has FULL-REPLACE + * semantics. Passing only the newly added inspector deletes the others, and a + * test that then asserts "the first two amounts are untouched" passes for the + * wrong reason because those rows are gone. Every `assign()` here supplies the + * whole roster. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { and, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import * as schema from '../../../server/lib/db/schema'; +import { + tenants, users, services, inspections, inspectionServices, + serviceInspectors, servicePayRules, +} from '../../../server/lib/db/schema'; +import { syncInspectionAssignments } from '../../../server/lib/db/assignment-links'; +import { + populateSplits, getSplitsForLine, refreshSplits, previewRefresh, + setSplitManually, exportPayroll, correctSplit, syncSplitsForInspection, findOrphanSplits, +} from '../../../server/services/pay-split.service'; +import { createTestDb, setupSchema } from '../db'; + +const T = 't1'; +const INSP = 'i1'; +const LINE = 'line1'; +const SVC = 'svc-home'; +const RADON_LINE = 'line2'; +const RADON = 'svc-radon'; + +describe('pay-split population', () => { + let db: DrizzleD1Database; + + beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + db = drizzle(fixture.sqlite, { schema }) as unknown as DrizzleD1Database; + const now = new Date(); + + await db.insert(tenants).values({ + id: T, name: 'Acme', slug: 'acme', tier: 'free', status: 'active', + maxUsers: 5, deploymentMode: 'shared', createdAt: now, + }).run(); + for (const id of ['u1', 'u2', 'u3']) { + await db.insert(users).values({ + id, tenantId: T, email: `${id}@acme.test`, passwordHash: 'x', + name: id.toUpperCase(), role: 'inspector', createdAt: now, + }).run(); + } + await db.insert(services).values([ + { id: SVC, tenantId: T, name: 'Home Inspection', price: 50000, createdAt: now }, + { id: RADON, tenantId: T, name: 'Radon', price: 50000, createdAt: now }, + ]).run(); + await db.insert(inspections).values({ + id: INSP, tenantId: T, propertyAddress: '1 Oak St', date: '2026-08-01', createdAt: now, + }).run(); + await db.insert(inspectionServices).values([ + { id: LINE, tenantId: T, inspectionId: INSP, serviceId: SVC, nameSnapshot: 'Home Inspection', priceSnapshot: 50000 }, + { id: RADON_LINE, tenantId: T, inspectionId: INSP, serviceId: RADON, nameSnapshot: 'Radon', priceSnapshot: 50000 }, + ]).run(); + }); + + /** Full-replace roster write — the same call every production assignment path makes. */ + const assign = (lead: string, helpers: string[] = []) => + syncInspectionAssignments(db, T, INSP, { leadInspectorId: lead, helperInspectorIds: helpers }); + + const qualify = (serviceId: string, userIds: string[]) => + db.insert(serviceInspectors).values( + userIds.map(userId => ({ serviceId, userId, tenantId: T, createdAt: new Date() })), + ).run(); + + const setLine = (id: string, patch: { priceSnapshot?: number; priceOverride?: number | null; active?: boolean }) => + db.update(inspectionServices).set(patch) + .where(and(eq(inspectionServices.tenantId, T), eq(inspectionServices.id, id))).run(); + + const setRule = async (r: { + serviceId?: string; userId?: string | null; + type: 'percent' | 'fixed' | 'percent_after_deduction'; value: number; deductionCents?: number; + }) => { + const serviceId = r.serviceId ?? SVC; + const userId = r.userId ?? null; + await db.delete(servicePayRules).where(and( + eq(servicePayRules.tenantId, T), eq(servicePayRules.serviceId, serviceId), + )).run(); + await db.insert(servicePayRules).values({ + id: `rule-${serviceId}-${userId ?? 'default'}`, tenantId: T, serviceId, userId, + type: r.type, value: r.value, deductionCents: r.deductionCents ?? null, createdAt: new Date(), + }).run(); + }; + + const splits = (lineId = LINE) => getSplitsForLine(db, T, lineId); + + it('assigns only inspectors qualified for that service', async () => { + // Mirrors the competitor: auto-assign every inspector on the inspection + // to the services they are not excluded from in Service Limitations. + await qualify(RADON, ['u2']); // u1 is NOT qualified for radon + await setRule({ serviceId: RADON, type: 'percent', value: 6000 }); + await assign('u1', ['u2']); + await populateSplits(db, T, INSP); + + expect((await splits(RADON_LINE)).map(s => s.userId)).toEqual(['u2']); + }); + + it('computes a percent rule against the EFFECTIVE line price', async () => { + // priceOverride ?? priceSnapshot — tier 2 of the money authority chain. + await setLine(LINE, { priceSnapshot: 20000, priceOverride: 15000 }); + await setRule({ type: 'percent', value: 6000 }); + await assign('u1'); + await populateSplits(db, T, INSP); + + expect((await splits())[0].amountCents).toBe(9000); + }); + + it('DIVIDES the computed split by the number of inspectors on that line', async () => { + // Mandatory and non-disableable at the competitor, for a reason: without + // it, attaching a second inspector pays out 120% of the service. + await setRule({ type: 'percent', value: 6000 }); + await assign('u1', ['u2']); + await populateSplits(db, T, INSP); + + const rows = await splits(); + expect(rows).toHaveLength(2); + expect(rows.map(s => s.amountCents)).toEqual([15000, 15000]); // 30% each, not 60% + }); + + it('applies the deduction BEFORE the percentage', async () => { + // percent_after_deduction is not a smaller percentage of the gross. + await setRule({ type: 'percent_after_deduction', value: 6000, deductionCents: 10000 }); + await assign('u1'); + await populateSplits(db, T, INSP); + + expect((await splits())[0].amountCents).toBe(24000); // (500 - 100) * 60% + }); + + it('skips a line that is no longer active', async () => { + // `is_active` names pay splits as the reason it exists: a line declined + // at the door survives because a report or a split may point at it, and + // paying against it anyway is the failure the filter prevents. + await setRule({ type: 'percent', value: 6000 }); + await assign('u1'); + await setLine(LINE, { active: false }); + await populateSplits(db, T, INSP); + + expect(await splits()).toHaveLength(0); + }); + + it('does NOT re-divide when a third inspector is added later', async () => { + // Re-deriving on read would silently rewrite what the first two were paid. + await setRule({ type: 'percent', value: 6000 }); + await assign('u1', ['u2']); + await populateSplits(db, T, INSP); + const before = (await splits()).map(s => s.amountCents); + + await assign('u1', ['u2', 'u3']); // FULL roster — sync is full-replace + await populateSplits(db, T, INSP); + + const after = await splits(); + expect(after.filter(s => ['u1', 'u2'].includes(s.userId)).map(s => s.amountCents)).toEqual(before); + expect(after.find(s => s.userId === 'u3')?.amountCents).toBe(10000); // 30000 / 3 + }); + + it('does NOT recompute an existing split when the rule changes', async () => { + // A rule edit that rewrites what someone was already paid is the failure + // this whole design avoids. + await setRule({ type: 'percent', value: 6000 }); + await assign('u1'); + await populateSplits(db, T, INSP); + const before = (await splits())[0].amountCents; + + await setRule({ type: 'percent', value: 9000 }); + await populateSplits(db, T, INSP); + + expect((await splits())[0].amountCents).toBe(before); + }); + + it('refuses splits exceeding the line price', async () => { + await setRule({ type: 'fixed', value: 999999 }); + await assign('u1'); + await expect(populateSplits(db, T, INSP)).rejects.toThrow(/exceed/i); + }); + + it('allows splits summing to LESS than the line price', async () => { + // The remainder is company margin. Forcing 100% would model a co-op. + await setRule({ type: 'percent', value: 5000 }); + await assign('u1'); + await expect(populateSplits(db, T, INSP)).resolves.toBe(1); // only the line with a rule + expect((await splits())[0].amountCents).toBe(25000); + }); + + describe('refresh', () => { + beforeEach(async () => { + await setLine(LINE, { priceOverride: 15000 }); + await setRule({ type: 'percent', value: 6000 }); + await assign('u1'); + await populateSplits(db, T, INSP); + }); + + it('re-derives from the current rules and roster, after showing what it would do', async () => { + await setRule({ type: 'percent', value: 7000 }); + const preview = await previewRefresh(db, T, INSP); + expect(preview).toContainEqual(expect.objectContaining({ from: 9000, to: 10500 })); + + await refreshSplits(db, T, INSP); + expect((await splits())[0].amountCents).toBe(10500); + }); + + it('does NOT silently overwrite a manual amount', async () => { + const id = (await splits())[0].id; + await setSplitManually(db, T, id, 12345); + await setRule({ type: 'percent', value: 7000 }); + await refreshSplits(db, T, INSP); + + expect((await splits())[0].amountCents).toBe(12345); // a human chose this + }); + + it('REFUSES once a split is locked', async () => { + await exportPayroll(db, T, { fromMs: 0, toMs: Date.now() + 1000 }); + await expect(refreshSplits(db, T, INSP)).rejects.toThrow(/locked|payroll/i); + }); + }); + + describe('payroll lock', () => { + beforeEach(async () => { + await setRule({ type: 'percent', value: 6000 }); + await assign('u1'); + await populateSplits(db, T, INSP); + }); + + it('makes a split read-only once exported', async () => { + const id = (await splits())[0].id; + await exportPayroll(db, T, { fromMs: 0, toMs: Date.now() + 1000 }); + await expect(setSplitManually(db, T, id, 999)).rejects.toThrow(/locked/i); + }); + + it('records a correction as a NEW row, leaving the original standing', async () => { + const id = (await splits())[0].id; + await exportPayroll(db, T, { fromMs: 0, toMs: Date.now() + 1000 }); + await correctSplit(db, T, id, { amountCents: -2000, reason: 'overpaid' }); + + const rows = await splits(); + expect(rows).toHaveLength(2); + expect(rows[0].id).toBe(id); + expect(rows[0].lockedAt).not.toBeNull(); + expect(rows[0].amountCents).toBe(30000); // untouched + expect(rows[1].correctsSplitId).toBe(id); + }); + }); + + describe('orphans', () => { + it('drops a departed inspector\'s rule split but keeps their agreed one', async () => { + await setRule({ type: 'percent', value: 4000 }); + await assign('u1', ['u2']); + await populateSplits(db, T, INSP); + const u2Split = (await splits()).find(s => s.userId === 'u2')!; + await setSplitManually(db, T, u2Split.id, 5000, 'agreed for the crawlspace'); + + await assign('u1'); // u2 leaves + const { removed } = await syncSplitsForInspection(db, T, INSP); + + expect(removed).toBe(0); // nothing rule-sourced to drop + const rows = await splits(); + expect(rows.find(s => s.userId === 'u2')?.amountCents).toBe(5000); + expect(await findOrphanSplits(db, T, INSP)).toContainEqual( + expect.objectContaining({ reason: 'inspector_removed' }), + ); + }); + + it('removes an unpaid rule split when its inspector leaves', async () => { + await setRule({ type: 'percent', value: 4000 }); + await assign('u1', ['u2']); + await populateSplits(db, T, INSP); + + await assign('u1'); + const { removed } = await syncSplitsForInspection(db, T, INSP); + + expect(removed).toBe(1); + expect((await splits()).map(s => s.userId)).toEqual(['u1']); + }); + + it('treats a deactivated line the same way', async () => { + await setRule({ type: 'percent', value: 4000 }); + await assign('u1'); + await populateSplits(db, T, INSP); + await setLine(LINE, { active: false }); + + expect(await findOrphanSplits(db, T, INSP)).toContainEqual( + expect.objectContaining({ reason: 'line_inactive' }), + ); + }); + }); +}); From a6c65027368a055d1451551bd2bac0d6ae4eec43 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 09:35:39 +0800 Subject: [PATCH 22/77] fix(pay-splits): stop exporting a helper nothing outside the module calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lint:deadcode exits 1 on it, and knip-baseline.json is empty on purpose — an export with no consumer is a failure, not an allow-list entry. Its only caller is refreshSplits in the same file; Task 3 gives it a route and can export it then. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- server/services/pay-split.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/services/pay-split.service.ts b/server/services/pay-split.service.ts index 6964da398..d08da587e 100644 --- a/server/services/pay-split.service.ts +++ b/server/services/pay-split.service.ts @@ -57,8 +57,13 @@ export async function getSplitsForLine( return await splitsForLines(db, tenantId, [inspectionServiceId]); } -/** Splits across every ACTIVE line of an inspection. */ -export async function getSplitsForInspection( +/** Splits across every ACTIVE line of an inspection. + * + * Not exported: its only caller today is `refreshSplits` below. Task 3 gives it + * a route and will export it then — `knip-baseline.json` is empty on purpose, + * so an export with no consumer outside this module fails `lint:deadcode` + * rather than sitting in an allow-list. */ +async function getSplitsForInspection( db: Db, tenantId: string, inspectionId: string, ): Promise { const lines = await activeLines(db, tenantId, inspectionId); From cef07373066f9b1037ba8771803a8fddad82119a Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 09:58:32 +0800 Subject: [PATCH 23/77] feat(pay-splits): the API surface, and visibility as query scoping (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inspector may see their own pay and nobody else's. That is a THIRD state — `financial: false` AND `subject = self` — and no boolean permission expresses it, which is exactly why Housecall Pro and Jobber do not offer it: their pay permissions are binary. So it is implemented as query scoping, not a capability and not a redactor exemption. financial: true -> every split on the inspection, editable financial: false -> only rows where user_id = the caller, read-only `server/lib/auth/money-redaction.ts` is deliberately untouched. That redactor stops an endpoint leaking the COMPANY's money; an endpoint that structurally returns only the caller's own row is not leaking, so there is nothing to exempt, and the single shared security-sensitive file stays as it was. No `payroll` capability was added. The line is the existing `financial`. Mounted under the inspections aggregator rather than server/index.ts, which sits at 703 of a baselined 704 — a top-level mount costs two lines and hard- fails lint:filesize. The payroll export is company-level, so it went to the team router (staff administration) instead of inventing a home for it. The route definitions are NAMED CONSTS, and that is not style. `check-idempotency-coverage.mjs` discovers routes by resolving `.openapi(IDENT)` to a `const X = createRoute(...)`; a route written inline as `.openapi(createRoute(...), handler)` is INVISIBLE to it and is never asked for a retry story. Written inline first, all four money routes passed the gate by not existing to it. That is also why `POST /api/inspections/{id}/services` (inline, shipped earlier) is absent from the baseline while its named-const siblings are there — a pre-existing hole, reported not fixed here. All four mutating routes are VERIFIED by a replay spec, not parked: - POST .../corrections writes a NEW row carrying a delta, so an unguarded retry pays the delta twice and the ledger stays internally consistent while doing it. - POST /api/team/payroll-export LOCKS what it returns, so a replayed handler hands the operator an EMPTY run and the money reads as unowed. - PATCH and refresh are contained on their own; asserted as characterization and labelled, so nobody reads them as evidence for the guard. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- scripts/idempotency-baseline.json | 4 +- server/api/inspections.ts | 8 +- server/api/inspections/pay-splits.ts | 212 +++++++++++++++ server/api/team.ts | 54 ++++ server/lib/mcp/openapi-snapshot.json | 230 +++++++++++++++++ server/lib/validations/pay-split.schema.ts | 78 ++++++ server/services/pay-split.service.ts | 9 +- .../unit/idempotency/pay-split-replay.spec.ts | 241 ++++++++++++++++++ tests/unit/pay-splits/visibility.spec.ts | 225 ++++++++++++++++ 9 files changed, 1051 insertions(+), 10 deletions(-) create mode 100644 server/api/inspections/pay-splits.ts create mode 100644 server/lib/validations/pay-split.schema.ts create mode 100644 tests/unit/idempotency/pay-split-replay.spec.ts create mode 100644 tests/unit/pay-splits/visibility.spec.ts diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index 941d1900e..131958299 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -12,8 +12,8 @@ "run so it is never silently forgotten." ], "coverage": { - "declaredMutating": 312, - "resolvedMutating": 302 + "declaredMutating": 316, + "resolvedMutating": 306 }, "knownUnreachable": {}, "uncoveredByDesign": { diff --git a/server/api/inspections.ts b/server/api/inspections.ts index 6ed4b1062..4d076bf9a 100644 --- a/server/api/inspections.ts +++ b/server/api/inspections.ts @@ -41,6 +41,7 @@ import peopleRoutes from './inspections/people'; import communicationRoutes from './inspections/communication'; import inspectionServiceRoutes from './inspections/services'; import inspectionReportRoutes from './inspections/reports'; +import paySplitRoutes from './inspections/pay-splits'; export const inspectionsRoutes = createApiRouter() .route('/', bulkRoutes) @@ -77,6 +78,11 @@ export const inspectionsRoutes = createApiRouter() .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); + .route('/', inspectionReportRoutes) + // #278 — /:id/pay-splits. Mounted HERE rather than as a top-level router + // because server/index.ts sits at its size cap; and it belongs here anyway, + // since every path is per-inspection. Visibility is query scoping inside + // the handler, not a capability: an inspector reads only their own row. + .route('/', paySplitRoutes); export type InspectionsApi = typeof inspectionsRoutes; diff --git a/server/api/inspections/pay-splits.ts b/server/api/inspections/pay-splits.ts new file mode 100644 index 000000000..6e9507cfb --- /dev/null +++ b/server/api/inspections/pay-splits.ts @@ -0,0 +1,212 @@ +// /api/inspections/:id/pay-splits — the read and write face for what each +// inspector earns on one job (#278). +// +// THE VISIBILITY RULE IS QUERY SCOPING, NOT A CAPABILITY. Spectora's shipped +// behaviour is "an inspector sees their own pay split on every inspection and +// cannot see or edit anyone else's"; that is a THIRD state — `financial: false` +// AND `subject = self` — and no boolean permission flag can express it. Housecall +// Pro and Jobber both fail to offer it for exactly that reason: their pay +// permissions are binary, so "see your own" has nowhere to live. Ours would be +// binary too if this were a capability, so the GET is gated on ROLE and the ROWS +// are filtered: +// +// financial: true (owner/manager) → every split on the inspection, editable +// financial: false (inspector) → only rows where user_id = the caller, read-only +// +// Deliberately NOT routed through `server/lib/auth/money-redaction.ts`. That +// redactor exists to stop an endpoint leaking the COMPANY's money to someone +// without `financial`; an endpoint that structurally returns only the caller's +// own row is not leaking, so there is nothing to exempt. Leaving the single +// shared redactor untouched is worth more than the convenience of reusing it. +// +// Naming: `amountCents` on this surface is PAY — what the worker is owed. The +// company-side figure ("attributed revenue") is a metrics concern and never +// appears here. Never "cost": the inspector reads this payload. +// +// Every write is `requireCapability('financial')` on top of the role gate. An +// inspector's own row is read-only to them, which is the competitor's rule and +// also the only defensible one — a wage nobody but its recipient can change is +// not an agreement. +// +// The route definitions are NAMED CONSTS rather than inlined into the chain, +// which is not a style preference: `scripts/check-idempotency-coverage.mjs` +// discovers routes by reading `.openapi(IDENT)` and resolving IDENT to a +// `const X = createRoute(...)`. A route written inline as +// `.openapi(createRoute(...), handler)` is INVISIBLE to that gate, so its retry +// story is never asked for. These are money routes; being invisible to the +// ledger is the one thing they may not be. +import { createRoute, z } from '@hono/zod-openapi'; +import { createApiRouter } from '../../lib/openapi-router'; +import { requireRole } from '../../lib/middleware/rbac'; +import { requireCapability, capabilitiesFor } from '../../lib/middleware/require-capability'; +import { Errors } from '../../lib/errors'; +import { getDrizzle, getTenantId } from '../../lib/route-helpers'; +import type { InspectionServicePaySplit } from '../../lib/db/schema'; +import { + getSplitsForInspection, setSplitManually, correctSplit, previewRefresh, refreshSplits, +} from '../../services/pay-split.service'; +import { + PaySplitListResponseSchema, PaySplitResponseSchema, RefreshPreviewResponseSchema, + RefreshResultResponseSchema, SetPaySplitSchema, CorrectPaySplitSchema, +} from '../../lib/validations/pay-split.schema'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; + +const IdParam = z.object({ + id: z.string().trim().min(1).describe('Inspection id the pay rows belong to.'), +}); + +const SplitParam = IdParam.extend({ + splitId: z.string().trim().min(1).describe('inspection_service_pay_splits row id.'), +}); + +/** Epoch ms out, never Date — the wire shape matches the timestamp_ms columns. */ +const toWire = (s: InspectionServicePaySplit) => ({ + id: s.id, + inspectionServiceId: s.inspectionServiceId, + userId: s.userId, + amountCents: s.amountCents, + source: s.source, + lockedAtMs: s.lockedAt === null ? null : Number(s.lockedAt), + correctsSplitId: s.correctsSplitId, + reason: s.reason, + createdAtMs: Number(s.createdAt), + updatedAtMs: Number(s.updatedAt), +}); + +/** + * A split id in the path must belong to the inspection in the path. Without + * this the inspection segment is decoration and any tenant split could be + * edited through any inspection's URL — the id would still be tenant-scoped, + * but the audit trail would name the wrong job. + */ +async function requireOnInspection( + db: ReturnType, tenantId: string, inspectionId: string, splitId: string, +): Promise { + const rows = await getSplitsForInspection(db, tenantId, inspectionId); + if (!rows.some(s => s.id === splitId)) throw Errors.NotFound('Pay split not found on this inspection'); +} + +const listPaySplitsRoute = createRoute(withMcpMetadata({ + method: 'get', path: '/{id}/pay-splits', + tags: ['inspections'], + summary: 'List pay splits for one inspection', + middleware: [requireRole('owner', 'manager', 'inspector')] as const, + request: { params: IdParam }, + responses: { + 200: { content: { 'application/json': { schema: PaySplitListResponseSchema } }, description: 'Pay rows the caller is allowed to see' }, + }, + operationId: 'listInspectionPaySplits', + description: 'Returns what each inspector is owed on the active billing lines of one inspection. A caller with the financial capability receives every row and may edit them; a caller without it receives only their own rows, read-only, and a colleague\'s amount is absent from the payload rather than hidden in it.', +}, { scopes: ['read'], tier: 'extended' })); + +const previewRefreshRoute = createRoute(withMcpMetadata({ + method: 'get', path: '/{id}/pay-splits/refresh-preview', + tags: ['inspections'], + summary: 'Preview what refreshing pay would change', + middleware: [requireRole('owner', 'manager'), requireCapability('financial')] as const, + request: { params: IdParam }, + responses: { + 200: { content: { 'application/json': { schema: RefreshPreviewResponseSchema } }, description: 'The moves a refresh would make' }, + }, + operationId: 'previewInspectionPaySplitRefresh', + description: 'Shows which pay rows the current tenant rules and roster would move, and to what, WITHOUT moving them. Re-deriving amounts silently is how somebody\'s pay changes with nobody deciding it should, so the preview exists to make the decision explicit before the write.', +}, { scopes: ['read'], tier: 'extended', capability: 'financial' })); + +const setPaySplitRoute = createRoute(withMcpMetadata({ + method: 'patch', path: '/{id}/pay-splits/{splitId}', + tags: ['inspections'], + summary: 'Set an agreed pay amount by hand', + middleware: [requireRole('owner', 'manager'), requireCapability('financial')] as const, + request: { + params: SplitParam, + body: { content: { 'application/json': { schema: SetPaySplitSchema } } }, + }, + responses: { + 200: { content: { 'application/json': { schema: PaySplitResponseSchema } }, description: 'Pay row updated and marked manual' }, + 400: { description: 'The amount would push this line past its effective price' }, + 404: { description: 'No such pay row on this inspection' }, + 409: { description: 'The row is locked by a payroll export; record a correction instead' }, + }, + operationId: 'setInspectionPaySplit', + description: 'Overrides the pay owed to one inspector on one billing line and marks the row manual, which exempts it from any later refresh. Refuses once a payroll export has locked the row, because editing money that has already moved desynchronises the books with nothing surfacing the divergence.', +}, { scopes: ['write'], tier: 'extended', capability: 'financial' })); + +const refreshPaySplitsRoute = createRoute(withMcpMetadata({ + method: 'post', path: '/{id}/pay-splits/refresh', + tags: ['inspections'], + summary: 'Re-derive rule-sourced pay for this inspection', + middleware: [requireRole('owner', 'manager'), requireCapability('financial')] as const, + request: { params: IdParam }, + responses: { + 200: { content: { 'application/json': { schema: RefreshResultResponseSchema } }, description: 'Rule-sourced rows re-derived' }, + 409: { description: 'A payroll export has locked splits here; record a correction instead' }, + }, + operationId: 'refreshInspectionPaySplits', + description: 'Re-derives the rule-sourced pay rows from the tenant rules and roster as they stand now, leaving hand-edited and corrected rows alone. This is the only path that moves an amount that already exists, and it is deliberately an explicit act rather than something a read performs.', +}, { scopes: ['write'], tier: 'extended', capability: 'financial' })); + +const correctPaySplitRoute = createRoute(withMcpMetadata({ + method: 'post', path: '/{id}/pay-splits/{splitId}/corrections', + tags: ['inspections'], + summary: 'Record a correction against exported pay', + middleware: [requireRole('owner', 'manager'), requireCapability('financial')] as const, + request: { + params: SplitParam, + body: { content: { 'application/json': { schema: CorrectPaySplitSchema } } }, + }, + responses: { + 201: { content: { 'application/json': { schema: PaySplitResponseSchema } }, description: 'Correction row written' }, + 400: { description: 'The original is not exported yet, or is itself a correction' }, + 404: { description: 'No such pay row on this inspection' }, + }, + operationId: 'correctInspectionPaySplit', + description: 'Adjusts an already-exported pay row by writing a NEW row carrying the delta, leaving the original untouched so both what was paid and what was owed stay answerable. An in-place edit after payroll has run destroys that, which is why this is a separate verb.', +}, { scopes: ['write'], tier: 'extended', capability: 'financial' })); + +const paySplitRoutes = createApiRouter() + .openapi(listPaySplitsRoute, async (c) => { + const tenantId = getTenantId(c); + const { id } = c.req.valid('param'); + const caps = await capabilitiesFor(c); + const rows = await getSplitsForInspection(getDrizzle(c), tenantId, id); + const self = c.get('user')?.sub ?? ''; + const visible = caps.financial ? rows : rows.filter(s => s.userId === self); + return c.json({ + success: true, + data: { + canEdit: caps.financial, + scope: caps.financial ? ('all' as const) : ('self' as const), + splits: visible.map(toWire), + }, + }); + }) + .openapi(previewRefreshRoute, async (c) => { + const { id } = c.req.valid('param'); + const changes = await previewRefresh(getDrizzle(c), getTenantId(c), id); + return c.json({ success: true, data: { changes } }); + }) + .openapi(setPaySplitRoute, async (c) => { + const tenantId = getTenantId(c); + const { id, splitId } = c.req.valid('param'); + const { amountCents, reason } = c.req.valid('json'); + const db = getDrizzle(c); + await requireOnInspection(db, tenantId, id, splitId); + const row = await setSplitManually(db, tenantId, splitId, amountCents, reason); + return c.json({ success: true, data: toWire(row) }); + }) + .openapi(refreshPaySplitsRoute, async (c) => { + const { id } = c.req.valid('param'); + const changed = await refreshSplits(getDrizzle(c), getTenantId(c), id); + return c.json({ success: true, data: { changed } }); + }) + .openapi(correctPaySplitRoute, async (c) => { + const tenantId = getTenantId(c); + const { id, splitId } = c.req.valid('param'); + const input = c.req.valid('json'); + const db = getDrizzle(c); + await requireOnInspection(db, tenantId, id, splitId); + const row = await correctSplit(db, tenantId, splitId, input); + return c.json({ success: true, data: toWire(row) }, 201); + }); + +export default paySplitRoutes; diff --git a/server/api/team.ts b/server/api/team.ts index 1543e5dc8..c17c9f8e5 100644 --- a/server/api/team.ts +++ b/server/api/team.ts @@ -3,6 +3,9 @@ import { createApiRouter } from '../lib/openapi-router'; import { z } from '@hono/zod-openapi'; import { eq } from 'drizzle-orm'; import { requireRole } from '../lib/middleware/rbac'; +import { requireCapability } from '../lib/middleware/require-capability'; +import { exportPayroll } from '../services/pay-split.service'; +import { PayrollExportSchema, PayrollRunResponseSchema } from '../lib/validations/pay-split.schema'; import { requireSeatAvailable } from '../features/seat-quota'; import { getBaseUrl } from '../lib/url'; import { tenantConfigs } from '../lib/db/schema'; @@ -16,6 +19,38 @@ import { createApiResponseSchema } from '../lib/validations/shared.schema'; import { withMcpMetadata } from "../lib/route-metadata-standards"; import { getDrizzle } from '../lib/route-helpers'; +/** + * POST /api/team/payroll-export — the company-level half of #278. + * + * Lives on the TEAM router rather than under an inspection because a payroll + * run spans a period and everyone in it, and because `server/index.ts` sits at + * its size cap so a new top-level mount would hard-fail the file-size ratchet + * for no gain. Payroll is staff administration; this is the + * staff-administration surface. + * + * Gated on `financial` and not on a new `payroll` bit: the competitor evidence + * put the line at "sees the company's money", and a second flag that nothing + * else reads is a permission nobody would maintain. + * + * A NAMED const, not inlined into the chain — `check-idempotency-coverage.mjs` + * resolves `.openapi(IDENT)` and cannot see a route written inline. Exporting + * LOCKS every row it returns, so an unguarded retry hands the operator an empty + * run and the money reads as unowed; that route may not be invisible to the + * retry-safety ledger. + */ +const exportPayrollRoute = createRoute(withMcpMetadata({ + method: 'post', path: '/payroll-export', + operationId: 'exportTeamPayroll', + tags: ['team'], + summary: 'Lock and export pay for a period', + description: 'Locks every unlocked pay split created inside the given period and returns them as one payroll run. Locking IS the export: once money has moved an edit would desynchronise the books from what was actually paid, so a later adjustment has to be recorded as a correction row instead.', + middleware: [requireRole('owner', 'manager'), requireCapability('financial')] as const, + request: { body: { content: { 'application/json': { schema: PayrollExportSchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: PayrollRunResponseSchema } }, description: 'The pay rows this run locked' }, + }, +}, { scopes: ['admin'], tier: 'extended', capability: 'financial' })); + /** * GET /api/team/members * Fetches active members and pending invitations for the workspace. @@ -312,6 +347,25 @@ const teamRoutes = createApiRouter() await c.var.services.branding.updateBranding(tenantId, update); } return c.json({ success: true as const, data: { ok: true as const } }, 200); + }) + .openapi(exportPayrollRoute, async (c) => { + const tenantId = c.get('tenantId'); + const { fromMs, toMs } = c.req.valid('json'); + const rows = await exportPayroll(getDrizzle(c), tenantId, { fromMs, toMs }); + return c.json({ + success: true as const, + data: { + lockedCount: rows.length, + totalCents: rows.reduce((sum, r) => sum + r.amountCents, 0), + splits: rows.map(r => ({ + id: r.id, inspectionServiceId: r.inspectionServiceId, userId: r.userId, + amountCents: r.amountCents, source: r.source, + lockedAtMs: r.lockedAt === null ? null : Number(r.lockedAt), + correctsSplitId: r.correctsSplitId, reason: r.reason, + createdAtMs: Number(r.createdAt), updatedAtMs: Number(r.updatedAt), + })), + }, + }, 200); }); export type TeamApi = typeof teamRoutes; diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 10ab6961d..1e2e99e0d 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -1057,6 +1057,63 @@ "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": "correctInspectionPaySplit", + "method": "POST", + "pathTemplate": "/api/inspections/{id}/pay-splits/{splitId}/corrections", + "scopes": [ + "write" + ], + "tag": "inspections", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inspection id the pay rows belong to.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Inspection id the pay rows belong to." + } + }, + { + "name": "splitId", + "in": "path", + "required": true, + "description": "inspection_service_pay_splits row id.", + "schema": { + "type": "string", + "minLength": 1, + "description": "inspection_service_pay_splits row id." + } + } + ], + "body": { + "type": "object", + "properties": { + "amountCents": { + "type": "integer", + "description": "The DELTA against the locked split, in integer cents; may be negative." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Why the exported amount is being corrected." + } + }, + "required": [ + "amountCents", + "reason" + ] + } + }, + "summary": "Record a correction against exported pay", + "description": "Adjusts an already-exported pay row by writing a NEW row carrying the delta, leaving the original untouched so both what was paid and what was owed stay answerable. An in-place edit after payroll has run destroys that, which is why this is a separate verb." + }, { "operationId": "correctInvoicePayment", "method": "POST", @@ -6844,6 +6901,38 @@ "summary": "Export the caller account as a JSON blob", "description": "Returns the caller's user record plus their agent-tenant memberships and the inspections they ran, for GDPR/CCPA portability." }, + { + "operationId": "exportTeamPayroll", + "method": "POST", + "pathTemplate": "/api/team/payroll-export", + "scopes": [ + "admin" + ], + "tag": "team", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "type": "object", + "properties": { + "fromMs": { + "type": "integer", + "description": "Epoch ms of the first instant in the payroll period, inclusive." + }, + "toMs": { + "type": "integer", + "description": "Epoch ms of the last instant in the payroll period, inclusive." + } + }, + "required": [ + "fromMs", + "toMs" + ] + } + }, + "summary": "Lock and export pay for a period", + "description": "Locks every unlocked pay split created inside the given period and returns them as one payroll run. Locking IS the export: once money has moved an edit would desynchronise the books from what was actually paid, so a later adjustment has to be recorded as a correction row instead." + }, { "operationId": "exportTenant", "method": "GET", @@ -9959,6 +10048,34 @@ "summary": "Media Center — all attached + pool photos", "description": "Auto-generated placeholder for listInspectionMedia (GET /{id}/media, inspections domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "listInspectionPaySplits", + "method": "GET", + "pathTemplate": "/api/inspections/{id}/pay-splits", + "scopes": [ + "read" + ], + "tag": "inspections", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inspection id the pay rows belong to.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Inspection id the pay rows belong to." + } + } + ], + "body": null + }, + "summary": "List pay splits for one inspection", + "description": "Returns what each inspector is owed on the active billing lines of one inspection. A caller with the financial capability receives every row and may edit them; a caller without it receives only their own rows, read-only, and a colleague's amount is absent from the payload rather than hidden in it." + }, { "operationId": "listInspectionPdf", "method": "GET", @@ -15371,6 +15488,34 @@ "summary": "Preview resolved closed dates for a year", "description": "Returns the union of federal, state, and custom holidays for the configured region. Readable by inspectors for the My Schedule company-closed strip." }, + { + "operationId": "previewInspectionPaySplitRefresh", + "method": "GET", + "pathTemplate": "/api/inspections/{id}/pay-splits/refresh-preview", + "scopes": [ + "read" + ], + "tag": "inspections", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inspection id the pay rows belong to.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Inspection id the pay rows belong to." + } + } + ], + "body": null + }, + "summary": "Preview what refreshing pay would change", + "description": "Shows which pay rows the current tenant rules and roster would move, and to what, WITHOUT moving them. Re-deriving amounts silently is how somebody's pay changes with nobody deciding it should, so the preview exists to make the decision explicit before the write." + }, { "operationId": "previewMessageTemplate", "method": "POST", @@ -15569,6 +15714,34 @@ "summary": "Refresh PDF renders (Summary + Full)", "description": "Auto-generated placeholder for refreshInspection (POST /{id}/pdf/refresh, inspections domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "refreshInspectionPaySplits", + "method": "POST", + "pathTemplate": "/api/inspections/{id}/pay-splits/refresh", + "scopes": [ + "write" + ], + "tag": "inspections", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inspection id the pay rows belong to.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Inspection id the pay rows belong to." + } + } + ], + "body": null + }, + "summary": "Re-derive rule-sourced pay for this inspection", + "description": "Re-derives the rule-sourced pay rows from the tenant rules and roster as they stand now, leaving hand-edited and corrected rows alone. This is the only path that moves an amount that already exists, and it is deliberately an explicit act rather than something a read performs." + }, { "operationId": "regenerateTotpRecoveryCodes", "method": "POST", @@ -17137,6 +17310,63 @@ "summary": "Set cropped report cover (baked JPEG derivative + crop transform)", "description": "Bake and store a cropped report-cover JPEG derivative for an inspection and record its re-editable crop transform (POST /{id}/cover, inspections domain)." }, + { + "operationId": "setInspectionPaySplit", + "method": "PATCH", + "pathTemplate": "/api/inspections/{id}/pay-splits/{splitId}", + "scopes": [ + "write" + ], + "tag": "inspections", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inspection id the pay rows belong to.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Inspection id the pay rows belong to." + } + }, + { + "name": "splitId", + "in": "path", + "required": true, + "description": "inspection_service_pay_splits row id.", + "schema": { + "type": "string", + "minLength": 1, + "description": "inspection_service_pay_splits row id." + } + } + ], + "body": { + "type": "object", + "properties": { + "amountCents": { + "type": "integer", + "minimum": 0, + "description": "The agreed pay for this inspector on this line, in integer cents." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Why the amount was changed, kept for payout disputes." + } + }, + "required": [ + "amountCents" + ] + } + }, + "summary": "Set an agreed pay amount by hand", + "description": "Overrides the pay owed to one inspector on one billing line and marks the row manual, which exempts it from any later refresh. Refuses once a payroll export has locked the row, because editing money that has already moved desynchronises the books with nothing surfacing the divergence." + }, { "operationId": "setInspectionReportLinkExpiry", "method": "PUT", diff --git a/server/lib/validations/pay-split.schema.ts b/server/lib/validations/pay-split.schema.ts new file mode 100644 index 000000000..44cdb3d48 --- /dev/null +++ b/server/lib/validations/pay-split.schema.ts @@ -0,0 +1,78 @@ +import { z } from '@hono/zod-openapi'; +import { createApiResponseSchema } from './shared.schema'; + +/** + * Pay splits over HTTP (#278). + * + * The naming here is load-bearing and is NOT interchangeable. `amountCents` is + * the WORKER'S PAY. The company-side figure — what the business billed for the + * work — is "attributed revenue" and lives in the metrics payload, never in + * this one. Housecall Pro calls the worker's money `Commission Cost`, which is + * safe there because only an owner ever opens that report; an inspector opens + * THIS one, so "cost" is not a word this surface may use. + */ +const PaySplitSchema = z.object({ + id: z.string().describe('Pay split row id.'), + inspectionServiceId: z.string().describe('Billing line (inspection_services.id) this pay attaches to.'), + userId: z.string().describe('Staff member the pay is owed to.'), + amountCents: z.number().int().describe('Pay owed to this inspector on this line, in integer cents.'), + source: z.enum(['rule', 'manual']).describe('Whether a tenant pay rule populated this row or a human set it.'), + lockedAtMs: z.number().int().nullable().describe('Epoch ms this row was locked by a payroll export; null while still editable.'), + correctsSplitId: z.string().nullable().describe('Set on a correction row; the locked split it carries a delta against.'), + reason: z.string().nullable().describe('Why a human moved this number — the audit answer for a disputed payout.'), + createdAtMs: z.number().int().describe('Epoch ms the row was created.'), + updatedAtMs: z.number().int().describe('Epoch ms the row last changed.'), +}); + +const PaySplitListSchema = z.object({ + /** + * False for an inspector reading their own row. The list is already scoped + * to them by the query, so this drives the UI rather than the security — + * the rows a `financial: false` caller may not see are absent, not hidden. + */ + canEdit: z.boolean().describe('Whether the caller may change these amounts (the financial capability).'), + scope: z.enum(['all', 'self']).describe('Whether the rows cover everyone on the inspection or only the caller.'), + splits: z.array(PaySplitSchema).describe('Pay rows for the active billing lines of this inspection.'), +}); + +export const SetPaySplitSchema = z.object({ + amountCents: z.number().int().min(0).describe('The agreed pay for this inspector on this line, in integer cents.'), + reason: z.string().trim().min(1).max(500).optional().describe('Why the amount was changed, kept for payout disputes.'), +}); + +export const CorrectPaySplitSchema = z.object({ + amountCents: z.number().int().describe('The DELTA against the locked split, in integer cents; may be negative.'), + reason: z.string().trim().min(1).max(500).describe('Why the exported amount is being corrected.'), +}); + +const RefreshPreviewSchema = z.object({ + changes: z.array(z.object({ + splitId: z.string().describe('Pay split row that would change.'), + userId: z.string().describe('Staff member whose pay would move.'), + inspectionServiceId: z.string().describe('Billing line the row belongs to.'), + from: z.number().int().describe('Current pay in integer cents.'), + to: z.number().int().describe('Pay the current rules and roster would produce, in integer cents.'), + })).describe('What an explicit refresh would change, before anything changes.'), +}); + +const RefreshResultSchema = z.object({ + changed: z.number().int().describe('How many existing pay rows the refresh moved.'), +}); + +const PayrollExportSchema = z.object({ + fromMs: z.number().int().describe('Epoch ms of the first instant in the payroll period, inclusive.'), + toMs: z.number().int().describe('Epoch ms of the last instant in the payroll period, inclusive.'), +}); + +const PayrollRunSchema = z.object({ + lockedCount: z.number().int().describe('How many pay rows this export locked.'), + totalCents: z.number().int().describe('Total pay locked by this export, in integer cents.'), + splits: z.array(PaySplitSchema).describe('The pay rows this export locked, now read-only.'), +}); + +export const PaySplitListResponseSchema = createApiResponseSchema(PaySplitListSchema); +export const RefreshPreviewResponseSchema = createApiResponseSchema(RefreshPreviewSchema); +export const RefreshResultResponseSchema = createApiResponseSchema(RefreshResultSchema); +export const PaySplitResponseSchema = createApiResponseSchema(PaySplitSchema); +export const PayrollRunResponseSchema = createApiResponseSchema(PayrollRunSchema); +export { PayrollExportSchema }; diff --git a/server/services/pay-split.service.ts b/server/services/pay-split.service.ts index d08da587e..6964da398 100644 --- a/server/services/pay-split.service.ts +++ b/server/services/pay-split.service.ts @@ -57,13 +57,8 @@ export async function getSplitsForLine( return await splitsForLines(db, tenantId, [inspectionServiceId]); } -/** Splits across every ACTIVE line of an inspection. - * - * Not exported: its only caller today is `refreshSplits` below. Task 3 gives it - * a route and will export it then — `knip-baseline.json` is empty on purpose, - * so an export with no consumer outside this module fails `lint:deadcode` - * rather than sitting in an allow-list. */ -async function getSplitsForInspection( +/** Splits across every ACTIVE line of an inspection. */ +export async function getSplitsForInspection( db: Db, tenantId: string, inspectionId: string, ): Promise { const lines = await activeLines(db, tenantId, inspectionId); diff --git a/tests/unit/idempotency/pay-split-replay.spec.ts b/tests/unit/idempotency/pay-split-replay.spec.ts new file mode 100644 index 000000000..6cc67ac18 --- /dev/null +++ b/tests/unit/idempotency/pay-split-replay.spec.ts @@ -0,0 +1,241 @@ +/** + * Retry safety for the pay-split write surface (#278). + * + * Two of these four routes have a real hazard and two are naturally contained; + * all four are asserted here rather than argued about in a baseline entry, + * because they are the routes that decide what a person is paid. + * + * - POST .../corrections is the dangerous one. A correction is a NEW ROW + * carrying a delta, so an unguarded retry pays the delta TWICE and the + * ledger looks internally consistent while doing it. + * - POST /api/team/payroll-export is dangerous in the opposite direction: + * exporting LOCKS the rows it returns, so a replay that re-ran the handler + * would hand the operator an EMPTY run and the money would read as unowed. + * - PATCH .../{splitId} sets an absolute amount and POST .../refresh + * re-derives to a fixed point, so both survive a replay on their own. They + * are asserted as characterization, labelled as such, so nobody later reads + * them as evidence for the guard. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { and, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import * as schema from '../../../server/lib/db/schema'; +import { + tenants, users, services, inspections, inspectionServices, servicePayRules, + inspectionServicePaySplits, +} from '../../../server/lib/db/schema'; +import { syncInspectionAssignments } from '../../../server/lib/db/assignment-links'; +import { populateSplits, exportPayroll } from '../../../server/services/pay-split.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; +import { createTestDb, setupSchema } from '../db'; + +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'; +// eslint-disable-next-line import/order +import teamRoutes from '../../../server/api/team'; + +const T = 't1'; +const INSP = 'i1'; +const LINE = 'line1'; +const SVC = 'svc-home'; +const MGR = 'mgr'; +const FAKE_ENV = { DB: {} } as HonoConfig['Bindings']; +const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; + +let db: DrizzleD1Database; + +function buildApp() { + (mockDrizzle as unknown 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', T); + c.set('userRole', 'manager'); + c.set('user', { sub: MGR, role: 'manager', tenantId: T }); + c.set('sdb', { getById: async () => ({ permissionOverrides: null }) } as unknown as HonoConfig['Variables']['sdb']); + c.set('services', {} as unknown as HonoConfig['Variables']['services']); + await next(); + }); + // The mounted shape: tenant on the context first, then the guard. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/inspections', inspectionsRoutes); + app.route('/api/team', teamRoutes); + return app; +} + +function send(method: string, path: string, key: string | null, body?: unknown) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().fetch( + new Request(`https://acme.example.com${path}`, { + method, headers, body: body === undefined ? undefined : JSON.stringify(body), + }), + FAKE_ENV as never, CTX, + ); +} + +const allSplits = () => db.select().from(inspectionServicePaySplits) + .where(eq(inspectionServicePaySplits.tenantId, T)).all(); + +const splitIdFor = async (userId: string) => { + const row = await db.select().from(inspectionServicePaySplits) + .where(and( + eq(inspectionServicePaySplits.tenantId, T), + eq(inspectionServicePaySplits.userId, userId), + )) + .limit(1).get(); + if (!row) throw new Error(`no split seeded for ${userId}`); + return row.id; +}; + +beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + db = drizzle(fixture.sqlite, { schema }) as unknown as DrizzleD1Database; + const now = new Date(); + + await db.insert(tenants).values({ + id: T, name: 'Acme', slug: 'acme', tier: 'free', status: 'active', + maxUsers: 5, deploymentMode: 'shared', createdAt: now, + }).run(); + for (const id of ['u1', 'u2']) { + await db.insert(users).values({ + id, tenantId: T, email: `${id}@acme.test`, passwordHash: 'x', + name: id.toUpperCase(), role: 'inspector', createdAt: now, + }).run(); + } + await db.insert(services).values({ + id: SVC, tenantId: T, name: 'Home Inspection', price: 50000, createdAt: now, + }).run(); + await db.insert(inspections).values({ + id: INSP, tenantId: T, propertyAddress: '1 Oak St', date: '2026-08-01', createdAt: now, + }).run(); + await db.insert(inspectionServices).values({ + id: LINE, tenantId: T, inspectionId: INSP, serviceId: SVC, + nameSnapshot: 'Home Inspection', priceSnapshot: 50000, + }).run(); + await db.insert(servicePayRules).values({ + id: 'rule-default', tenantId: T, serviceId: SVC, userId: null, + type: 'percent', value: 6000, deductionCents: null, createdAt: now, + }).run(); + await syncInspectionAssignments(db, T, INSP, { leadInspectorId: 'u1', helperInspectorIds: ['u2'] }); + await populateSplits(db, T, INSP); +}); + +describe("POST '/api/inspections/{id}/pay-splits/{splitId}/corrections' — a replay must not pay the delta twice", () => { + let lockedId: string; + + beforeEach(async () => { + // A correction is only legal against an EXPORTED row, so lock first. + await exportPayroll(db, T, { fromMs: 0, toMs: Date.now() + 86_400_000 }); + lockedId = await splitIdFor('u1'); + }); + + const correct = (key: string | null) => send( + 'POST', `/api/inspections/${INSP}/pay-splits/${lockedId}/corrections`, key, + { amountCents: 5000, reason: 'Agreed uplift for the crawlspace' }, + ); + + it('writes ONE correction row across two posts under one key', async () => { + const first = await correct('corr-1'); + const second = await correct('corr-1'); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + const corrections = (await allSplits()).filter(s => s.correctsSplitId !== null); + expect(corrections).toHaveLength(1); + expect(corrections[0].amountCents).toBe(5000); + }); + + it('replays the original response, flagged', async () => { + const first = await correct('corr-1'); + const second = await correct('corr-1'); + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + }); + + it('a DELIBERATE second correction under a fresh key still lands', async () => { + await correct('corr-1'); + await correct('corr-2'); + expect((await allSplits()).filter(s => s.correctsSplitId !== null)).toHaveLength(2); + }); + + it('UNGUARDED, the same post twice pays the delta twice — the hazard, stated', async () => { + // No key: the guard cannot key on anything, and both posts write. + await correct(null); + await correct(null); + expect((await allSplits()).filter(s => s.correctsSplitId !== null)).toHaveLength(2); + }); +}); + +describe("POST '/api/team/payroll-export' — a replay must not report an empty run", () => { + // A FIXED period, not `Date.now() + …`: the guard fingerprints the body, so + // a period that moves between calls is a different request under the same + // key and the endpoint correctly answers 422 instead of replaying. That + // failure mode passes a naive "nothing was double-locked" assertion, which + // is exactly the kind of green this spec exists to refuse. + const PERIOD = { fromMs: 0, toMs: 4_102_444_800_000 }; + const exportRun = (key: string | null) => send('POST', '/api/team/payroll-export', key, PERIOD); + + it('returns the SAME run twice under one key, not an empty second one', async () => { + const first = await exportRun('pay-1'); + const second = await exportRun('pay-1'); + const a = await first.json() as { data: { lockedCount: number; totalCents: number } }; + const b = await second.json() as { data: { lockedCount: number; totalCents: number } }; + + expect(a.data.lockedCount).toBe(2); + // Re-running the handler would lock nothing (every row is already + // locked) and the operator's retry would read as "no pay was owed". + expect(b.data).toEqual(a.data); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + }); + + it('locks each row once — a replay does not restamp locked_at', async () => { + const first = await exportRun('pay-1'); + const body = await first.json() as { data: { splits: { id: string; lockedAtMs: number }[] } }; + const before = new Map(body.data.splits.map(s => [s.id, s.lockedAtMs])); + await exportRun('pay-1'); + + for (const row of await allSplits()) { + expect(Number(row.lockedAt)).toBe(before.get(row.id)); + } + }); +}); + +describe("PATCH '/api/inspections/{id}/pay-splits/{splitId}' and POST '/api/inspections/{id}/pay-splits/refresh'", () => { + it('CHARACTERIZATION: setting an absolute amount survives a replay on its own', async () => { + // Not evidence for the guard. The route writes a value, not a delta, so + // a second identical write is the same state. Stated rather than left + // for someone to rediscover by turning it into a delta later — at which + // point this assertion is the one that should be rewritten, loudly. + const id = await splitIdFor('u1'); + const path = `/api/inspections/${INSP}/pay-splits/${id}`; + await send('PATCH', path, 'set-1', { amountCents: 20000 }); + await send('PATCH', path, 'set-1', { amountCents: 20000 }); + + const rows = (await allSplits()).filter(s => s.id === id); + expect(rows).toHaveLength(1); + expect(rows[0].amountCents).toBe(20000); + }); + + it('CHARACTERIZATION: refresh re-derives to a fixed point, so a replay changes nothing', async () => { + const path = `/api/inspections/${INSP}/pay-splits/refresh`; + const first = await send('POST', path, 'ref-1'); + const second = await send('POST', path, 'ref-1'); + expect(await second.json()).toEqual(await first.clone().json()); + expect((await allSplits()).map(s => s.amountCents).sort()).toEqual([15000, 15000]); + }); +}); diff --git a/tests/unit/pay-splits/visibility.spec.ts b/tests/unit/pay-splits/visibility.spec.ts new file mode 100644 index 000000000..e6131f3d5 --- /dev/null +++ b/tests/unit/pay-splits/visibility.spec.ts @@ -0,0 +1,225 @@ +/** + * Pay-split visibility, asserted over HTTP (#278). + * + * These go through `app.request` rather than calling the service, because the + * thing being pinned is not a function's return value — it is what a REQUEST + * receives. The rule ("an inspector sees their own pay and nobody else's") is a + * third state that no boolean permission can express: `financial: false` AND + * `subject = self`. It is implemented as QUERY SCOPING inside the handler, so a + * test that bypassed the route would prove nothing at all. + * + * The load-bearing assertion is the negative one: a colleague's amount must be + * ABSENT from the payload, not merely unrendered. A wage hidden in the response + * body is a wage that has been disclosed. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { and, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import * as schema from '../../../server/lib/db/schema'; +import { + tenants, users, services, inspections, inspectionServices, servicePayRules, + inspectionServicePaySplits, +} from '../../../server/lib/db/schema'; +import { syncInspectionAssignments } from '../../../server/lib/db/assignment-links'; +import { populateSplits } from '../../../server/services/pay-split.service'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; +import type { UserRole } from '../../../server/types/auth'; +import { createTestDb, setupSchema } from '../db'; + +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'; +// eslint-disable-next-line import/order +import teamRoutes from '../../../server/api/team'; + +const T = 't1'; +const INSP = 'i1'; +const LINE = 'line1'; +const SVC = 'svc-home'; +const FAKE_ENV = { DB: {} } as HonoConfig['Bindings']; + +let db: DrizzleD1Database; + +type Overrides = Record | null; + +function buildApp(actor: string, role: UserRole, overrides: Overrides = null) { + (mockDrizzle as unknown 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', T); + c.set('userRole', role); + c.set('user', { sub: actor, role, tenantId: T }); + 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); + app.route('/api/team', teamRoutes); + return app; +} + +const getSplitsAs = (actor: string, role: UserRole, overrides: Overrides = null) => + buildApp(actor, role, overrides).request(`/api/inspections/${INSP}/pay-splits`, {}, FAKE_ENV); + +// 20000c against a 50000c line whose other inspector already holds 15000c — +// inside the "splits sum to <= the line price" guard, so a 400 here would mean +// that guard fired rather than that the caller was refused. +const patchSplitAs = (actor: string, role: UserRole, splitId: string, overrides: Overrides = null) => + buildApp(actor, role, overrides).request(`/api/inspections/${INSP}/pay-splits/${splitId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amountCents: 20000 }), + }, FAKE_ENV); + +const exportPayrollAs = (actor: string, role: UserRole, overrides: Overrides = null) => + buildApp(actor, role, overrides).request('/api/team/payroll-export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fromMs: 0, toMs: Date.now() + 86_400_000 }), + }, FAKE_ENV); + +const splitIdFor = async (userId: string) => { + const row = await db.select().from(inspectionServicePaySplits) + .where(and(eq(inspectionServicePaySplits.tenantId, T), eq(inspectionServicePaySplits.userId, userId))) + .limit(1).get(); + if (!row) throw new Error(`no split seeded for ${userId}`); + return row.id; +}; + +beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + db = drizzle(fixture.sqlite, { schema }) as unknown as DrizzleD1Database; + const now = new Date(); + + await db.insert(tenants).values({ + id: T, name: 'Acme', slug: 'acme', tier: 'free', status: 'active', + maxUsers: 5, deploymentMode: 'shared', createdAt: now, + }).run(); + for (const id of ['u1', 'u2']) { + await db.insert(users).values({ + id, tenantId: T, email: `${id}@acme.test`, passwordHash: 'x', + name: id.toUpperCase(), role: 'inspector', createdAt: now, + }).run(); + } + await db.insert(services).values({ + id: SVC, tenantId: T, name: 'Home Inspection', price: 50000, createdAt: now, + }).run(); + await db.insert(inspections).values({ + id: INSP, tenantId: T, propertyAddress: '1 Oak St', date: '2026-08-01', createdAt: now, + }).run(); + await db.insert(inspectionServices).values({ + id: LINE, tenantId: T, inspectionId: INSP, serviceId: SVC, + nameSnapshot: 'Home Inspection', priceSnapshot: 50000, + }).run(); + await db.insert(servicePayRules).values({ + id: 'rule-default', tenantId: T, serviceId: SVC, userId: null, + type: 'percent', value: 6000, deductionCents: null, createdAt: now, + }).run(); + // Full-replace roster write, the same call every production path makes. + await syncInspectionAssignments(db, T, INSP, { leadInspectorId: 'u1', helperInspectorIds: ['u2'] }); + await populateSplits(db, T, INSP); +}); + +describe('GET /api/inspections/:id/pay-splits — who sees whose pay', () => { + it('an inspector sees only their own split', async () => { + const res = await getSplitsAs('u1', 'inspector'); + expect(res.status).toBe(200); + const body = await res.json() as { data: { splits: { userId: string }[]; scope: string; canEdit: boolean } }; + expect(body.data.splits.length).toBeGreaterThan(0); + expect(body.data.splits.every(s => s.userId === 'u1')).toBe(true); + expect(body.data.scope).toBe('self'); + expect(body.data.canEdit).toBe(false); + }); + + it("an inspector never receives a colleague's amount, not even to hide it", async () => { + // A wage is not information to withhold visually — it must not be in + // the payload at all. This is the assertion the whole design serves. + const res = await getSplitsAs('u1', 'inspector'); + expect(JSON.stringify(await res.json())).not.toContain('u2'); + }); + + it('a manager sees every split on the inspection, editable', async () => { + const res = await getSplitsAs('mgr', 'manager'); + const body = await res.json() as { data: { splits: { userId: string }[]; scope: string; canEdit: boolean } }; + expect(body.data.splits.map(s => s.userId).sort()).toEqual(['u1', 'u2']); + expect(body.data.scope).toBe('all'); + expect(body.data.canEdit).toBe(true); + }); + + it('the line is the CAPABILITY, not the role — an inspector granted financial sees everyone', async () => { + // The mirror test: if this returned one row, the scoping would be + // keyed on the role tier and the override would be decorative. + const res = await getSplitsAs('u1', 'inspector', { financial: true }); + const body = await res.json() as { data: { splits: { userId: string }[] } }; + expect(body.data.splits.map(s => s.userId).sort()).toEqual(['u1', 'u2']); + }); + + it('a manager whose financial override is revoked drops to their own rows', async () => { + const res = await getSplitsAs('u2', 'manager', { financial: false }); + const body = await res.json() as { data: { splits: { userId: string }[]; scope: string } }; + expect(body.data.splits.map(s => s.userId)).toEqual(['u2']); + expect(body.data.scope).toBe('self'); + }); +}); + +describe('writing pay — an agreement only one side can move is not an agreement', () => { + it('an inspector cannot edit a split, including their own', async () => { + const res = await patchSplitAs('u1', 'inspector', await splitIdFor('u1')); + expect(res.status).toBe(403); + }); + + it('an inspector granted financial still cannot edit — the write is role-gated too', async () => { + const res = await patchSplitAs('u1', 'inspector', await splitIdFor('u1'), { financial: true }); + expect(res.status).toBe(403); + }); + + it('a manager can edit', async () => { + const res = await patchSplitAs('mgr', 'manager', await splitIdFor('u1')); + expect(res.status).toBe(200); + const body = await res.json() as { data: { amountCents: number; source: string } }; + expect(body.data).toMatchObject({ amountCents: 20000, source: 'manual' }); + }); + + it('a manager without financial cannot edit', async () => { + const res = await patchSplitAs('mgr', 'manager', await splitIdFor('u1'), { financial: false }); + expect(res.status).toBe(403); + }); + + it('the split id must belong to the inspection in the path', async () => { + const res = await patchSplitAs('mgr', 'manager', 'not-a-split-on-this-job'); + expect(res.status).toBe(404); + }); +}); + +describe('POST /api/team/payroll-export — locking is financial work', () => { + it('an inspector cannot export payroll', async () => { + expect((await exportPayrollAs('u1', 'inspector')).status).toBe(403); + }); + + it('a manager without financial cannot export payroll', async () => { + expect((await exportPayrollAs('mgr', 'manager', { financial: false })).status).toBe(403); + }); + + it('a manager exports and the rows come back locked', async () => { + const res = await exportPayrollAs('mgr', 'manager'); + expect(res.status).toBe(200); + const body = await res.json() as { data: { lockedCount: number; totalCents: number; splits: { lockedAtMs: number | null }[] } }; + expect(body.data.lockedCount).toBe(2); + expect(body.data.totalCents).toBe(30000); + expect(body.data.splits.every(s => s.lockedAtMs !== null)).toBe(true); + }); +}); From 836377464f2a97241bfd36dc4becc73ee5fd15ca Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 10:18:00 +0800 Subject: [PATCH 24/77] fix(qbo): require owner or manager on the QuickBooks router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentication is not authorization, and this router had only the first. It verified the JWT and ran the handler — no role, no capability — so any signed-in inspector could call POST /disconnect, which revokes the Intuit refresh token and deletes the tenant's whole qbo_entity_map. That table is the OI-invoice to QBO-invoice correspondence; reconnecting does not restore it, so the next push writes duplicate invoices against the same DocNumbers. Listing a Stripe webhook log, in the router next door, already required owner or manager. Neither authorization gate could have caught this. check-capability-declarations scans for createRoute(withMcpMetadata( windows and this file is a hand-rolled Hono router with none, so it passed vacuously; the authorization-surface spec is the same inversion against the live registry. Both ask whether declaration and enforcement agree — a route that declares nothing and mounts nothing looks correct to both. So the assertion is an explicit HTTP-level spec instead. Seen RED at "expected 200 to be 403": an inspector was getting 200 on disconnect. The owner/manager control passed before the fix too, which is why it is there — without it the four refusal cases would also pass against a router that refuses everyone. Applied router-wide rather than per route, since /status exposes the connected realm, company name and sync errors, and a uniform surface gives a future edit no per-route reasoning to get wrong. It also refuses a caller with no role at all, which is what an agent JWT is: it satisfies the verifier and carries no tenant by design. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- server/api/qbo.ts | 29 ++++ .../unit/qbo/qbo-route-authorization.spec.ts | 127 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 tests/unit/qbo/qbo-route-authorization.spec.ts diff --git a/server/api/qbo.ts b/server/api/qbo.ts index c82823a65..4a339aa5a 100644 --- a/server/api/qbo.ts +++ b/server/api/qbo.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import type { HonoConfig } from '../types/hono'; import { getCookie } from 'hono/cookie'; import { verifyJwt } from '../lib/jwt-keyring'; +import { requireRole } from '../lib/middleware/rbac'; import { QBOTokenResponseSchema, QBOCompanyInfoResponseSchema, QBOLinkCustomerBodySchema } from '../lib/validations/qbo.schema'; import { logger } from '../lib/logger'; @@ -23,6 +24,34 @@ api.use('*', async (c, next) => { } }); +/** + * Authentication above is NOT authorization, and this router needs both. + * + * Every route here administers a COMPANY-level finance integration. + * `/disconnect` in particular revokes the Intuit refresh token and deletes the + * tenant's whole `qbo_entity_map` — the OI-invoice -> QBO-invoice + * correspondence table — which reconnecting does not restore, so the next push + * writes duplicate invoices against the same DocNumbers. Until this line + * existed, any signed-in inspector could do that, while listing a Stripe + * webhook log next door (`integrations.ts`) already required owner/manager. + * + * Applied router-wide rather than per route: a uniform surface has no per-route + * reasoning for a future edit to get wrong, and the read (`/status`) exposes the + * connected realm, company name and sync errors, which is company books state. + * + * `requireRole` also refuses a caller with no role, which is what an agent + * (client/realtor) JWT is — it satisfies the verifier above and deliberately + * carries no tenant, so it must never reach a handler. + * + * Note for whoever makes `/connect` and `/callback` reachable from a browser: + * they are not today (`workers/app.ts` routes `/settings/*` to SSR, which has no + * matching route), and this guard must stay in place when they are — otherwise + * that change turns an in-app escalation into an internet-addressable one. + * Asserted at the HTTP boundary in `tests/unit/qbo/qbo-route-authorization.spec.ts`, + * because neither authorization gate can see a hand-rolled Hono router. + */ +api.use('*', requireRole('owner', 'manager')); + api.get('/status', async (c) => { const status = await c.var.services.qbo.getConnectionStatus(c.get('tenantId')); return c.json({ success: true, data: status }); diff --git a/tests/unit/qbo/qbo-route-authorization.spec.ts b/tests/unit/qbo/qbo-route-authorization.spec.ts new file mode 100644 index 000000000..288a53a3c --- /dev/null +++ b/tests/unit/qbo/qbo-route-authorization.spec.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoConfig } from '../../../server/types/hono'; +import type { UserRole } from '../../../server/types/auth'; +import { AppError } from '../../../server/lib/errors'; + +/** + * The QBO router is a COMPANY-integration admin surface, and every route on it + * must require owner/manager. + * + * Why this spec exists: the router shipped with authentication only — it + * verified the JWT and then ran the handler, with no role and no capability. + * `POST /disconnect` revokes the Intuit refresh token and DELETEs the tenant's + * entire `qbo_entity_map`, which is the OI-invoice -> QBO-invoice + * correspondence table. Reconnecting does not restore it, so the next push + * creates duplicate invoices in QuickBooks against the same DocNumbers. Any + * signed-in inspector could do that. + * + * Neither authorization gate could see the problem. `check-capability-declarations.mjs` + * scans for `createRoute(withMcpMetadata(` windows and this file is a hand-rolled + * `new Hono()`, so it has zero windows and passes vacuously; the + * authorization-surface spec is the same inversion against the live registry. + * Both answer "do declaration and enforcement agree?" — a route that declares + * nothing and mounts nothing looks correct to both. Hence an explicit HTTP-level + * assertion here rather than a gate entry. + * + * Asserted at the HTTP boundary on the REAL router, deliberately: a unit call to + * the middleware would not prove it is mounted, and a `createRoutesStub` test + * does not run middleware at all. + */ + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); + +// The auth layer is not what is under test — authorization is. Stubbing the +// verifier keeps this spec on the role boundary instead of rebuilding a keyring. +vi.mock('../../../server/lib/jwt-keyring', () => ({ + verifyJwt: vi.fn(async () => ({ sub: 'u1' })), +})); + +// eslint-disable-next-line import/order +import qboRoutes from '../../../server/api/qbo'; + +const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +const qboService = { + disconnect: vi.fn(async () => {}), + setSyncEnabled: vi.fn(async () => false), + getConnectionStatus: vi.fn(async () => ({ connected: true })), + runSync: vi.fn(async () => ({})), + resolveError: vi.fn(async () => {}), + linkExistingCustomer: vi.fn(async () => {}), +}; + +function buildApp(role: UserRole | undefined) { + const app = new Hono(); + + 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); + }); + + // Mirrors what the global middleware chain has already established by the + // time a request reaches this router. + app.use('*', async (c, next) => { + c.set('tenantId', TENANT_ID); + if (role) c.set('userRole', role); + c.set('keyringPromise', Promise.resolve({} as never)); + c.set('services', { qbo: qboService } as never); + return next(); + }); + + app.route('/settings/integrations/qbo', qboRoutes); + return app; +} + +function call(role: UserRole | undefined, path: string, method = 'POST') { + return buildApp(role).request(`/settings/integrations/qbo${path}`, { + method, + headers: { Cookie: '__Host-inspector_token=stub' }, + }); +} + +describe('QBO router authorization', () => { + beforeEach(() => { + Object.values(qboService).forEach(fn => fn.mockClear()); + }); + + it('refuses an inspector the destructive disconnect', async () => { + const res = await call('inspector', '/disconnect'); + expect(res.status).toBe(403); + // The refusal must happen BEFORE the service runs — a 403 returned + // after the entity map was already deleted would be worthless. + expect(qboService.disconnect).not.toHaveBeenCalled(); + }); + + it('refuses an inspector pause and force-sync', async () => { + expect((await call('inspector', '/pause')).status).toBe(403); + expect((await call('inspector', '/sync')).status).toBe(403); + expect(qboService.setSyncEnabled).not.toHaveBeenCalled(); + expect(qboService.runSync).not.toHaveBeenCalled(); + }); + + it('refuses an inspector the connection status read', async () => { + // Company books state — connected realm, company name, sync errors — is + // not inspector-visible just because the page is reachable. + expect((await call('inspector', '/status', 'GET')).status).toBe(403); + }); + + it('refuses a caller with no role at all', async () => { + // An agent (client/realtor) JWT satisfies the router's auth check and + // carries NO tenant and no staff role by design. It must not fall + // through to a handler that would then act on `tenantId: undefined`. + const res = await call(undefined, '/disconnect'); + expect(res.status).toBe(401); + expect(qboService.disconnect).not.toHaveBeenCalled(); + }); + + it('still admits owner and manager', async () => { + // The control. Without this, every assertion above would also pass + // against a router that refuses everyone. + expect((await call('owner', '/disconnect')).status).toBe(200); + expect((await call('manager', '/pause')).status).toBe(200); + expect(qboService.disconnect).toHaveBeenCalledTimes(1); + }); +}); From 19594957610711be66806b7533fa7f4c9e88321f Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 10:28:43 +0800 Subject: [PATCH 25/77] feat(metrics): pay, attributed revenue, and a turnaround that says its basis (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two thirds of this already shipped, so this is a rewrite of `byInspector` rather than an introduction. Every field of the old row is gone, and each for its own reason: count -> ledCount + assistedCount revenue -> payCents + attributedRevenueCents avgTurnaroundDays -> medianTurnaroundDays + turnaroundBasis The lead-only grouping the old query used was a deliberate anti-double-count — its own comment said so. Widening it to the whole roster is right for COUNTS (a helper was there) and wrong for the company's revenue, so the count widens and that figure does not follow. What follows instead is `attributedRevenueCents`, which is an attribution and is NOT additive down the column: two inspectors on one job are each credited with the work they were on. It is eligible-line scoped by the SAME rule pay splits use, so the two columns sitting side by side are comparable. Pay is not attributed revenue. Housecall Pro names the worker's money `Commission Cost`, which is safe there because only an owner opens that report. Our inspector opens this page, so it is Pay, and there is no column called "revenue" holding both. Median, not mean: one delayed report on a complex property drags a mean and misrepresents the person it is attached to. Turnaround anchors, both corrected: - END is `reports.published_at` — per deliverable, nullable — NOT `report_versions.published_at`, which is NOT NULL and fires per version AND per amendment. The old query also joined report_versions on version_number = 1 scoped by inspection_id alone, so an order delivering several reports scored an arbitrary one. - START is MAX(inspection_events.completed_at), which has no frontend writer yet. So the metric reports `turnaroundBasis: 'no_data'` and renders "no field-completion times have been recorded" rather than substituting a booking-confirmation clock, which measures how fast the office confirms bookings under this metric's name. Referrals: `referred_by_contact_id is not null` was silently dropping every row whose only attribution is free text — and for a one-person firm those are usually the only rows there are. They come back as a second bucket tagged `kind: 'source'`, listed after the contact-keyed rows and never merged into them; they are different kinds of answer. The route widens to `inspector` with scoping instead of a capability, matching the pay-split surface: without `financial` the caller gets their own row and the company figures as NULL, not zero. Zero is a claim about the business. Verified in Chrome, light and dark: the By Inspector table renders Led / Assisted / Pay / Attributed revenue / Median turnaround with the basis line under the title, legible in both themes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- app/routes/metrics.tsx | 104 ++++-- messages/en/metrics.json | 14 +- messages/es-419/metrics.json | 14 +- server/api/metrics.ts | 145 ++++---- server/lib/mcp/openapi-snapshot.json | 2 +- server/lib/validations/metrics.schema.ts | 54 ++- server/services/metrics/inspector-metrics.ts | 264 +++++++++++++ .../unit/metrics/metrics-by-inspector.spec.ts | 352 ++++++++++++++---- .../metrics/metrics-top-agents-people.spec.ts | 28 ++ 9 files changed, 784 insertions(+), 193 deletions(-) create mode 100644 server/services/metrics/inspector-metrics.ts diff --git a/app/routes/metrics.tsx b/app/routes/metrics.tsx index 59557c477..0b9c98f4c 100644 --- a/app/routes/metrics.tsx +++ b/app/routes/metrics.tsx @@ -16,13 +16,30 @@ export function meta() { interface MetricsData { totalInspections: number; - totalRevenue: number; - avgOrderValue: number; + // Null, not zero, for a reader without the `financial` capability: zero would + // be a claim about the business. `scope: 'self'` says why they are null. + totalRevenue: number | null; + avgOrderValue: number | null; + scope: "all" | "self"; // Field names mirror the server's response exactly (server/api/metrics.ts): // the monthly series is `monthly[]` with `{ month, count, revenue }`. monthly: { month: string; count: number; revenue: number }[]; - topAgents: { agentName: string; count: number; revenue: number }[]; - byInspector: { inspectorId: string | null; inspectorName: string; count: number; revenue: number; avgTurnaroundDays: number | null }[]; + topAgents: { agentName: string; kind: "contact" | "source"; count: number; revenue: number }[]; + // Two money columns with two labels. `payCents` is what the inspector earns; + // `attributedRevenueCents` is what the business billed for the lines they + // worked. Never merged into one column called "revenue" — they differ by + // margin and the difference is the business. Never called "cost": unlike the + // competitor's equivalent report, an inspector reads this page. + byInspector: { + inspectorId: string; + inspectorName: string; + ledCount: number; + assistedCount: number; + payCents: number; + attributedRevenueCents: number | null; + medianTurnaroundDays: number | null; + turnaroundBasis: "field_complete_to_report_published" | "no_data"; + }[]; // IA-82 — the endpoint has always computed and returned this; nothing rendered // it, so the aggregation ran for no reader. serviceBreakdown: { serviceName: string; count: number; revenue: number }[]; @@ -98,12 +115,20 @@ export default function MetricsPage() { navigate(`/metrics?from=${next.from}&to=${next.to}`, { replace: true }); }; + /** Null money is "not yours to see", and renders as a dash, never as $0. */ + const fmtOrDash = (n: number | null | undefined) => (n == null ? "—" : fmt(n)); + const kpis = [ - { label: m.metrics_kpi_revenue(), value: data ? fmt(data.totalRevenue) : "—" }, + { label: m.metrics_kpi_revenue(), value: fmtOrDash(data?.totalRevenue) }, { label: m.metrics_kpi_inspections(), value: data ? String(data.totalInspections) : "—" }, - { label: m.metrics_kpi_aov(), value: data ? fmt(data.avgOrderValue) : "—" }, + { label: m.metrics_kpi_aov(), value: fmtOrDash(data?.avgOrderValue) }, ]; + // A reader without the `financial` capability gets their own row and nothing + // else. Rendering the company cards as a wall of dashes would be worse than + // not rendering them: it advertises figures they cannot have. + const companyView = data?.scope !== "self"; + return (
{/* KPI cards */} -
- {kpis.map((kpi) => ( - -

{kpi.label}

-

{kpi.value}

-
- ))} -
+ {companyView ? ( +
+ {kpis.map((kpi) => ( + +

{kpi.label}

+

{kpi.value}

+
+ ))} +
+ ) : ( + +

{m.metrics_self_scope_notice()}

+
+ )} {/* Inspections per month chart placeholder */} + {companyView && (

{m.metrics_chart_inspections()}

{data && data.monthly?.length > 0 ? ( @@ -148,8 +180,10 @@ export default function MetricsPage() {

{m.metrics_no_data()}

)}
+ )} {/* Revenue per month bar chart */} + {companyView && (

{m.metrics_chart_revenue()}

{data && data.monthly?.length > 0 ? ( @@ -173,21 +207,32 @@ export default function MetricsPage() {

{m.metrics_no_revenue()}

)}
+ )} - {/* By inspector — team productivity: count, revenue, turnaround */} + {/* Per inspector. Two money columns, two labels: Pay is the worker's, + Attributed revenue is the company's. The turnaround basis is stated + under the title rather than hidden in a tooltip — a duration with an + unstated start is not a measurement. */} -

{m.metrics_by_inspector()}

+

{m.metrics_by_inspector()}

+

+ {data?.byInspector?.some((r) => r.turnaroundBasis !== "no_data") + ? m.metrics_turnaround_basis() + : m.metrics_turnaround_no_basis()} +

{data && data.byInspector?.length > 0 ? (
rows={data.byInspector} - getRowKey={(row) => row.inspectorId ?? row.inspectorName} + getRowKey={(row) => row.inspectorId} columns={[ { label: m.metrics_col_inspector(), cell: (row) => {row.inspectorName} }, - { label: m.metrics_col_inspections(), align: "center", cell: (row) => {row.count} }, - { label: m.metrics_col_revenue(), align: "right", cell: (row) => {fmt(row.revenue)} }, + { label: m.metrics_col_led(), align: "center", cell: (row) => {row.ledCount} }, + { label: m.metrics_col_assisted(), align: "center", cell: (row) => {row.assistedCount} }, + { label: m.metrics_col_pay(), align: "right", cell: (row) => {fmt(row.payCents)} }, + { label: m.metrics_col_attributed_revenue(), align: "right", cell: (row) => {fmtOrDash(row.attributedRevenueCents)} }, { label: m.metrics_col_turnaround(), align: "right", cell: (row) => ( - {row.avgTurnaroundDays == null ? m.metrics_turnaround_na() : m.metrics_turnaround_days({ days: row.avgTurnaroundDays })} + {row.medianTurnaroundDays == null ? m.metrics_turnaround_na() : m.metrics_turnaround_days({ days: row.medianTurnaroundDays })} ) }, ]} /> @@ -200,6 +245,7 @@ export default function MetricsPage() { {/* Service mix */} + {companyView && (

{m.metrics_services_title()}

{data && data.serviceBreakdown?.length > 0 ? ( @@ -218,15 +264,26 @@ export default function MetricsPage() {

{m.metrics_no_services()}

)}
+ )} - {/* Top agents */} + {/* Referrers. Contact-keyed rows come first and free-text sources follow, + tagged — they are different kinds of answer and merging them into one + list without saying which is which invents precision. */} + {companyView && (

{m.metrics_top_agents()}

{data && data.topAgents?.length > 0 ? (
- {data.topAgents.slice(0, 5).map((agent, i) => ( + {data.topAgents.slice(0, 8).map((agent, i) => (
- {agent.agentName} + + {agent.agentName} + {agent.kind === "source" && ( + + {m.metrics_referrer_source_tag()} + + )} +
{m.metrics_agent_count({ count: agent.count })} {fmt(agent.revenue)} @@ -238,6 +295,7 @@ export default function MetricsPage() {

{m.metrics_no_agents()}

)} + )}
); } diff --git a/messages/en/metrics.json b/messages/en/metrics.json index e9859bacb..d41895ec0 100644 --- a/messages/en/metrics.json +++ b/messages/en/metrics.json @@ -11,14 +11,14 @@ "metrics_no_data": "No data in this date range.", "metrics_chart_revenue": "Revenue per Month", "metrics_no_revenue": "No revenue in this date range.", - "metrics_top_agents": "Top Referring Agents", + "metrics_top_agents": "Top Referrers", "metrics_agent_count": "{count} insp", "metrics_no_agents": "No agent data yet.", "metrics_by_inspector": "By Inspector", "metrics_col_inspector": "Inspector", "metrics_col_inspections": "Inspections", "metrics_col_revenue": "Revenue", - "metrics_col_turnaround": "Avg turnaround", + "metrics_col_turnaround": "Median turnaround", "metrics_turnaround_days": "{days}d", "metrics_turnaround_na": "—", "metrics_no_inspectors": "No inspector data yet.", @@ -46,5 +46,13 @@ "metrics_range_from": "Start date", "metrics_range_to": "End date", "metrics_range_apply": "Apply range", - "metrics_range_aria": "Choose the date range these figures cover" + "metrics_range_aria": "Choose the date range these figures cover", + "metrics_col_led": "Led", + "metrics_col_assisted": "Assisted", + "metrics_col_pay": "Pay", + "metrics_col_attributed_revenue": "Attributed revenue", + "metrics_turnaround_basis": "Turnaround is measured from field completion to report publish, and is attributed to the lead inspector.", + "metrics_turnaround_no_basis": "No field-completion times have been recorded, so turnaround has no basis to measure from.", + "metrics_referrer_source_tag": "Source", + "metrics_self_scope_notice": "These are your own figures. Company totals are visible to owners and managers." } diff --git a/messages/es-419/metrics.json b/messages/es-419/metrics.json index aa4a0d987..d45a43d8c 100644 --- a/messages/es-419/metrics.json +++ b/messages/es-419/metrics.json @@ -11,14 +11,14 @@ "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_top_agents": "Principales referentes", "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_col_turnaround": "Tiempo de entrega (mediana)", "metrics_turnaround_days": "{days} d", "metrics_turnaround_na": "—", "metrics_no_inspectors": "Todavía no hay datos de inspectores.", @@ -46,5 +46,13 @@ "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" + "metrics_range_aria": "Elija el rango de fechas que cubren estas cifras", + "metrics_col_led": "Como líder", + "metrics_col_assisted": "Como apoyo", + "metrics_col_pay": "Pago", + "metrics_col_attributed_revenue": "Ingresos atribuidos", + "metrics_turnaround_basis": "El tiempo de entrega se mide desde que termina el trabajo en campo hasta que se publica el informe, y se atribuye al inspector líder.", + "metrics_turnaround_no_basis": "No se han registrado horas de finalización en campo, así que el tiempo de entrega no tiene punto de partida.", + "metrics_referrer_source_tag": "Origen", + "metrics_self_scope_notice": "Estas son tus propias cifras. Los totales de la empresa los ven los propietarios y gerentes." } diff --git a/server/api/metrics.ts b/server/api/metrics.ts index 9bf09dc13..c702daf67 100644 --- a/server/api/metrics.ts +++ b/server/api/metrics.ts @@ -1,8 +1,20 @@ +// GET /api/metrics — the reporting surface. +// +// The gate is `requireRole('owner', 'manager', 'inspector')` plus row scoping +// inside the handler, not a capability on the route: an inspector may see a +// single-row view of THEMSELVES (own pay, own counts, own turnaround) and none +// of the company's money. That is the same third state the pay-split routes +// implement — `financial: false` AND `subject = self` — and no boolean +// permission expresses it. A caller without `financial` gets the company +// aggregates as NULL rather than as zero: zero is a claim about the business, +// null says "not yours to see". import { createRoute } from '@hono/zod-openapi'; import { createApiRouter } from '../lib/openapi-router'; import { requireRole } from '../lib/middleware/rbac'; +import { capabilitiesFor } from '../lib/middleware/require-capability'; import { MetricsQuerySchema, MetricsApiResponseSchema } from '../lib/validations/metrics.schema'; -import { inspections, inspectionServices, contacts, users, reportVersions, inspectionInspectors } from '../lib/db/schema'; +import { inspections, inspectionServices, contacts } from '../lib/db/schema'; +import { perInspectorMetrics } from '../services/metrics/inspector-metrics'; import { eq, and, gte, lte, sql } from 'drizzle-orm'; import { withMcpMetadata } from "../lib/route-metadata-standards"; import { sumEffectivePriceCentsSql } from '../lib/effective-price.sql'; @@ -13,16 +25,18 @@ const metricsRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["metrics"], - middleware: [requireRole('owner', 'manager')] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: MetricsQuerySchema.describe('Inclusive civil-date window the figures cover.') }, responses: { 200: { content: { 'application/json': { schema: MetricsApiResponseSchema.describe('Revenue, volume, agent, inspector and service aggregates for the window.') } }, description: 'Metrics' } }, operationId: "listMetrics", summary: "List metrics for current tenant", - description: "Revenue and volume aggregates over an inclusive `from`..`to` civil-date window: monthly series, top referring agents, per-inspector productivity, service mix, and paid/unpaid split. Omitting both bounds returns the trailing three months." + description: "Revenue and volume aggregates over an inclusive `from`..`to` civil-date window: monthly series, referral sources, per-inspector pay and attributed revenue, service mix, and paid/unpaid split. A caller without the financial capability receives only their own inspector row and null company figures. Omitting both bounds returns the trailing three months." }, { scopes: ['read'], tier: 'extended' })), async (c) => { const tenantId = c.get('tenantId'); const { from, to } = resolveMetricsWindow(c.req.valid('query')); const db = getDrizzle(c); + const caps = await capabilitiesFor(c); + const self = c.get('user')?.sub ?? ''; // Both bounds are inclusive. `inspections.date` may hold a bare civil date // or a full ISO instant, so the upper bound carries a sentinel that sorts @@ -70,6 +84,37 @@ const metricsRoutes = createApiRouter() .then(rows => rows.map(r => ({ agentId: r.agentId ?? null, agentName: r.agentName || r.agentId || 'Unknown', + kind: 'contact' as const, + count: Number(r.count), + revenue: Number(r.revenue || 0), + }))); + + // The coarse bucket. `referred_by_contact_id` is the precise axis — a real + // contact row — and `referral_source` is free text ("Google", "repeat + // client"). They are different KINDS of answer, so they are not merged into + // one column; but the contact-keyed query filters `is not null`, which + // silently DROPPED every source-only row, and for a one-person firm those + // are usually the only rows there are. "Who sends me work" is the single + // number a solo tenant can act on next month. + const referralSources = await db.select({ + source: inspections.referralSource, + count: sql`count(*)`, + revenue: sumEffectivePriceCentsSql, + }) + .from(inspections) + .where(and( + eq(inspections.tenantId, tenantId), + inWindow, + sql`${inspections.referredByContactId} is null`, + sql`coalesce(trim(${inspections.referralSource}), '') <> ''`, + )) + .groupBy(inspections.referralSource) + .orderBy(sql`count(*) desc`) + .limit(10) + .then(rows => rows.map(r => ({ + agentId: null, + agentName: r.source ?? 'Unknown', + kind: 'source' as const, count: Number(r.count), revenue: Number(r.revenue || 0), }))); @@ -106,75 +151,47 @@ const metricsRoutes = createApiRouter() const paidAmt = Number(paymentSummary.find(r => r.status === 'paid')?.revenue ?? 0); const unpaidAmt = Number(paymentSummary.find(r => r.status === 'unpaid')?.revenue ?? 0); - // Per-inspector productivity (IA-63) — multi-inspector companies need count, - // revenue, and turnaround per inspector for team management + commission. - // "Who did this inspection" authority: the ROSTER's lead row. It used to be - // coalesce(lead_inspector_id, inspector_id), which agreed with everything - // else only because lead_inspector_id is NULL on every row — the first write - // of a lead would have made these numbers disagree with the rest of the app. - // - // Joined on role = 'lead' deliberately, NOT the whole roster: grouping over - // every link row would count an inspection once per assigned person and - // double its revenue the moment a job has a helper. One inspection is - // attributed to one person here; this change moves the SOURCE, not the - // meaning. Turnaround = - // first publish (report_versions v1 published_at) − inspection date, in days; - // the LEFT JOIN keeps unpublished inspections in the count while avg() skips - // their NULL turnaround (so an all-unpublished inspector reports null, not 0). - // Explicit column projection keeps well under D1's 100-column result cap. - const inspectorKey = inspectionInspectors.userId; - const byInspector = await db.select({ - inspectorId: inspectorKey, - inspectorName: users.name, - count: sql`count(*)`, - revenue: sumEffectivePriceCentsSql, - avgTurnaroundDays: sql`avg(julianday(${reportVersions.publishedAt} / 1000.0, 'unixepoch') - julianday(${inspections.date}))`, - }) - .from(inspections) - .leftJoin(reportVersions, and( - eq(reportVersions.inspectionId, inspections.id), - eq(reportVersions.tenantId, inspections.tenantId), - eq(reportVersions.versionNumber, 1), - )) - .leftJoin(inspectionInspectors, and( - eq(inspectionInspectors.inspectionId, inspections.id), - eq(inspectionInspectors.tenantId, inspections.tenantId), - eq(inspectionInspectors.role, 'lead'), - )) - .leftJoin(users, eq(users.id, inspectorKey)) - .where(and( - eq(inspections.tenantId, tenantId), - inWindow, - sql`${inspectorKey} is not null`, - )) - .groupBy(inspectorKey) - .orderBy(sql`count(*) desc`) - .limit(50) - .then(rows => rows.map(r => ({ - inspectorId: r.inspectorId ?? null, - inspectorName: r.inspectorName || r.inspectorId || 'Unknown', - count: Number(r.count), - revenue: Number(r.revenue || 0), - avgTurnaroundDays: r.avgTurnaroundDays == null ? null : Math.round(Number(r.avgTurnaroundDays) * 10) / 10, - }))); + // Per-inspector: counts split lead/helper, PAY and ATTRIBUTED REVENUE as two + // labelled figures, and a MEDIAN turnaround with its basis. See + // services/metrics/inspector-metrics.ts for why each of those is the shape + // it is — the reasoning is long and belongs next to the arithmetic. + const everyInspector = await perInspectorMetrics(db, tenantId, { from, to }); + + // The scoping rule, and the reason it is here rather than in a redactor: a + // caller without `financial` is not being shown a censored version of the + // company's numbers, they are being shown THEIR OWN row. A colleague's pay + // is absent from the payload, not hidden inside it. + const byInspector = caps.financial + ? everyInspector + : everyInspector + .filter(r => r.inspectorId === self) + // Attributed revenue is the company's side of the line. Pay is not. + .map(r => ({ ...r, attributedRevenueCents: null })); return c.json({ success: true, data: { from, to, - totalRevenue, + scope: caps.financial ? ('all' as const) : ('self' as const), + totalRevenue: caps.financial ? totalRevenue : null, totalInspections, - avgOrderValue, - monthly: monthly.map(r => ({ month: r.month, revenue: Number(r.revenue || 0), count: Number(r.count) })), - topAgents, + avgOrderValue: caps.financial ? avgOrderValue : null, + monthly: caps.financial + ? monthly.map(r => ({ month: r.month, revenue: Number(r.revenue || 0), count: Number(r.count) })) + : [], + // "Which agent sends the most work" is commercially sensitive in a + // multi-inspector firm — same gate as attributed revenue. + topAgents: caps.financial ? [...topAgents, ...referralSources] : [], byInspector, - serviceBreakdown: serviceBreakdown.map(r => ({ - serviceName: r.serviceName, - count: Number(r.count), - revenue: Number(r.revenue || 0), - })), - paymentSummary: { paid: paidAmt, unpaid: unpaidAmt, overdue: 0 }, + serviceBreakdown: caps.financial + ? serviceBreakdown.map(r => ({ + serviceName: r.serviceName, + count: Number(r.count), + revenue: Number(r.revenue || 0), + })) + : [], + paymentSummary: caps.financial ? { paid: paidAmt, unpaid: unpaidAmt, overdue: 0 } : null, }, }); }); diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 1e2e99e0d..7df071484 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -11090,7 +11090,7 @@ "body": null }, "summary": "List metrics for current tenant", - "description": "Revenue and volume aggregates over an inclusive `from`..`to` civil-date window: monthly series, top referring agents, per-inspector productivity, service mix, and paid/unpaid split. Omitting both bounds returns the trailing three months." + "description": "Revenue and volume aggregates over an inclusive `from`..`to` civil-date window: monthly series, referral sources, per-inspector pay and attributed revenue, service mix, and paid/unpaid split. A caller without the financial capability receives only their own inspector row and null company figures. Omitting both bounds returns the trailing three months." }, { "operationId": "listNotifications", diff --git a/server/lib/validations/metrics.schema.ts b/server/lib/validations/metrics.schema.ts index 4545df158..aaf8d9a3f 100644 --- a/server/lib/validations/metrics.schema.ts +++ b/server/lib/validations/metrics.schema.ts @@ -21,18 +21,28 @@ const MonthlyDataSchema = z.object({ }); const TopAgentSchema = z.object({ - agentId: z.string().nullable().describe('TODO describe agentId field for the OpenInspection MCP integration'), - agentName: z.string().describe('TODO describe agentName field for the OpenInspection MCP integration'), - count: z.number().describe('TODO describe count field for the OpenInspection MCP integration'), - revenue: z.number().describe('TODO describe revenue field for the OpenInspection MCP integration'), + agentId: z.string().nullable().describe('Contact id of the referrer; null for a free-text referral source.'), + agentName: z.string().describe('Referrer name, or the free-text source when there is no contact row.'), + // Two different KINDS of answer, deliberately not merged into one column: + // `contact` is a real contact row, `source` is free text such as "Google". + kind: z.enum(['contact', 'source']).describe('Whether this row is keyed on a contact or on a free-text referral source.'), + count: z.number().describe('Inspections referred in the period.'), + revenue: z.number().describe('Effective price of the inspections they referred, in cents.'), }); +/** + * Pay and attributed revenue are two figures with two labels, never one column + * called "revenue": they differ by margin and the difference is the business. + */ const ByInspectorSchema = z.object({ - inspectorId: z.string().nullable().describe('User id of the lead (or fallback) inspector for this row.'), - inspectorName: z.string().describe('Display name of the inspector, falling back to id then Unknown.'), - count: z.number().describe('Number of inspections attributed to this inspector in the period.'), - revenue: z.number().describe('Summed inspection price attributed to this inspector.'), - avgTurnaroundDays: z.number().nullable().describe('Average days from inspection date to first publish; null when none published.'), + inspectorId: z.string().describe('User id of the inspector this row is about.'), + inspectorName: z.string().describe('Display name of the inspector, falling back to their id.'), + ledCount: z.number().describe('Inspections this person led in the period.'), + assistedCount: z.number().describe('Inspections this person assisted on in the period.'), + payCents: z.number().describe('What this inspector earns: the sum of their recorded pay split rows, in cents.'), + attributedRevenueCents: z.number().nullable().describe('What the business billed for the lines they worked, in cents; null for a caller without the financial capability.'), + medianTurnaroundDays: z.number().nullable().describe('Median days from field completion to report publish, lead only; null when there is no basis.'), + turnaroundBasis: z.enum(['field_complete_to_report_published', 'no_data']).describe('Which clock the turnaround figure used, or no_data when none was available.'), }); const ServiceDistributionSchema = z.object({ @@ -45,18 +55,22 @@ const MetricsResponseSchema = z.object({ /** Echoed back so a caller can tell what window the numbers actually cover. */ from: z.string().describe('First day the figures cover (inclusive), YYYY-MM-DD.'), to: z.string().describe('Last day the figures cover (inclusive), YYYY-MM-DD.'), - totalRevenue: z.number().describe('TODO describe totalRevenue field for the OpenInspection MCP integration'), - totalInspections: z.number().describe('TODO describe totalInspections field for the OpenInspection MCP integration'), - avgOrderValue: z.number().describe('TODO describe avgOrderValue field for the OpenInspection MCP integration'), - monthly: z.array(MonthlyDataSchema).describe('TODO describe monthly field for the OpenInspection MCP integration'), - topAgents: z.array(TopAgentSchema).describe('TODO describe topAgents field for the OpenInspection MCP integration'), - byInspector: z.array(ByInspectorSchema).describe('Per-inspector productivity: count, revenue, and average turnaround days.'), - serviceBreakdown: z.array(ServiceDistributionSchema).describe('TODO describe serviceBreakdown field for the OpenInspection MCP integration'), + // `self` means the caller lacks the financial capability: byInspector holds + // only their own row and every company figure is null. Null rather than + // zero — zero would be a claim about the business. + scope: z.enum(['all', 'self']).describe('Whether the payload covers the company or only the caller.'), + totalRevenue: z.number().nullable().describe('Effective revenue over the window in cents; null without the financial capability.'), + totalInspections: z.number().describe('Inspections in the window.'), + avgOrderValue: z.number().nullable().describe('Mean effective price per inspection in cents; null without the financial capability.'), + monthly: z.array(MonthlyDataSchema).describe('Monthly revenue and volume series; empty without the financial capability.'), + topAgents: z.array(TopAgentSchema).describe('Referrers by volume — contact-keyed rows first, then free-text sources; empty without the financial capability.'), + byInspector: z.array(ByInspectorSchema).describe('Per-inspector counts, pay, attributed revenue and median turnaround.'), + serviceBreakdown: z.array(ServiceDistributionSchema).describe('Service mix by volume and revenue; empty without the financial capability.'), paymentSummary: z.object({ - paid: z.number().describe('TODO describe paid field for the OpenInspection MCP integration'), - unpaid: z.number().describe('TODO describe unpaid field for the OpenInspection MCP integration'), - overdue: z.number().describe('TODO describe overdue field for the OpenInspection MCP integration'), - }).describe('TODO describe paymentSummary field for the OpenInspection MCP integration'), + paid: z.number().describe('Effective revenue on inspections marked paid, in cents.'), + unpaid: z.number().describe('Effective revenue on inspections still unpaid, in cents.'), + overdue: z.number().describe('Effective revenue past due, in cents.'), + }).nullable().describe('Paid/unpaid split; null without the financial capability.'), }); export const MetricsApiResponseSchema = createApiResponseSchema(MetricsResponseSchema); diff --git a/server/services/metrics/inspector-metrics.ts b/server/services/metrics/inspector-metrics.ts new file mode 100644 index 000000000..536708588 --- /dev/null +++ b/server/services/metrics/inspector-metrics.ts @@ -0,0 +1,264 @@ +/** + * Per-inspector metrics (#278) — what each person did, earned, and generated. + * + * FOUR THINGS HERE ARE EASY TO GET WRONG, and each is wrong in a different + * direction: + * + * 1. COUNTS ARE SPLIT, MONEY IS NOT DOUBLE-COUNTED. The old query grouped on + * `role = 'lead'` alone, with a comment saying why: widening the grouping to + * the whole roster counts an inspection once per assigned person AND doubles + * its revenue the moment a job has a helper. So the count widens (a helper + * was there; hiding their work to keep one number clean is the wrong trade) + * and it widens into TWO fields — `ledCount` and `assistedCount` — while the + * company's revenue figure does not come along. What comes along instead is + * `attributedRevenueCents`, which is explicitly an attribution and is NOT + * additive across people: two inspectors on one job are each credited with + * the work they were on. Summing that column is a category error, which is + * why it is never labelled "revenue" on its own. + * + * 2. PAY IS NOT ATTRIBUTED REVENUE. `payCents` is the sum of that person's + * recorded split rows — money owed to them. `attributedRevenueCents` is what + * the business billed for the lines they worked. They differ by margin and + * the difference is the business. Housecall Pro calls the first one + * `Commission Cost`; we do not, because our inspector can see this figure. + * + * 3. MEDIAN, NOT MEAN. One delayed report on a complex property drags a mean + * and misrepresents the person it is attached to. + * + * 4. TURNAROUND HAS NO START TIMESTAMP IN PRACTICE, AND SAYS SO. The industry + * definition runs from when the FIELD WORK finished to when the report + * reached the client, so the start is `MAX(inspection_events.completed_at)` + * — MAX because an order with a radon pickup cannot produce its report until + * the last piece of fieldwork is done. That column has no frontend writer + * yet, so most tenants have no start at all, and the metric reports + * `turnaroundBasis: 'no_data'` rather than substituting a different clock. + * A turnaround computed from a booking-confirmation timestamp measures how + * fast the office confirms bookings, which is a different number wearing + * this one's name. + * + * The END anchor is `reports.published_at` — per deliverable and nullable — not + * `report_versions.published_at`, which is NOT NULL and fires per version AND + * per amendment, so an amended report would score a second, later turnaround. + * The previous query also joined `report_versions` on `version_number = 1` + * scoped only by `inspection_id`, which under an order with several reports + * picks an arbitrary one's v1. + */ +import { and, eq, gte, lte, isNotNull, sql } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { + inspections, inspectionServices, inspectionInspectors, inspectionServicePaySplits, + inspectionEvents, reports, users, serviceInspectors, +} from '../../lib/db/schema'; +import { inclusiveUpperBound } from '../../lib/metrics-window'; + +type TurnaroundBasis = 'field_complete_to_report_published' | 'no_data'; + +export interface InspectorMetricsRow { + inspectorId: string; + inspectorName: string; + /** Inspections where this person was the lead. */ + ledCount: number; + /** Inspections where this person assisted. Deliberately a separate figure. */ + assistedCount: number; + /** Sum of this person's recorded pay split rows — what they earn. */ + payCents: number; + /** Effective price of the lines they were assigned to — what the business billed. */ + attributedRevenueCents: number; + /** Median days from field completion to report publish; null when there is no basis. */ + medianTurnaroundDays: number | null; + turnaroundBasis: TurnaroundBasis; +} + +/** The maximum rows returned, matching the previous query's cap. */ +const MAX_ROWS = 50; + +function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** + * Who may be paid — and therefore credited — on a line. ZERO qualification rows + * for a service means everyone is qualified; rows restrict. This is the SAME + * rule `pay-split/core.ts#eligibleFor` applies, on purpose: if attributed + * revenue used a different eligibility rule from pay, the two columns sitting + * side by side in the UI would not be comparable, and the reader has no way to + * know that. + */ +function eligible(serviceId: string, roster: string[], quals: Map>): string[] { + const restricted = quals.get(serviceId); + if (!restricted || restricted.size === 0) return roster; + return roster.filter(u => restricted.has(u)); +} + +export async function perInspectorMetrics( + db: DrizzleD1Database, + tenantId: string, + window: { from: string; to: string }, +): Promise { + const inWindow = and( + eq(inspections.tenantId, tenantId), + gte(inspections.date, window.from), + lte(inspections.date, inclusiveUpperBound(window.to)), + ); + + const [roster, lines, quals, pay, published, fieldDone] = await Promise.all([ + db.select({ + inspectionId: inspectionInspectors.inspectionId, + userId: inspectionInspectors.userId, + role: inspectionInspectors.role, + name: users.name, + }) + .from(inspectionInspectors) + .innerJoin(inspections, and( + eq(inspections.id, inspectionInspectors.inspectionId), + eq(inspections.tenantId, inspectionInspectors.tenantId), + )) + .leftJoin(users, eq(users.id, inspectionInspectors.userId)) + .where(inWindow) + .all(), + + // Soft-deleted lines are history; a declined service bills nobody and + // credits nobody. + db.select({ + inspectionId: inspectionServices.inspectionId, + serviceId: inspectionServices.serviceId, + priceOverride: inspectionServices.priceOverride, + priceSnapshot: inspectionServices.priceSnapshot, + }) + .from(inspectionServices) + .innerJoin(inspections, and( + eq(inspections.id, inspectionServices.inspectionId), + eq(inspections.tenantId, inspectionServices.tenantId), + )) + .where(and(inWindow, eq(inspectionServices.active, true))) + .all(), + + db.select({ serviceId: serviceInspectors.serviceId, userId: serviceInspectors.userId }) + .from(serviceInspectors) + .where(eq(serviceInspectors.tenantId, tenantId)) + .all(), + + // Correction rows are included: they are part of what the person is + // owed, which is the whole reason they exist as rows rather than edits. + db.select({ + userId: inspectionServicePaySplits.userId, + total: sql`sum(${inspectionServicePaySplits.amountCents})`, + }) + .from(inspectionServicePaySplits) + .innerJoin(inspectionServices, and( + eq(inspectionServices.id, inspectionServicePaySplits.inspectionServiceId), + eq(inspectionServices.tenantId, inspectionServicePaySplits.tenantId), + )) + .innerJoin(inspections, and( + eq(inspections.id, inspectionServices.inspectionId), + eq(inspections.tenantId, inspectionServices.tenantId), + )) + .where(inWindow) + .groupBy(inspectionServicePaySplits.userId) + .all(), + + db.select({ inspectionId: reports.inspectionId, publishedAt: reports.publishedAt }) + .from(reports) + .innerJoin(inspections, and( + eq(inspections.id, reports.inspectionId), + eq(inspections.tenantId, reports.tenantId), + )) + .where(and(inWindow, isNotNull(reports.publishedAt))) + .all(), + + // MAX, not the primary visit: the report cannot ship until the LAST + // piece of fieldwork on the order is done. + db.select({ + inspectionId: inspectionEvents.inspectionId, + lastDoneMs: sql`max(${inspectionEvents.completedAt})`, + }) + .from(inspectionEvents) + .innerJoin(inspections, and( + eq(inspections.id, inspectionEvents.inspectionId), + eq(inspections.tenantId, inspectionEvents.tenantId), + )) + .where(inWindow) + .groupBy(inspectionEvents.inspectionId) + .all(), + ]); + + const qualMap = new Map>(); + for (const q of quals) { + const set = qualMap.get(q.serviceId) ?? new Set(); + set.add(q.userId); + qualMap.set(q.serviceId, set); + } + + const rosterByInspection = new Map(); + const names = new Map(); + const led = new Map(); + const assisted = new Map(); + for (const r of roster) { + const list = rosterByInspection.get(r.inspectionId) ?? []; + list.push({ userId: r.userId, role: r.role }); + rosterByInspection.set(r.inspectionId, list); + names.set(r.userId, r.name || r.userId); + const bucket = r.role === 'lead' ? led : assisted; + bucket.set(r.userId, (bucket.get(r.userId) ?? 0) + 1); + } + + const attributed = new Map(); + const linesByInspection = new Map(); + for (const l of lines) { + const list = linesByInspection.get(l.inspectionId) ?? []; + list.push(l); + linesByInspection.set(l.inspectionId, list); + } + for (const [inspectionId, list] of linesByInspection) { + const crew = (rosterByInspection.get(inspectionId) ?? []).map(m => m.userId); + if (crew.length === 0) continue; + for (const line of list) { + const price = line.priceOverride ?? line.priceSnapshot; + for (const userId of eligible(line.serviceId, crew, qualMap)) { + attributed.set(userId, (attributed.get(userId) ?? 0) + price); + } + } + } + + const doneMs = new Map(); + for (const f of fieldDone) { + if (f.lastDoneMs != null) doneMs.set(f.inspectionId, Number(f.lastDoneMs)); + } + + // Turnaround is attributed to the LEAD only. The report is one artifact with + // one publisher; dividing a duration between two people means nothing. + const samples = new Map(); + for (const p of published) { + const start = doneMs.get(p.inspectionId); + if (start === undefined || p.publishedAt == null) continue; + const lead = (rosterByInspection.get(p.inspectionId) ?? []).find(m => m.role === 'lead'); + if (!lead) continue; + const days = (Number(p.publishedAt) - start) / 86_400_000; + const list = samples.get(lead.userId) ?? []; + list.push(days); + samples.set(lead.userId, list); + } + + const payByUser = new Map(pay.map(p => [p.userId, Number(p.total || 0)])); + + const out: InspectorMetricsRow[] = [...names.keys()].map((userId) => { + const med = median(samples.get(userId) ?? []); + return { + inspectorId: userId, + inspectorName: names.get(userId) ?? userId, + ledCount: led.get(userId) ?? 0, + assistedCount: assisted.get(userId) ?? 0, + payCents: payByUser.get(userId) ?? 0, + attributedRevenueCents: attributed.get(userId) ?? 0, + medianTurnaroundDays: med === null ? null : Math.round(med * 10) / 10, + turnaroundBasis: med === null ? 'no_data' : 'field_complete_to_report_published', + }; + }); + + out.sort((a, b) => (b.ledCount + b.assistedCount) - (a.ledCount + a.assistedCount) + || a.inspectorName.localeCompare(b.inspectorName)); + return out.slice(0, MAX_ROWS); +} diff --git a/tests/unit/metrics/metrics-by-inspector.spec.ts b/tests/unit/metrics/metrics-by-inspector.spec.ts index 7ec6c9df5..054506b53 100644 --- a/tests/unit/metrics/metrics-by-inspector.spec.ts +++ b/tests/unit/metrics/metrics-by-inspector.spec.ts @@ -1,10 +1,24 @@ /** - * IA-63 — GET /api/metrics must expose a per-inspector productivity dimension - * (count / revenue / average turnaround) so multi-inspector companies can see - * team output, not just workspace totals. "Who did this inspection" resolves - * from lead_inspector_id, falling back to inspector_id. Turnaround is the days - * from inspection date to the first report_versions publish; an inspector with - * nothing published reports null (not 0). + * GET /api/metrics — the per-inspector dimension (#278). + * + * REWRITTEN from the IA-63 version, which pinned `{ count, revenue, + * avgTurnaroundDays }` grouped lead-only. Every one of those three fields is + * gone, and each for a reason worth keeping: + * + * - `count` became `ledCount` + `assistedCount`. A helper was on the job; + * hiding their work to keep one number clean is the wrong trade. The old + * lead-only grouping existed to stop revenue being double-counted, so the + * count widens and the COMPANY revenue figure does not come with it. + * - `revenue` became TWO figures: `payCents` (what the inspector earns) and + * `attributedRevenueCents` (what the business billed for the lines they + * worked). One column called "revenue" conflated them. + * - `avgTurnaroundDays` became `medianTurnaroundDays` + `turnaroundBasis`. + * A mean is dragged by one delayed report on a complex property, and a + * metric that silently reports nothing is worse than one that says why. + * + * The turnaround anchors moved too: END is `reports.published_at` (per + * deliverable, nullable), not `report_versions.published_at` (NOT NULL, fires + * per version and per amendment); START is `MAX(inspection_events.completed_at)`. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as schema from '../../../server/lib/db/schema'; @@ -16,18 +30,24 @@ import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; import { OpenAPIHono } from '@hono/zod-openapi'; import metricsRoutes from '../../../server/api/metrics'; import type { HonoConfig } from '../../../server/types/hono'; +import type { UserRole } from '../../../server/types/auth'; const TENANT = '00000000-0000-0000-0000-000000000001'; const U1 = 'user-inspector-1'; const U2 = 'user-inspector-2'; +const SVC = 'svc-home'; +const EVT = 'evt-type-1'; +const DAY = 86_400_000; let db: BetterSQLite3Database; -function buildApp() { +function buildApp(role: UserRole = 'owner', actor = 'owner-1', overrides: Record | null = null) { const app = new OpenAPIHono(); app.use('*', async (c, next) => { - c.set('userRole', 'owner' as never); + c.set('userRole', role); c.set('tenantId', TENANT); + 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/metrics', metricsRoutes); @@ -37,84 +57,258 @@ function buildApp() { const ENV = { DB: {} } as never; const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; -type ByInspectorRow = { inspectorId: string | null; inspectorName: string; count: number; revenue: number; avgTurnaroundDays: number | null }; +interface ByInspectorRow { + inspectorId: string; + inspectorName: string; + ledCount: number; + assistedCount: number; + payCents: number; + attributedRevenueCents: number | null; + medianTurnaroundDays: number | null; + turnaroundBasis: 'field_complete_to_report_published' | 'no_data'; +} -describe('GET /api/metrics — byInspector (IA-63)', () => { - 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: U1, tenantId: TENANT, email: 'ins1@acme.test', passwordHash: 'x', name: 'Alice Inspector', createdAt: new Date() }, - { id: U2, tenantId: TENANT, email: 'ins2@acme.test', passwordHash: 'x', name: 'Bob Inspector', createdAt: new Date() }, - ] as never); +interface Payload { + scope: 'all' | 'self'; + totalRevenue: number | null; + avgOrderValue: number | null; + monthly: unknown[]; + topAgents: unknown[]; + serviceBreakdown: unknown[]; + paymentSummary: unknown; + byInspector: ByInspectorRow[]; +} + +const fetchMetrics = async (app = buildApp()) => { + const res = await app.request('/api/metrics?from=2026-01-01&to=2026-12-31', {}, ENV, CTX); + expect(res.status).toBe(200); + return ((await res.json()) as { data: Payload }).data; +}; + +const rowFor = (rows: ByInspectorRow[], id: string) => rows.find(r => r.inspectorId === id)!; + +/** One inspection with one billing line, dated inside the window. */ +async function seedInspection(id: string, priceCents: number, date = '2026-07-01') { + await db.insert(schema.inspections).values({ + id, tenantId: TENANT, propertyAddress: `${id} Main St`, date, + status: 'completed', paymentStatus: 'paid', price: priceCents, createdAt: new Date(), + } as never); + await db.insert(schema.inspectionServices).values({ + id: `line-${id}`, tenantId: TENANT, inspectionId: id, serviceId: SVC, + nameSnapshot: 'Home Inspection', priceSnapshot: priceCents, + } as never); +} + +const assign = (inspectionId: string, userId: string, role: 'lead' | 'helper') => + db.insert(schema.inspectionInspectors).values({ + inspectionId, userId, tenantId: TENANT, role, createdAt: new Date(), + } as never); + +/** Field work finished — the turnaround START anchor. */ +const fieldDone = (inspectionId: string, atMs: number) => + db.insert(schema.inspectionEvents).values({ + id: `ev-${inspectionId}-${atMs}`, tenantId: TENANT, inspectionId, eventTypeId: EVT, + scheduledAt: new Date(atMs - DAY), durationMin: 120, status: 'completed', + completedAt: new Date(atMs), createdAt: new Date(), + } as never); + +/** A deliverable going out — the turnaround END anchor. */ +const publishReport = (id: string, inspectionId: string, atMs: number, kind: 'primary' | 'ancillary' = 'primary') => + db.insert(schema.reports).values({ + id, tenantId: TENANT, inspectionId, kind, title: 'Report', + status: 'published', createdAt: new Date(atMs - DAY), publishedAt: new Date(atMs), + } as never); + +const payRow = (inspectionId: string, userId: string, amountCents: number) => + db.insert(schema.inspectionServicePaySplits).values({ + id: `split-${inspectionId}-${userId}`, tenantId: TENANT, + inspectionServiceId: `line-${inspectionId}`, userId, amountCents, + source: 'rule', lockedAt: null, correctsSplitId: null, reason: null, + createdAt: new Date(), updatedAt: new Date(), + } as never); + +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: U1, tenantId: TENANT, email: 'ins1@acme.test', passwordHash: 'x', name: 'Alice Inspector', createdAt: new Date() }, + { id: U2, tenantId: TENANT, email: 'ins2@acme.test', passwordHash: 'x', name: 'Bob Inspector', createdAt: new Date() }, + ] as never); + await db.insert(schema.services).values({ + id: SVC, tenantId: TENANT, name: 'Home Inspection', price: 50000, createdAt: new Date(), + } as never); + await db.insert(schema.eventTypes).values({ + id: EVT, tenantId: TENANT, name: 'On-site inspection', slug: 'on-site', createdAt: new Date(), + } as never); +}); - it('groups by lead/fallback inspector with correct per-inspector count and revenue', async () => { - const today = new Date().toISOString().slice(0, 10); - await db.insert(schema.inspections).values([ - // Alice — attributed via inspector_id (lead NULL ⇒ fallback). - { id: 'i-a1', tenantId: TENANT, propertyAddress: '1 Main', date: today, status: 'completed', paymentStatus: 'paid', price: 10000, inspectorId: U1, createdAt: new Date() }, - { id: 'i-a2', tenantId: TENANT, propertyAddress: '2 Main', date: today, status: 'completed', paymentStatus: 'paid', price: 15000, inspectorId: U1, createdAt: new Date() }, - // Bob — attributed via lead_inspector_id, which must win over inspector_id. - { id: 'i-b1', tenantId: TENANT, propertyAddress: '3 Oak', date: today, status: 'completed', paymentStatus: 'paid', price: 20000, inspectorId: U1, leadInspectorId: U2, createdAt: new Date() }, - { id: 'i-b2', tenantId: TENANT, propertyAddress: '4 Oak', date: today, status: 'completed', paymentStatus: 'paid', price: 5000, inspectorId: U1, leadInspectorId: U2, createdAt: new Date() }, - ]); - // Attribution now comes from the roster's lead row, not from - // coalesce(lead_inspector_id, inspector_id). Same intent as the columns - // above: a1/a2 are Alice's, b1/b2 are Bob's even though Alice is the - // inspector_id on them. - await db.insert(schema.inspectionInspectors).values([ - { inspectionId: 'i-a1', userId: U1, tenantId: TENANT, role: 'lead', createdAt: new Date() }, - { inspectionId: 'i-a2', userId: U1, tenantId: TENANT, role: 'lead', createdAt: new Date() }, - { inspectionId: 'i-b1', userId: U2, tenantId: TENANT, role: 'lead', createdAt: new Date() }, - { inspectionId: 'i-b2', userId: U2, tenantId: TENANT, role: 'lead', createdAt: new Date() }, - ] as never); - - const res = await buildApp().request('/api/metrics?from=2024-01-01&to=2028-12-31', {}, ENV, CTX); - expect(res.status).toBe(200); - const rows = ((await res.json()) as { data: { byInspector: ByInspectorRow[] } }).data.byInspector; - expect(rows).toHaveLength(2); - - const alice = rows.find((r) => r.inspectorId === U1)!; - const bob = rows.find((r) => r.inspectorId === U2)!; - expect(alice.inspectorName).toBe('Alice Inspector'); - expect(alice.count).toBe(2); - expect(alice.revenue).toBe(25000); - // lead_inspector_id wins: both i-b* count for Bob, not Alice. - expect(bob.inspectorName).toBe('Bob Inspector'); - expect(bob.count).toBe(2); - expect(bob.revenue).toBe(25000); +describe('byInspector — counts', () => { + it('counts an inspection for every assigned inspector, lead and helper apart', async () => { + await seedInspection('i1', 50000); + await assign('i1', U1, 'lead'); + await assign('i1', U2, 'helper'); + + const rows = (await fetchMetrics()).byInspector; + expect(rowFor(rows, U1)).toMatchObject({ ledCount: 1, assistedCount: 0 }); + expect(rowFor(rows, U2)).toMatchObject({ ledCount: 0, assistedCount: 1 }); + }); +}); + +describe('byInspector — pay is not attributed revenue', () => { + it('reports pay and attributed revenue as separate figures', async () => { + // Conflating them is the error the two labels exist to prevent: pay is + // what the inspector earns, attributed revenue is what the business + // billed for work they did. They differ by margin. + await seedInspection('i1', 50000); + await assign('i1', U1, 'lead'); + await payRow('i1', U1, 15000); + + const row = rowFor((await fetchMetrics()).byInspector, U1); + expect(row.payCents).toBe(15000); + expect(row.attributedRevenueCents).toBe(50000); + expect(row.payCents).not.toEqual(row.attributedRevenueCents); + }); + + it('does NOT carry company revenue into the widened count', async () => { + // The old lead-only grouping existed to stop exactly this. Two people + // on one 50000c job: the company earned 50000 once, and the attributed + // figure credits each of them with the line they worked — which is why + // it is called attribution and is never summed across the column. + await seedInspection('i1', 50000); + await assign('i1', U1, 'lead'); + await assign('i1', U2, 'helper'); + + const data = await fetchMetrics(); + expect(data.totalRevenue).toBe(50000); + expect(rowFor(data.byInspector, U1).attributedRevenueCents).toBe(50000); + expect(rowFor(data.byInspector, U2).attributedRevenueCents).toBe(50000); + }); +}); + +describe('byInspector — turnaround', () => { + it('reports no_data, not a substitute basis, when field work was never completed', async () => { + // `inspection_events.completed_at` has no frontend writer yet, so this + // is the case nearly every tenant is in. A turnaround computed off a + // booking-confirmation timestamp measures how fast the office confirms + // bookings — a different number wearing this one's name. + await seedInspection('i1', 50000); + await assign('i1', U1, 'lead'); + await publishReport('r1', 'i1', Date.UTC(2026, 6, 4)); + + const row = rowFor((await fetchMetrics()).byInspector, U1); + expect(row.medianTurnaroundDays).toBeNull(); + expect(row.turnaroundBasis).toBe('no_data'); + }); + + it('measures field completion to report publish, and attributes it to the lead only', async () => { + await seedInspection('i1', 50000); + // Helper assigned FIRST on purpose: "first row on the roster" and "the + // lead" must not be allowed to coincide, or this test passes for an + // implementation that just takes roster[0]. + await assign('i1', U2, 'helper'); + await assign('i1', U1, 'lead'); + await fieldDone('i1', Date.UTC(2026, 6, 1)); + await publishReport('r1', 'i1', Date.UTC(2026, 6, 3)); + + const rows = (await fetchMetrics()).byInspector; + expect(rowFor(rows, U1).medianTurnaroundDays).toBe(2); + expect(rowFor(rows, U1).turnaroundBasis).toBe('field_complete_to_report_published'); + // The report is one artifact with one publisher; splitting a duration + // between two people means nothing. + expect(rowFor(rows, U2).medianTurnaroundDays).toBeNull(); + }); + + it('takes the MEDIAN, so one delayed report does not restate the other three', async () => { + const days = [1, 2, 3, 40]; + for (const [i, d] of days.entries()) { + const id = `i${i}`; + await seedInspection(id, 10000); + await assign(id, U1, 'lead'); + await fieldDone(id, Date.UTC(2026, 6, 1)); + await publishReport(`r${i}`, id, Date.UTC(2026, 6, 1) + d * DAY); + } + // mean = 11.5; median = 2.5. The mean describes none of these jobs. + expect(rowFor((await fetchMetrics()).byInspector, U1).medianTurnaroundDays).toBe(2.5); + }); + + it('uses the RIGHT report under a multi-report order', async () => { + // The inherited bug: the old query joined report_versions on + // version_number = 1 scoped by inspection_id alone, so an order + // delivering several reports picked an arbitrary one's v1. Both + // deliverables count here, and each against its own publish time. + await seedInspection('i1', 50000); + await assign('i1', U1, 'lead'); + await fieldDone('i1', Date.UTC(2026, 6, 1)); + await publishReport('r-primary', 'i1', Date.UTC(2026, 6, 2), 'primary'); + await publishReport('r-radon', 'i1', Date.UTC(2026, 6, 8), 'ancillary'); + + // Samples are 1 and 7 days; the median of the pair is 4. + expect(rowFor((await fetchMetrics()).byInspector, U1).medianTurnaroundDays).toBe(4); }); - it('reports turnaround from first publish, and null (not 0) for an inspector with nothing published', async () => { - const date = '2026-07-01'; - await db.insert(schema.inspections).values([ - { id: 'pub', tenantId: TENANT, propertyAddress: '1 Main', date, status: 'delivered', paymentStatus: 'paid', price: 10000, inspectorId: U1, createdAt: new Date() }, - { id: 'unpub', tenantId: TENANT, propertyAddress: '2 Oak', date, status: 'completed', paymentStatus: 'unpaid', price: 10000, inspectorId: U2, createdAt: new Date() }, - ]); - await db.insert(schema.inspectionInspectors).values([ - { inspectionId: 'pub', userId: U1, tenantId: TENANT, role: 'lead', createdAt: new Date() }, - { inspectionId: 'unpub', userId: U2, tenantId: TENANT, role: 'lead', createdAt: new Date() }, - ] as never); - // Alice's inspection published 3 days after the inspection date. - await db.insert(schema.reportVersions).values({ - id: 'rv1', tenantId: TENANT, inspectionId: 'pub', versionNumber: 1, - snapshotJson: '{}', publishedAt: new Date('2026-07-04T00:00:00Z'), publishedBy: U1, + it('ignores an unpublished report rather than scoring it as zero', async () => { + await seedInspection('i1', 50000); + await assign('i1', U1, 'lead'); + await fieldDone('i1', Date.UTC(2026, 6, 1)); + await db.insert(schema.reports).values({ + id: 'r-draft', tenantId: TENANT, inspectionId: 'i1', kind: 'primary', + title: 'Draft', status: 'in_progress', createdAt: new Date(), publishedAt: null, } as never); - const res = await buildApp().request('/api/metrics?from=2024-01-01&to=2028-12-31', {}, ENV, CTX); - const rows = ((await res.json()) as { data: { byInspector: ByInspectorRow[] } }).data.byInspector; + const row = rowFor((await fetchMetrics()).byInspector, U1); + expect(row.medianTurnaroundDays).toBeNull(); + expect(row.turnaroundBasis).toBe('no_data'); + }); +}); + +describe('byInspector — an inspector sees a single-row view of themselves', () => { + beforeEach(async () => { + await seedInspection('i1', 50000); + await assign('i1', U1, 'lead'); + await assign('i1', U2, 'helper'); + await payRow('i1', U1, 15000); + await payRow('i1', U2, 15000); + }); + + it('returns only the caller row, and never a colleague name or amount', async () => { + const res = await buildApp('inspector', U1).request('/api/metrics?from=2026-01-01&to=2026-12-31', {}, ENV, CTX); + expect(res.status).toBe(200); + const raw = await res.text(); + const data = (JSON.parse(raw) as { data: Payload }).data; + + expect(data.scope).toBe('self'); + expect(data.byInspector.map(r => r.inspectorId)).toEqual([U1]); + expect(data.byInspector[0].payCents).toBe(15000); + // A colleague's identity is absent from the payload, not hidden in it. + expect(raw).not.toContain('Bob Inspector'); + expect(raw).not.toContain(U2); + }); + + it('withholds the company figures as null, not as zero', async () => { + // Zero is a claim about the business; null says "not yours to see". + const data = await fetchMetrics(buildApp('inspector', U1)); + expect(data.totalRevenue).toBeNull(); + expect(data.avgOrderValue).toBeNull(); + expect(data.paymentSummary).toBeNull(); + expect(data.monthly).toEqual([]); + expect(data.topAgents).toEqual([]); + expect(data.serviceBreakdown).toEqual([]); + // Attributed revenue is the company's side of the line; pay is not. + expect(data.byInspector[0].attributedRevenueCents).toBeNull(); + expect(data.byInspector[0].payCents).toBe(15000); + }); - const alice = rows.find((r) => r.inspectorId === U1)!; - const bob = rows.find((r) => r.inspectorId === U2)!; - expect(alice.avgTurnaroundDays).toBeCloseTo(3, 1); - expect(bob.avgTurnaroundDays).toBeNull(); + it('an inspector GRANTED financial sees the whole company — the line is the capability', async () => { + const data = await fetchMetrics(buildApp('inspector', U1, { financial: true })); + expect(data.scope).toBe('all'); + expect(data.byInspector.map(r => r.inspectorId).sort()).toEqual([U1, U2]); + expect(data.totalRevenue).toBe(50000); }); }); diff --git a/tests/unit/metrics/metrics-top-agents-people.spec.ts b/tests/unit/metrics/metrics-top-agents-people.spec.ts index fb366c2ec..86b4d4e56 100644 --- a/tests/unit/metrics/metrics-top-agents-people.spec.ts +++ b/tests/unit/metrics/metrics-top-agents-people.spec.ts @@ -75,6 +75,34 @@ describe('GET /api/metrics — topAgents via inspection_people (Task 9c)', () => expect(body.data.topAgents[0].revenue).toBe(30000); }); + it('buckets referral_source rows the contact-keyed query drops (#278)', async () => { + // The contact-keyed query filters `referred_by_contact_id is not null`, + // so a job whose only attribution is free text ("Google") was dropped + // ENTIRELY — and for a one-person firm those are usually the only rows + // there are. The two are different KINDS of answer and stay in separate + // rows keyed by `kind`, never merged into one column. + const today = new Date().toISOString().slice(0, 10); + await db.insert(schema.inspections).values([ + { id: INSP_1, tenantId: TENANT, propertyAddress: '1 Main', date: today, status: 'confirmed', paymentStatus: 'paid', price: 10000, referredByContactId: AGENT_CONTACT, inspectorId: null, createdAt: new Date() }, + { id: INSP_2, tenantId: TENANT, propertyAddress: '2 Oak', date: today, status: 'confirmed', paymentStatus: 'paid', price: 20000, referredByContactId: null, referralSource: 'Google', inspectorId: null, createdAt: new Date() }, + { id: 'insp-3', tenantId: TENANT, propertyAddress: '3 Elm', date: today, status: 'confirmed', paymentStatus: 'paid', price: 5000, referredByContactId: null, referralSource: ' ', inspectorId: null, createdAt: new Date() }, + ] as never); + + const res = await buildApp().request('/api/metrics?from=2024-01-01&to=2028-12-31', {}, ENV, CTX); + const body = await res.json() as { data: { topAgents: { agentId: string | null; agentName: string; kind: string; count: number; revenue: number }[] } }; + + // Contact-keyed first, then the coarse bucket. + expect(body.data.topAgents.map(r => [r.kind, r.agentName])).toEqual([ + ['contact', 'Jane'], + ['source', 'Google'], + ]); + const source = body.data.topAgents[1]; + expect(source.agentId).toBeNull(); + expect(source.count).toBe(1); + expect(source.revenue).toBe(20000); + // A whitespace-only source is not an answer and gets no row. + }); + it('inspection with no referrer is excluded from topAgents', async () => { const today = new Date().toISOString().slice(0, 10); await db.insert(schema.inspections).values({ From ea9b9eb18127c30e954295455be1e474fcfacd82 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 11:00:55 +0800 Subject: [PATCH 26/77] fix(qbo): make the OAuth pair reachable, and authorize the callback by state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent defects, either of which alone kept the QuickBooks connect flow from ever running. Neither is visible from the settings page, which is why the integration has never completed a handshake. First, unreachable. workers/app.ts forwards an explicit prefix allow-list to the Hono app; /settings/** is not on it, so the connect and callback routes went to React Router, which has no such route. The webhook next door lives under /api/* and works — that asymmetry was the tell. /connect and /callback move to /api/integrations/qbo, beside the webhook. The siblings (/status, /pause, /sync, /errors/:id/retry, /contacts/:contactId/link) stay where they are: the settings page reaches those through the in-process API_WORKER binding, and they work today. Second, the session cookie cannot arrive. __Host-inspector_token is SameSite=Strict, and Intuit returns the user by a cross-site top-level navigation — the exact case Strict withholds a cookie on, as the portal cookie right below it in auth-helpers.ts already documents. Making the route reachable alone would have turned a 404 into a 401. So /callback is unauthenticated and authorized by state instead: the value stored under qbo_oauth_state:${state} is now the tenantId rather than the placeholder, and the callback resolves the tenant from it. The state is a server-generated UUID, single-use, 600s TTL, and only ever issued to a caller who has just passed the owner/manager guard on /connect — which /connect keeps. Declared public in jwt-auth.ts alongside the QBO webhook, for the same reason: neither caller can hold a session. Also found while tracing, not in the brief: the client id and secret can be a per-tenant secret, and integrationSecretsMiddleware only merges those once a tenant is known — which on this request it is not, in saas mode. The callback loads them for the tenant its state names, using the same helper and the same precedence rule. redirectUri was built from the same literal in two places. Both now call qboRedirectUri(), and the mount plus the jwt-auth entry come from the same constants, so the path cannot move without the URI moving with it. Intuit compares that string byte-for-byte against the registered value. The load-bearing test sends what Intuit sends — a bare GET with no Cookie header — and asserts it completes against the right tenant. Proven red first: "expected 401 to be 302". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- app/routes/settings-integrations-qbo.tsx | 7 +- scripts/file-size-baseline.json | 4 +- server/api/qbo-oauth.ts | 164 ++++++++++++++++ server/api/qbo.ts | 101 ++-------- server/index.ts | 8 + server/lib/middleware/jwt-auth.ts | 9 +- server/lib/qbo-oauth-paths.ts | 22 +++ tests/unit/qbo/qbo-oauth-callback.spec.ts | 218 ++++++++++++++++++++++ 8 files changed, 438 insertions(+), 95 deletions(-) create mode 100644 server/api/qbo-oauth.ts create mode 100644 server/lib/qbo-oauth-paths.ts create mode 100644 tests/unit/qbo/qbo-oauth-callback.spec.ts diff --git a/app/routes/settings-integrations-qbo.tsx b/app/routes/settings-integrations-qbo.tsx index 319889b53..cbd923026 100644 --- a/app/routes/settings-integrations-qbo.tsx +++ b/app/routes/settings-integrations-qbo.tsx @@ -257,7 +257,10 @@ export default function SettingsIntegrationsQbo() { {m.settings_qbo_expiry_warning()}{" "} - + {/* /api/*, not a child of this page's path: only API-prefixed paths + reach the Hono app (workers/app.ts allow-list). A link under + /settings/** lands on React Router, which has no such route. */} + {m.settings_qbo_reconnect_link()} @@ -288,7 +291,7 @@ export default function SettingsIntegrationsQbo() { {m.settings_qbo_connect_button()} diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 3882abe48..8d2485b9c 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -15,7 +15,7 @@ "server/api/inspections/report-delivery.ts": 736, "server/services/inspection/inspection-analytics.service.ts": 729, "app/routes/template-edit.tsx": 719, - "server/index.ts": 704, + "server/index.ts": 712, "app/components/media-studio/PhotoAnnotator.tsx": 692, "server/services/inspection/inspection-publish.service.ts": 680, "app/hooks/usePhotoOps.ts": 661, @@ -54,9 +54,9 @@ "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, + "server/portal/integration.routes.ts": 441, "app/routes/settings-schedule.tsx": 437, "app/lib/collab/results-doc-connection.ts": 435, "server/api/inspections/media.ts": 435, diff --git a/server/api/qbo-oauth.ts b/server/api/qbo-oauth.ts new file mode 100644 index 000000000..de99cd18f --- /dev/null +++ b/server/api/qbo-oauth.ts @@ -0,0 +1,164 @@ +import { Hono } from 'hono'; +import type { HonoConfig } from '../types/hono'; +import { requireRole } from '../lib/middleware/rbac'; +import { QBOTokenResponseSchema, QBOCompanyInfoResponseSchema } from '../lib/validations/qbo.schema'; +import { logger } from '../lib/logger'; +import { qboRedirectUri } from '../lib/qbo-oauth-paths'; +import { loadTenantSecrets } from '../lib/secrets-cache'; +import { applyIntegrationSecrets } from '../lib/middleware/integration-secrets'; + +/** + * The two halves of the QuickBooks OAuth handshake that a BROWSER walks + * through, split out from the rest of the QBO admin router (`api/qbo.ts`) and + * mounted under `/api/integrations/qbo` beside the webhook. + * + * They live here, and not with their siblings, because they are the only QBO + * routes the outside world navigates to. The siblings (`/status`, `/pause`, + * `/sync`, …) are fetched by the settings page through the in-process + * `API_WORKER` binding, so their `/settings/**` mount is fine; these two are + * not reachable there at all — `workers/app.ts` forwards an explicit prefix + * allow-list to this API app, `/settings/**` is not on it, and everything else + * goes to React Router, which has no `/settings/integrations/qbo/connect` page. + * + * NOTE: no router-wide `use('*')` middleware. This router shares its mount + * prefix with the webhook router, and prefix middleware here would run on + * `/api/integrations/qbo/webhook` too — a session check in front of a route + * Intuit calls with an HMAC and no cookie. The guards are per-route. + */ +const api = new Hono(); + +/** + * Authenticated entry point — keeps the owner/manager guard the rest of the QBO + * router carries (see the rationale in `api/qbo.ts`). Connecting company books + * is company-level administration, and this is the door. + * + * The global JWT middleware has already verified the cookie and set `userRole` + * by the time this runs; `requireRole` also refuses a caller with no role, + * which is what an agent (client/realtor) JWT is. + */ +api.get('/connect', requireRole('owner', 'manager'), async (c) => { + if (!c.env.QBO_CLIENT_ID || !c.env.QBO_CLIENT_SECRET) { + return c.redirect('/settings/integrations/qbo?error=not_configured', 302); + } + if (!c.env.APP_BASE_URL) { + return c.redirect('/settings/integrations/qbo?error=missing_base_url', 302); + } + + const state = crypto.randomUUID(); + // The state IS the callback's authorization, so it carries the tenant. + // + // Why that is safe: this value is a server-generated UUID, single-use (the + // callback deletes it before doing anything with it), expires in 600 + // seconds, and is only ever issued to a caller who has just passed the + // owner/manager guard above. Possession of a valid state is therefore proof + // that an owner or manager initiated this exchange — which is precisely + // what the OAuth `state` parameter is for. It is never sent to the browser + // as a credential for anything else, and it grants exactly one action: + // finishing the handshake it was minted for. + // + // It cannot be a session instead: Intuit sends the user back as a + // cross-site top-level navigation, and `__Host-inspector_token` is + // `SameSite=Strict` (`lib/auth-helpers.ts`), so the cookie is withheld on + // exactly that navigation. There is no session on the callback to read. + await c.env.TENANT_CACHE.put(`qbo_oauth_state:${state}`, c.get('tenantId'), { expirationTtl: 600 }); + + const url = new URL('https://appcenter.intuit.com/connect/oauth2'); + url.searchParams.set('client_id', c.env.QBO_CLIENT_ID); + url.searchParams.set('redirect_uri', qboRedirectUri(c.env.APP_BASE_URL)); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'com.intuit.quickbooks.accounting'); + url.searchParams.set('state', state); + return c.redirect(url.toString()); +}); + +/** + * UNAUTHENTICATED by design — declared public in `lib/middleware/jwt-auth.ts` + * (the same list that exempts the QBO webhook), and authorized by `state`. + * See the note on `/connect` for why a session cannot reach this handler. + */ +api.get('/callback', async (c) => { + const code = c.req.query('code') ?? ''; + const state = c.req.query('state') ?? ''; + const realmId = c.req.query('realmId') ?? ''; + const error = c.req.query('error'); + + // These redirects point at the React Router settings PAGE, which is where + // the user should end up — not back into this API family. + if (error) return c.redirect('/settings/integrations/qbo?error=' + encodeURIComponent(error)); + if (!c.env.APP_BASE_URL) return c.redirect('/settings/integrations/qbo?error=not_configured'); + + const tenantId = await c.env.TENANT_CACHE.get(`qbo_oauth_state:${state}`); + if (!tenantId) return c.redirect('/settings/integrations/qbo?error=invalid_state'); + // Burn it before use: a replayed callback must not be able to write a + // second connection, and the window closes even if the exchange below + // throws. + await c.env.TENANT_CACHE.delete(`qbo_oauth_state:${state}`); + c.set('tenantId', tenantId); + + // The client id/secret can be a per-tenant secret (Settings -> Integrations), + // and `integrationSecretsMiddleware` only merges those into `c.env` once a + // tenant is known. On this request the tenant was unknown until the line + // above — in saas mode nothing upstream could resolve it — so load them + // here for the tenant the state names. Same helper, same precedence rule + // (env wins, DB is the self-host fallback); a no-op when env already has + // them, which is the standalone case. + if (!c.env.QBO_CLIENT_ID || !c.env.QBO_CLIENT_SECRET) { + const decrypted = await loadTenantSecrets( + c.env.DB, c.env.TENANT_CACHE, tenantId, c.env.JWT_SECRET, c.env.JWT_SECRET_PREVIOUS, + ).catch(() => null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (decrypted) applyIntegrationSecrets(c.env as any, decrypted as Record); + } + if (!c.env.QBO_CLIENT_ID || !c.env.QBO_CLIENT_SECRET) { + return c.redirect('/settings/integrations/qbo?error=not_configured'); + } + + // Byte-identical to the value `/connect` authorized with, and to what is + // registered on the Intuit app — one function, no second literal. + const redirectUri = qboRedirectUri(c.env.APP_BASE_URL); + const basicAuth = 'Basic ' + btoa(`${c.env.QBO_CLIENT_ID}:${c.env.QBO_CLIENT_SECRET}`); + + try { + const tokenResp = await fetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', { + method: 'POST', + headers: { + Authorization: basicAuth, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri }), + }); + if (!tokenResp.ok) throw new Error('Token exchange failed'); + const tokens = QBOTokenResponseSchema.parse(await tokenResp.json()); + + let companyName: string | null = null; + try { + const infoResp = await fetch( + `https://quickbooks.api.intuit.com/v3/company/${realmId}/companyinfo/${realmId}?minorversion=75`, + { headers: { Authorization: `Bearer ${tokens.access_token}`, Accept: 'application/json' } }, + ); + if (infoResp.ok) { + const info = QBOCompanyInfoResponseSchema.parse(await infoResp.json()); + companyName = info.CompanyInfo.CompanyName; + } + } catch { /* non-fatal: company name is a UX nicety */ } + + const svc = c.var.services.qbo; + await svc.saveConnection({ + tenantId, + realmId, + companyName, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + refreshTokenExpiresIn: tokens.x_refresh_token_expires_in, + }); + c.executionCtx.waitUntil(svc.bootstrapDefaultItem(tenantId)); + + return c.redirect('/settings/integrations/qbo?connected=1'); + } catch (e) { + logger.error('QBO OAuth callback failed', { realmId }, e instanceof Error ? e : undefined); + return c.redirect('/settings/integrations/qbo?error=oauth_failed'); + } +}); + +export default api; diff --git a/server/api/qbo.ts b/server/api/qbo.ts index 4a339aa5a..652e6f310 100644 --- a/server/api/qbo.ts +++ b/server/api/qbo.ts @@ -3,8 +3,7 @@ import type { HonoConfig } from '../types/hono'; import { getCookie } from 'hono/cookie'; import { verifyJwt } from '../lib/jwt-keyring'; import { requireRole } from '../lib/middleware/rbac'; -import { QBOTokenResponseSchema, QBOCompanyInfoResponseSchema, QBOLinkCustomerBodySchema } from '../lib/validations/qbo.schema'; -import { logger } from '../lib/logger'; +import { QBOLinkCustomerBodySchema } from '../lib/validations/qbo.schema'; const api = new Hono(); @@ -43,12 +42,16 @@ api.use('*', async (c, next) => { * (client/realtor) JWT is — it satisfies the verifier above and deliberately * carries no tenant, so it must never reach a handler. * - * Note for whoever makes `/connect` and `/callback` reachable from a browser: - * they are not today (`workers/app.ts` routes `/settings/*` to SSR, which has no - * matching route), and this guard must stay in place when they are — otherwise - * that change turns an in-app escalation into an internet-addressable one. - * Asserted at the HTTP boundary in `tests/unit/qbo/qbo-route-authorization.spec.ts`, - * because neither authorization gate can see a hand-rolled Hono router. + * The browser-facing half of the integration — `/connect` and `/callback` — is + * NOT here: it lives in `api/qbo-oauth.ts` under `/api/integrations/qbo`, + * because this `/settings/**` mount is unreachable from a browser + * (`workers/app.ts` forwards an allow-list that does not include it). `/connect` + * kept this same owner/manager guard on the way over; `/callback` is authorized + * by `state` instead, since Intuit's redirect carries no cookie. + * + * Asserted at the HTTP boundary in `tests/unit/qbo/qbo-route-authorization.spec.ts` + * (and `qbo-oauth-callback.spec.ts` for the pair that moved), because neither + * authorization gate can see a hand-rolled Hono router. */ api.use('*', requireRole('owner', 'manager')); @@ -57,88 +60,6 @@ api.get('/status', async (c) => { return c.json({ success: true, data: status }); }); -api.get('/connect', async (c) => { - if (!c.env.QBO_CLIENT_ID || !c.env.QBO_CLIENT_SECRET) { - return c.redirect('/settings/integrations/qbo?error=not_configured', 302); - } - if (!c.env.APP_BASE_URL) { - return c.redirect('/settings/integrations/qbo?error=missing_base_url', 302); - } - const state = crypto.randomUUID(); - await c.env.TENANT_CACHE.put(`qbo_oauth_state:${state}`, '1', { expirationTtl: 600 }); - const redirectUri = `${c.env.APP_BASE_URL}/settings/integrations/qbo/callback`; - const url = new URL('https://appcenter.intuit.com/connect/oauth2'); - url.searchParams.set('client_id', c.env.QBO_CLIENT_ID); - url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', 'com.intuit.quickbooks.accounting'); - url.searchParams.set('state', state); - return c.redirect(url.toString()); -}); - -api.get('/callback', async (c) => { - const code = c.req.query('code') ?? ''; - const state = c.req.query('state') ?? ''; - const realmId = c.req.query('realmId') ?? ''; - const error = c.req.query('error'); - - if (error) return c.redirect('/settings/integrations/qbo?error=' + encodeURIComponent(error)); - - if (!c.env.QBO_CLIENT_ID || !c.env.QBO_CLIENT_SECRET || !c.env.APP_BASE_URL) { - return c.redirect('/settings/integrations/qbo?error=not_configured'); - } - - const stored = await c.env.TENANT_CACHE.get(`qbo_oauth_state:${state}`); - if (!stored) return c.redirect('/settings/integrations/qbo?error=invalid_state'); - await c.env.TENANT_CACHE.delete(`qbo_oauth_state:${state}`); - - const redirectUri = `${c.env.APP_BASE_URL}/settings/integrations/qbo/callback`; - const basicAuth = 'Basic ' + btoa(`${c.env.QBO_CLIENT_ID}:${c.env.QBO_CLIENT_SECRET}`); - - try { - const tokenResp = await fetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', { - method: 'POST', - headers: { - Authorization: basicAuth, - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri }), - }); - if (!tokenResp.ok) throw new Error('Token exchange failed'); - const tokens = QBOTokenResponseSchema.parse(await tokenResp.json()); - const tenantId = c.get('tenantId'); - - let companyName: string | null = null; - try { - const infoResp = await fetch( - `https://quickbooks.api.intuit.com/v3/company/${realmId}/companyinfo/${realmId}?minorversion=75`, - { headers: { Authorization: `Bearer ${tokens.access_token}`, Accept: 'application/json' } }, - ); - if (infoResp.ok) { - const info = QBOCompanyInfoResponseSchema.parse(await infoResp.json()); - companyName = info.CompanyInfo.CompanyName; - } - } catch { /* non-fatal: company name is a UX nicety */ } - - const svc = c.var.services.qbo; - await svc.saveConnection({ - tenantId, - realmId, - companyName, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - refreshTokenExpiresIn: tokens.x_refresh_token_expires_in, - }); - c.executionCtx.waitUntil(svc.bootstrapDefaultItem(tenantId)); - - return c.redirect('/settings/integrations/qbo?connected=1'); - } catch (e) { - logger.error('QBO OAuth callback failed', { realmId }, e instanceof Error ? e : undefined); - return c.redirect('/settings/integrations/qbo?error=oauth_failed'); - } -}); - api.post('/disconnect', async (c) => { await c.var.services.qbo.disconnect(c.get('tenantId')); return c.json({ success: true }); diff --git a/server/index.ts b/server/index.ts index 8d2d45e7c..c162ecb1d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -103,6 +103,8 @@ import conciergeRoutes from './api/concierge'; import sessionContextRoutes from './api/session-context'; import qboRoutes from './api/qbo'; import qboWebhookRoutes from './api/qbo-webhook'; +import qboOauthRoutes from './api/qbo-oauth'; +import { QBO_OAUTH_MOUNT } from './lib/qbo-oauth-paths'; import stripeWebhookRoutes from './api/stripe-webhook'; import agreementsRenderRoutes from './api/agreements-render'; import evidenceRoutes from './api/evidence'; @@ -442,6 +444,12 @@ const routes = app .route('/api', notificationPreferenceRoutes) // reader's own preferences (§4) .route('/settings/integrations/qbo', qboRoutes) .route('/api/integrations/qbo/webhook', qboWebhookRoutes) + // Browser-facing OAuth pair (/connect, /callback). Mounted under /api/* — + // NOT under /settings/** with its siblings — because workers/app.ts only + // forwards an allow-list of prefixes to this app and /settings/** is not on + // it, so a browser can never reach those. Registered AFTER the webhook so no + // ordering question can arise on the shared prefix. See lib/qbo-oauth-paths.ts. + .route(QBO_OAUTH_MOUNT, qboOauthRoutes) // Stripe webhook, tenant-scoped (SaaS): /api/integrations/stripe/webhook/:tenant // resolves the tenant via PUBLIC_PREFIXES path-param resolution so // integration-secrets loads THAT tenant's whsec. The bare path below stays diff --git a/server/lib/middleware/jwt-auth.ts b/server/lib/middleware/jwt-auth.ts index 2aa614b91..a0dac613b 100644 --- a/server/lib/middleware/jwt-auth.ts +++ b/server/lib/middleware/jwt-auth.ts @@ -28,6 +28,7 @@ import type * as schema from '../db/schema'; import type { HonoConfig } from '../../types/hono'; import type { UserRole } from '../../types/auth'; import { bearerToken, AUTH_COOKIE_NAME } from '../auth-helpers'; +import { QBO_CALLBACK_PATH } from '../qbo-oauth-paths'; // Static asset extensions — these bypass JWT verification. We use a strict allowlist // rather than path.includes('.') so a dot inside a path segment (e.g. "/inspections/foo.bar") @@ -50,7 +51,13 @@ export const jwtAuthMiddleware: MiddlewareHandler = async (c, next) path === '/api/concierge/book-info' || path === '/api/concierge/book' || path === '/api/concierge/confirm-info'; - const isPublic = path.startsWith('/api/__test__/') || path.startsWith('/api/public/') || path.startsWith('/api/integration/') || path.startsWith('/api/admin/connect') || path.startsWith('/api/admin/silo') || path.startsWith('/api/ics/') || path === '/book' || path.startsWith('/book/') || path.startsWith('/inspector/') || path.startsWith('/embed/') || path.startsWith('/photos/') || path === '/' || path === '/status' || path.startsWith('/static/') || path.startsWith('/report/') || path.startsWith('/report-view/') || path.startsWith('/invoice/') || path.startsWith('/agreements/sign/') || path.startsWith('/checkout/') || path.startsWith('/sign/') || path.startsWith('/m2m/') || path.startsWith('/verify/') || path.startsWith('/v/') || path.startsWith('/.well-known/') || STATIC_ASSET_EXT.test(path) || path === '/api/integrations/qbo/webhook' || path === '/api/integrations/stripe/webhook' || path.startsWith('/api/integrations/stripe/webhook/') || path.startsWith('/repair-request/') || path.startsWith('/repair-builder/') || path.startsWith('/api/portal/') || path.startsWith('/portal/'); + // `QBO_CALLBACK_PATH` below is public for the same shape of reason as the QBO + // webhook beside it: neither caller can hold a session. Intuit returns the + // user by cross-site top-level navigation, and `__Host-inspector_token` is + // SameSite=Strict, so no cookie is on that request — the route is authorized + // by a single-use, 600s, owner/manager-issued `state` instead. See + // `server/api/qbo-oauth.ts`. + const isPublic = path.startsWith('/api/__test__/') || path.startsWith('/api/public/') || path.startsWith('/api/integration/') || path.startsWith('/api/admin/connect') || path.startsWith('/api/admin/silo') || path.startsWith('/api/ics/') || path === '/book' || path.startsWith('/book/') || path.startsWith('/inspector/') || path.startsWith('/embed/') || path.startsWith('/photos/') || path === '/' || path === '/status' || path.startsWith('/static/') || path.startsWith('/report/') || path.startsWith('/report-view/') || path.startsWith('/invoice/') || path.startsWith('/agreements/sign/') || path.startsWith('/checkout/') || path.startsWith('/sign/') || path.startsWith('/m2m/') || path.startsWith('/verify/') || path.startsWith('/v/') || path.startsWith('/.well-known/') || STATIC_ASSET_EXT.test(path) || path === '/api/integrations/qbo/webhook' || path === QBO_CALLBACK_PATH || path === '/api/integrations/stripe/webhook' || path.startsWith('/api/integrations/stripe/webhook/') || path.startsWith('/repair-request/') || path.startsWith('/repair-builder/') || path.startsWith('/api/portal/') || path.startsWith('/portal/'); if (isAuthPublic || isPublic || isAgentPublic || isConciergePublic || path === '/setup' || path === '/login' || path === '/join') return next(); diff --git a/server/lib/qbo-oauth-paths.ts b/server/lib/qbo-oauth-paths.ts new file mode 100644 index 000000000..3af338ba4 --- /dev/null +++ b/server/lib/qbo-oauth-paths.ts @@ -0,0 +1,22 @@ +/** + * The QuickBooks OAuth mount point, and the redirect URI derived from it. + * + * Intuit matches `redirect_uri` byte-for-byte against the value registered on + * the app — including casing, scheme, and any trailing slash — and it is sent + * TWICE in one flow: once on the authorize redirect and again on the token + * exchange. Two hand-written copies is one chance to drift, and the failure it + * produces (`invalid_grant` at the exchange, after the user has already + * approved) reads like a credential problem, not a string problem. So both + * sides call `qboRedirectUri`, and the mount in `server/index.ts` plus the + * `isPublic` entry in `jwt-auth.ts` come from the same constants — the path + * cannot move without the URI moving with it. + */ +export const QBO_OAUTH_MOUNT = '/api/integrations/qbo'; + +/** Absolute path Intuit redirects the browser back to. */ +export const QBO_CALLBACK_PATH = `${QBO_OAUTH_MOUNT}/callback`; + +/** The exact string to register with Intuit, for a given public origin. */ +export function qboRedirectUri(appBaseUrl: string): string { + return `${appBaseUrl}${QBO_CALLBACK_PATH}`; +} diff --git a/tests/unit/qbo/qbo-oauth-callback.spec.ts b/tests/unit/qbo/qbo-oauth-callback.spec.ts new file mode 100644 index 000000000..e3bea12ec --- /dev/null +++ b/tests/unit/qbo/qbo-oauth-callback.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoConfig } from '../../../server/types/hono'; +import type { UserRole } from '../../../server/types/auth'; +import { AppError } from '../../../server/lib/errors'; + +/** + * The Intuit redirect lands here as a CROSS-SITE top-level navigation, so the + * staff session cookie is not on it: `__Host-inspector_token` is + * `SameSite=Strict` (`server/lib/auth-helpers.ts`), which withholds the cookie + * on exactly this kind of navigation. A callback that authenticates by session + * therefore cannot ever succeed — it 401s for every user, every time. + * + * So the callback is authorized by the `state` parameter instead, and these + * assertions are written at the HTTP boundary with NO Cookie header at all, + * because that is the only shape Intuit will ever send. A test that passes a + * cookie would pass against a router that is broken in production. + * + * `createRoutesStub` is deliberately not used: it does not run middleware, so + * it cannot tell an authorized callback from an unauthorized one. + */ + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); + +// Authentication is not what is under test here. `/connect` still needs a +// verifier for its owner/manager guard; the callback must reach its handler +// without one. +vi.mock('../../../server/lib/jwt-keyring', () => ({ + verifyJwt: vi.fn(async () => ({ sub: 'u1' })), +})); + +// eslint-disable-next-line import/order +import qboOauthRoutes from '../../../server/api/qbo-oauth'; +// eslint-disable-next-line import/order +import { QBO_OAUTH_MOUNT, qboRedirectUri } from '../../../server/lib/qbo-oauth-paths'; + +const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const APP_BASE_URL = 'https://inspect.example.com'; +const REALM_ID = '9130350000000000'; + +function makeKv() { + const store = new Map(); + return { + store, + get: vi.fn(async (key: string) => store.get(key) ?? null), + put: vi.fn(async (key: string, value: string) => { store.set(key, value); }), + delete: vi.fn(async (key: string) => { store.delete(key); }), + }; +} + +const qboService = { + saveConnection: vi.fn(async () => {}), + bootstrapDefaultItem: vi.fn(async () => {}), +}; + +/** + * `role` is what the global JWT middleware would have established. The callback + * is exercised with `undefined` — nothing upstream can identify the caller. + */ +function buildApp(kv: ReturnType, role?: UserRole) { + const app = new Hono(); + + 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) => { + if (role) { + c.set('tenantId', TENANT_ID); + c.set('userRole', role); + } + c.set('keyringPromise', Promise.resolve({} as never)); + c.set('services', { qbo: qboService } as never); + return next(); + }); + + app.route(QBO_OAUTH_MOUNT, qboOauthRoutes); + return app; +} + +const ENV = (kv: ReturnType) => ({ + QBO_CLIENT_ID: 'test-client-id', + QBO_CLIENT_SECRET: 'test-client-secret', + APP_BASE_URL, + TENANT_CACHE: kv, + DB: {}, + JWT_SECRET: 'a'.repeat(32), +}) as never; + +const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as never; + +function tokenExchangeOk() { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('oauth.platform.intuit.com')) { + return new Response(JSON.stringify({ + access_token: 'at', + refresh_token: 'rt', + x_refresh_token_expires_in: 8_726_400, + token_type: 'bearer', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ CompanyInfo: { CompanyName: 'Sandbox Co' } }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + }); +} + +describe('QBO OAuth callback authorization', () => { + let kv: ReturnType; + + beforeEach(() => { + kv = makeKv(); + qboService.saveConnection.mockClear(); + qboService.bootstrapDefaultItem.mockClear(); + vi.stubGlobal('fetch', tokenExchangeOk()); + }); + + afterEach(() => { vi.unstubAllGlobals(); }); + + it('completes with NO cookie, resolving the tenant from the state', async () => { + // Exactly what Intuit sends: a bare GET, cross-site, no Cookie header. + kv.store.set('qbo_oauth_state:st-1', TENANT_ID); + + const res = await buildApp(kv).request( + `${QBO_OAUTH_MOUNT}/callback?code=c1&state=st-1&realmId=${REALM_ID}`, + {}, + ENV(kv), + CTX, + ); + + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/settings/integrations/qbo?connected=1'); + expect(qboService.saveConnection).toHaveBeenCalledTimes(1); + // The tenant must come from the state, not from a session that was + // never sent. `undefined` here would write a connection row nobody owns. + expect(qboService.saveConnection.mock.calls[0][0]).toMatchObject({ + tenantId: TENANT_ID, + realmId: REALM_ID, + }); + }); + + it('sends the token exchange the SAME redirect_uri it authorized with', async () => { + // Intuit compares this byte-for-byte with the registered value; a + // mismatch fails at the exchange, after the user already approved. + kv.store.set('qbo_oauth_state:st-2', TENANT_ID); + + await buildApp(kv).request( + `${QBO_OAUTH_MOUNT}/callback?code=c1&state=st-2&realmId=${REALM_ID}`, + {}, ENV(kv), CTX, + ); + + const call = (globalThis.fetch as unknown as ReturnType).mock.calls + .find(([u]: [unknown]) => String(u).includes('oauth.platform.intuit.com')); + const body = new URLSearchParams(String((call![1] as RequestInit).body)); + expect(body.get('redirect_uri')).toBe(qboRedirectUri(APP_BASE_URL)); + expect(body.get('redirect_uri')).toBe(`${APP_BASE_URL}/api/integrations/qbo/callback`); + }); + + it('refuses an unknown state', async () => { + const res = await buildApp(kv).request( + `${QBO_OAUTH_MOUNT}/callback?code=c1&state=forged&realmId=${REALM_ID}`, + {}, ENV(kv), CTX, + ); + expect(res.headers.get('location')).toBe('/settings/integrations/qbo?error=invalid_state'); + expect(qboService.saveConnection).not.toHaveBeenCalled(); + }); + + it('refuses a REUSED state — single use is the whole guarantee', async () => { + kv.store.set('qbo_oauth_state:st-3', TENANT_ID); + const app = buildApp(kv); + const first = await app.request( + `${QBO_OAUTH_MOUNT}/callback?code=c1&state=st-3&realmId=${REALM_ID}`, {}, ENV(kv), CTX); + expect(first.headers.get('location')).toBe('/settings/integrations/qbo?connected=1'); + + const second = await app.request( + `${QBO_OAUTH_MOUNT}/callback?code=c1&state=st-3&realmId=${REALM_ID}`, {}, ENV(kv), CTX); + expect(second.headers.get('location')).toBe('/settings/integrations/qbo?error=invalid_state'); + expect(qboService.saveConnection).toHaveBeenCalledTimes(1); + }); +}); + +describe('QBO OAuth connect guard', () => { + let kv: ReturnType; + + beforeEach(() => { kv = makeKv(); }); + + function connect(role?: UserRole) { + return buildApp(kv, role).request(`${QBO_OAUTH_MOUNT}/connect`, { + headers: { Cookie: '__Host-inspector_token=stub' }, + }, ENV(kv), CTX); + } + + it('still refuses an inspector', async () => { + expect((await connect('inspector')).status).toBe(403); + expect(kv.store.size).toBe(0); + }); + + it('refuses a caller with no role', async () => { + expect((await connect(undefined)).status).toBe(401); + }); + + it('stores the initiating tenant under the state key, not a placeholder', async () => { + // The stored value IS the authorization the callback will read. A + // placeholder makes the callback unable to tell whose books these are. + const res = await connect('owner'); + expect(res.status).toBe(302); + + const authorizeUrl = new URL(res.headers.get('location')!); + expect(authorizeUrl.searchParams.get('redirect_uri')).toBe(qboRedirectUri(APP_BASE_URL)); + + const state = authorizeUrl.searchParams.get('state')!; + expect(kv.store.get(`qbo_oauth_state:${state}`)).toBe(TENANT_ID); + }); +}); From e4d29227c62f79df3edda7e63a39b53ae28ec530 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 11:05:06 +0800 Subject: [PATCH 27/77] feat(qbo): choose the Intuit API host by QBO_ENV, with no default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accounting host was compiled in — quickbooks.api.intuit.com, in services/qbo/api-base.ts and again in the OAuth callback's companyinfo lookup. Sandbox lives at sandbox-quickbooks.api.intuit.com, and the two are not reachable with the same credentials: Intuit Development keys authenticate only against sandbox companies, Production keys only against real ones. A build that can only address production therefore cannot be exercised against a sandbox at all, which is a large part of why this integration has never run end to end. QBO_ENV names the host. It fails closed — unset or unrecognised raises before any request leaves the worker, and the OAuth callback refuses to store a connection rather than saving a working token against an API the worker will then decline to call. No fallback constant, because either default is wrong half the time and both failure modes read as credential problems: pointed at production with Development keys you get an auth error, and pointed at sandbox with Production keys a paying customer's books quietly receive nothing. Resolved lazily off the constructor rather than eagerly, since the service is built for every request that touches an invoice and a deployment with no QuickBooks connection should not fail on a setting it never uses. The token and revoke endpoints are shared by both environments and are unchanged. Asserted through apiCall — what matters is the URL that actually leaves the worker, not a helper in isolation. Proven red first: "expected 'https://quickbooks.api.intuit.com/...' to be 'https://sandbox-quickbooks.api.intuit.com/...'", and the two fail-closed cases resolved instead of rejecting. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- CLAUDE.md | 1 + scripts/file-size-baseline.json | 2 +- server/api/qbo-oauth.ts | 14 ++++- server/lib/middleware/di.ts | 1 + server/scheduled.ts | 2 + server/services/qbo/api-base.ts | 47 +++++++++++++++- server/types/hono.ts | 6 +++ tests/unit/qbo/qbo-api-env.spec.ts | 66 +++++++++++++++++++++++ tests/unit/qbo/qbo-oauth-callback.spec.ts | 14 +++++ 9 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 tests/unit/qbo/qbo-api-env.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index a75e513cf..f9d9c9b8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,6 +177,7 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls | `SYNC_QUEUE` | No | Cloudflare Queue producer for the SaaS user-sync seam (SaaS only; absent in standalone). The outbox publishes CloudEvents envelopes here; a cron sweeper republishes stragglers; this worker also consumes the matching DLQ to mark failed rows. The same queue carries command REPLIES (`reply.tenant.updated`) from the cmd consumer. (The former `PORTAL_SERVICE` Service Binding was RETIRED 2026-06-04 — core holds no binding to portal; inbound M2M is guarded by the `x-portal-m2m` HMAC.) Inbound portal→core commands arrive on a separate queue this worker consumes (`server/portal/cmd-consumer.ts`): dedup (`processed_cmd_events`) → per-tenant stale guard (`tenants.applied_cmd_seq`) + credential-stream guard (`tenants.applied_cred_seq`) → apply → optional reply; unknown types park (`parked_cmd_events`). | | `STRIPE_SECRET_KEY` | No | Stripe Connect (each tenant's OWN account; the platform never collects payments). Resolution is tenant-DB-preferred: a tenant's stored key always beats this env, so a platform-level binding can never hijack tenant payments. | | `STRIPE_WEBHOOK_SECRET` | No | Stripe webhook HMAC verification | +| `QBO_ENV` | No | Which Intuit host the QuickBooks Online integration calls: `sandbox` (`https://sandbox-quickbooks.api.intuit.com`) or `production` (`https://quickbooks.api.intuit.com`). **No default and no fallback** — when unset, every QuickBooks API call throws and `GET /api/integrations/qbo/callback` refuses to store a connection. That is deliberate: Intuit Development keys authenticate only against sandbox companies and Production keys only against real ones, so a guessed host is wrong for one of them and fails in a way that reads like a bad credential. Required (together with `QBO_CLIENT_ID` / `QBO_CLIENT_SECRET`, which may instead be set per tenant in Settings → Integrations) for any QuickBooks sync. The OAuth authorize, token, and revoke endpoints are shared by both environments and are not affected by this setting. | | `GOOGLE_PLACES_API_KEY` | No | Google Places API key powering address autocomplete on the dashboard new-inspection wizard and the public `/book` page (proxied via `/api/places/*` and `/public/geocode`). When unset, both endpoints return `{ data: [], reason: 'NO_API_KEY' }` and the address inputs degrade gracefully to plain text — the customer can still type a free-form address and submit. | | `ESTATED_API_KEY` | No | Estated.io public-records key for the `POST /api/inspections/:id/property-facts/autofill` endpoint. Resolves year built / sqft / foundation / lot size / bedrooms / bathrooms by address. When unset, returns `{ data: null, reason: 'NO_API_KEY' }` and the Property Facts card shows a polite "auto-fill not configured" hint while still accepting manual entry. Same graceful-degrade pattern as `GOOGLE_PLACES_API_KEY`. | | `STREAM` | No | Cloudflare Stream binding (binding name `STREAM`). Required only when the video backend is set to Stream (self-host: Settings → Integrations → Video; SaaS: paid tier). Absent in the default R2 configuration. | diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 8d2485b9c..8c782df08 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -60,8 +60,8 @@ "app/routes/settings-schedule.tsx": 437, "app/lib/collab/results-doc-connection.ts": 435, "server/api/inspections/media.ts": 435, + "server/lib/middleware/di.ts": 434, "app/components/media-studio/VideoCapture.tsx": 433, - "server/lib/middleware/di.ts": 433, "app/routes/public/portal-inspection.tsx": 430, "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, diff --git a/server/api/qbo-oauth.ts b/server/api/qbo-oauth.ts index de99cd18f..bc8d2562e 100644 --- a/server/api/qbo-oauth.ts +++ b/server/api/qbo-oauth.ts @@ -4,6 +4,7 @@ import { requireRole } from '../lib/middleware/rbac'; import { QBOTokenResponseSchema, QBOCompanyInfoResponseSchema } from '../lib/validations/qbo.schema'; import { logger } from '../lib/logger'; import { qboRedirectUri } from '../lib/qbo-oauth-paths'; +import { resolveQboApiBase } from '../services/qbo/api-base'; import { loadTenantSecrets } from '../lib/secrets-cache'; import { applyIntegrationSecrets } from '../lib/middleware/integration-secrets'; @@ -113,6 +114,17 @@ api.get('/callback', async (c) => { return c.redirect('/settings/integrations/qbo?error=not_configured'); } + // Which Intuit host this deployment talks to. Checked here rather than + // after the exchange: connecting sandbox books with no QBO_ENV would store + // a working token against an API the worker refuses to call, and the + // integration would look connected while syncing nothing. + let apiBase: string; + try { + apiBase = resolveQboApiBase(c.env.QBO_ENV); + } catch { + return c.redirect('/settings/integrations/qbo?error=not_configured'); + } + // Byte-identical to the value `/connect` authorized with, and to what is // registered on the Intuit app — one function, no second literal. const redirectUri = qboRedirectUri(c.env.APP_BASE_URL); @@ -134,7 +146,7 @@ api.get('/callback', async (c) => { let companyName: string | null = null; try { const infoResp = await fetch( - `https://quickbooks.api.intuit.com/v3/company/${realmId}/companyinfo/${realmId}?minorversion=75`, + `${apiBase}/${realmId}/companyinfo/${realmId}?minorversion=75`, { headers: { Authorization: `Bearer ${tokens.access_token}`, Accept: 'application/json' } }, ); if (infoResp.ok) { diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts index 28f9e8997..c58dccab8 100644 --- a/server/lib/middleware/di.ts +++ b/server/lib/middleware/di.ts @@ -395,6 +395,7 @@ export async function diMiddleware(c: Context, next: Next) { c.env.QBO_CLIENT_SECRET ?? '', c.env.QBO_WEBHOOK_SECRET ?? '', c.env.JWT_SECRET, + c.env.QBO_ENV, ); break; case 'unit': diff --git a/server/scheduled.ts b/server/scheduled.ts index 4d5d65812..edd6b3c2a 100644 --- a/server/scheduled.ts +++ b/server/scheduled.ts @@ -33,6 +33,7 @@ export interface ScheduledEnv { JWT_SECRET_PREVIOUS?: string; QBO_CLIENT_ID?: string; QBO_CLIENT_SECRET?: string; + QBO_ENV?: string; QBO_WEBHOOK_SECRET?: string; // Track L — platform-default Twilio creds + the KV used by loadTwilioForTenant // to read per-tenant secrets. The cron SMS runtime is built only when both @@ -72,6 +73,7 @@ async function runQBOCDC(env: ScheduledEnv): Promise { env.QBO_CLIENT_SECRET ?? '', env.QBO_WEBHOOK_SECRET ?? '', env.JWT_SECRET, + env.QBO_ENV, ); const invoiceSvc = new InvoiceService(env.DB); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/services/qbo/api-base.ts b/server/services/qbo/api-base.ts index f719f73a4..c6c459f3f 100644 --- a/server/services/qbo/api-base.ts +++ b/server/services/qbo/api-base.ts @@ -5,7 +5,37 @@ 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'; +/** + * QuickBooks serves sandbox companies and real companies from two different + * hosts, and the credentials are not interchangeable: Intuit Development keys + * authenticate only against sandbox, Production keys only against production. + * So the host is a deployment decision, named by `QBO_ENV`. + * + * There is deliberately NO default. Either default is wrong half the time, and + * both failure modes are silent to the operator: pointed at production with + * Development keys you get an auth error that reads like a bad secret, and + * pointed at sandbox with Production keys a paying customer's books quietly + * receive nothing. Unset raises instead — see `resolveQboApiBase`. + * + * The OAuth token and revoke endpoints below are shared by both environments; + * only the accounting API host differs. + */ +const QBO_API_HOSTS: Record = { + sandbox: 'https://sandbox-quickbooks.api.intuit.com', + production: 'https://quickbooks.api.intuit.com', +}; + +/** The company-scoped API base for `QBO_ENV`. Throws when it is unset or unknown. */ +export function resolveQboApiBase(qboEnv: string | undefined): string { + const host = qboEnv ? QBO_API_HOSTS[qboEnv] : undefined; + if (!host) { + throw new Error( + `QBO_ENV must be one of [${Object.keys(QBO_API_HOSTS).join(', ')}] to reach the QuickBooks API (got ${qboEnv === undefined ? 'unset' : `"${qboEnv}"`})`, + ); + } + return `${host}/v3/company`; +} + const QBO_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'; export const QBO_REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke'; const MINOR_VERSION = '75'; @@ -67,8 +97,18 @@ export class QBOServiceBase { protected clientSecret: string, protected webhookSecret: string, protected jwtSecret: string, + /** + * `QBO_ENV` verbatim, resolved lazily rather than in the constructor: + * the service is built for every request that touches an invoice, and + * a deployment with no QuickBooks connection at all should not fail on + * construction for a setting it never uses. + */ + protected qboEnv?: string, ) {} + /** Throws when `QBO_ENV` is unset or unknown — no host is guessed. */ + protected get apiBase(): string { return resolveQboApiBase(this.qboEnv); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected getDrizzle() { return drizzle(this.db as any); } @@ -133,9 +173,12 @@ export class QBOServiceBase { path: string, body?: unknown, ): Promise { + // Resolved before the token, so a misconfigured QBO_ENV surfaces + // without first spending a refresh round-trip on Intuit. + const base = this.apiBase; const { accessToken, realmId } = await this.getToken(tenantId); const separator = path.includes('?') ? '&' : '?'; - const url = `${QBO_API_BASE}/${realmId}/${path}${separator}minorversion=${MINOR_VERSION}`; + const url = `${base}/${realmId}/${path}${separator}minorversion=${MINOR_VERSION}`; const opts: RequestInit = { method, diff --git a/server/types/hono.ts b/server/types/hono.ts index 021dda476..bb3fad279 100644 --- a/server/types/hono.ts +++ b/server/types/hono.ts @@ -223,6 +223,12 @@ export interface AppEnv { QBO_CLIENT_ID?: string; QBO_CLIENT_SECRET?: string; QBO_WEBHOOK_SECRET?: string; + /** + * `sandbox` | `production` — which Intuit API host to call. No default: + * Development and Production keys are not interchangeable, so guessing is + * always wrong for one of them. See services/qbo/api-base.ts. + */ + QBO_ENV?: string; } import type { AdminService } from '../services/admin.service'; diff --git a/tests/unit/qbo/qbo-api-env.spec.ts b/tests/unit/qbo/qbo-api-env.spec.ts new file mode 100644 index 000000000..10143e5d3 --- /dev/null +++ b/tests/unit/qbo/qbo-api-env.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { QBOServiceBase } from '../../../server/services/qbo/api-base'; + +/** + * QuickBooks has two API hosts, and the credentials are NOT interchangeable: + * Intuit Development keys authenticate only against sandbox companies, and + * Production keys only against real ones. A build that can only ever talk to + * `quickbooks.api.intuit.com` therefore cannot be exercised against a sandbox + * at all — which is why this integration has never been tested end to end. + * + * The host is asserted through `apiCall`, not against a helper in isolation: + * what matters is the URL that actually leaves the worker. + * + * Unset fails CLOSED. There is no default host, because either default is + * wrong half the time and the wrong one is the expensive half — a build that + * silently points at production and is handed sandbox keys fails with an auth + * error nobody reads as a configuration mistake, and one that silently points + * at sandbox writes a customer's real books nowhere. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +class ProbeQbo extends QBOServiceBase { + public call(path: string) { return this.apiCall('t1', 'GET', path); } + protected override async getToken() { + return { accessToken: 'at', realmId: '9130350000000000', tenantId: 't1' }; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const build = (qboEnv?: string) => new ProbeQbo({} as any, 'cid', 'csec', 'whsec', 'a'.repeat(32), qboEnv); + +describe('QBO API host selection', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { vi.unstubAllGlobals(); }); + + it('calls the SANDBOX host when QBO_ENV=sandbox', async () => { + await build('sandbox').call('companyinfo/9130350000000000'); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'https://sandbox-quickbooks.api.intuit.com/v3/company/9130350000000000/companyinfo/9130350000000000?minorversion=75', + ); + }); + + it('calls the PRODUCTION host when QBO_ENV=production', async () => { + await build('production').call('companyinfo/9130350000000000'); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'https://quickbooks.api.intuit.com/v3/company/9130350000000000/companyinfo/9130350000000000?minorversion=75', + ); + }); + + it('fails closed when QBO_ENV is unset — no request is made', async () => { + await expect(build(undefined).call('companyinfo/1')).rejects.toThrow(/QBO_ENV/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fails closed on an unrecognised QBO_ENV rather than guessing', async () => { + await expect(build('staging').call('companyinfo/1')).rejects.toThrow(/QBO_ENV/); + await expect(build('').call('companyinfo/1')).rejects.toThrow(/QBO_ENV/); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/qbo/qbo-oauth-callback.spec.ts b/tests/unit/qbo/qbo-oauth-callback.spec.ts index e3bea12ec..6256bc0eb 100644 --- a/tests/unit/qbo/qbo-oauth-callback.spec.ts +++ b/tests/unit/qbo/qbo-oauth-callback.spec.ts @@ -84,6 +84,7 @@ function buildApp(kv: ReturnType, role?: UserRole) { const ENV = (kv: ReturnType) => ({ QBO_CLIENT_ID: 'test-client-id', QBO_CLIENT_SECRET: 'test-client-secret', + QBO_ENV: 'sandbox', APP_BASE_URL, TENANT_CACHE: kv, DB: {}, @@ -160,6 +161,19 @@ describe('QBO OAuth callback authorization', () => { expect(body.get('redirect_uri')).toBe(`${APP_BASE_URL}/api/integrations/qbo/callback`); }); + it('refuses to complete when QBO_ENV is unset', async () => { + // Storing a token against an API host the worker will refuse to call + // would leave the page saying "connected" while nothing ever syncs. + kv.store.set('qbo_oauth_state:st-4', TENANT_ID); + const env = { ...(ENV(kv) as object), QBO_ENV: undefined } as never; + + const res = await buildApp(kv).request( + `${QBO_OAUTH_MOUNT}/callback?code=c1&state=st-4&realmId=${REALM_ID}`, {}, env, CTX); + + expect(res.headers.get('location')).toBe('/settings/integrations/qbo?error=not_configured'); + expect(qboService.saveConnection).not.toHaveBeenCalled(); + }); + it('refuses an unknown state', async () => { const res = await buildApp(kv).request( `${QBO_OAUTH_MOUNT}/callback?code=c1&state=forged&realmId=${REALM_ID}`, From 3eefddb2f87b4b5cc2888788d182931eb0e42d91 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 11:09:28 +0800 Subject: [PATCH 28/77] fix(qbo): stop disconnecting a customer over a transient refresh failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshToken treated any non-2xx from Intuit's token endpoint as "reauthorize required" and DELETEd the connection row. Intuit rotates the refresh token every 24-26 hours, so that path runs on every connected tenant every day, forever — which means one Intuit 5xx, or one rate limit, permanently severed a paying customer's integration and sent an owner back through the whole OAuth flow. Their books stop syncing in the meantime, with nothing recorded that says why. Only an explicit refusal of the grant is terminal now. 400 (invalid_grant) and 401 are the answers that actually say the token is dead; 429, 5xx and network errors say nothing about its validity, so the row is left intact and the next attempt uses the same token. The two cases also raise distinct messages, so a log line no longer has to be guessed at. Intuit has begun returning a field on this response that dates the refresh token's hard expiry. It is deliberately not read: the exact key could not be verified from the published documentation, and keying a destructive delete off a guessed field name would reintroduce exactly the failure this removes. The status code already separates the two cases; the field would only allow disconnecting EARLIER, which is not the useful direction. Proven red first: at 500 and at 429, "expected undefined to be truthy" — the connection row was gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- server/services/qbo/api-base.ts | 25 ++++- tests/unit/qbo/qbo-refresh-failure.spec.ts | 110 +++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/unit/qbo/qbo-refresh-failure.spec.ts diff --git a/server/services/qbo/api-base.ts b/server/services/qbo/api-base.ts index c6c459f3f..53d47dfeb 100644 --- a/server/services/qbo/api-base.ts +++ b/server/services/qbo/api-base.ts @@ -150,8 +150,29 @@ export class QBOServiceBase { }); if (!resp.ok) { - await db.delete(qboConnections).where(eq(qboConnections.tenantId, tenantId)); - throw new Error('QBO token refresh failed — reconnect required'); + // Only an explicit refusal of the grant is terminal. + // + // Intuit rotates the refresh token every 24-26 hours, so this path + // runs on every connected tenant every day. Treating any non-2xx as + // "reauthorize required" meant a single Intuit 5xx or a rate limit + // permanently disconnected a paying customer and sent an owner back + // through the whole OAuth flow — for an outage that had nothing to + // say about their token. 400 (`invalid_grant`) and 401 are the + // answers that DO say the token is dead; everything else leaves it + // intact so the next attempt can use it. + // + // Intuit also began returning a field on this response that dates + // the refresh token's hard expiry. It is deliberately not read + // here: the exact key was not verifiable at the time of writing, + // and keying a destructive delete off a guessed field name would + // reintroduce the same failure it is supposed to prevent. The + // status code already distinguishes the two cases; the field would + // only let us disconnect EARLIER, which is not the useful direction. + if (resp.status === 400 || resp.status === 401) { + await db.delete(qboConnections).where(eq(qboConnections.tenantId, tenantId)); + throw new Error('QBO refresh token rejected — reconnect required'); + } + throw new Error(`QBO token refresh failed with ${resp.status} — connection left intact`); } const data = QBOTokenResponseSchema.parse(await resp.json()); diff --git a/tests/unit/qbo/qbo-refresh-failure.spec.ts b/tests/unit/qbo/qbo-refresh-failure.spec.ts new file mode 100644 index 000000000..2156a2802 --- /dev/null +++ b/tests/unit/qbo/qbo-refresh-failure.spec.ts @@ -0,0 +1,110 @@ +/** + * A transient failure must not disconnect a paying customer. + * + * Intuit rotates the refresh token every 24-26 hours, so `refreshToken` runs on + * every connected tenant, every day, forever. It used to treat ANY non-2xx from + * the token endpoint as "reauthorize required" and DELETE the connection row — + * meaning a single Intuit 5xx, or a rate limit, permanently severed the + * integration and forced an owner back through the whole OAuth flow, with no + * record of why. The tenant's books stop syncing in the meantime, silently. + * + * Only an explicit refusal of the grant is terminal. Intuit answers a dead or + * already-rotated refresh token with 400 (`invalid_grant`) or 401; everything + * else — 429, 5xx, a network error — says nothing about the token's validity + * and must leave it alone so the next attempt can use it. + */ +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('../../../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'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; + +/** Exposes the protected refresh so the real decision is what is under test. */ +class TestQbo extends QBOServiceBase { + refresh(tenantId: string) { return this.refreshToken(tenantId); } +} + +function respond(status: number, body: unknown = { error: 'x' }) { + return vi.fn(async () => new Response(JSON.stringify(body), { + status, headers: { 'Content-Type': 'application/json' }, + })); +} + +describe('QBO refresh-token failure handling', () => { + let db: BetterSQLite3Database; + let svc: TestQbo; + + beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db as unknown as BetterSQLite3Database; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + + await db.insert(schema.qboConnections).values({ + tenantId: TENANT, + realmId: '9130350000000000', + companyName: 'Sandbox Co', + accessToken: 'enc:at', + refreshToken: 'enc:rt', + tokenExpiresAt: new Date(Date.now() - 1000), + refreshTokenExpiresAt: new Date(Date.now() + 86_400_000 * 100), + syncEnabled: true, + defaultItemId: '1', + createdAt: new Date(), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + svc = new TestQbo({} as any, 'cid', 'csec', 'whsec', 'a'.repeat(32), 'sandbox'); + }); + + const connectionRow = () => + db.select().from(schema.qboConnections).where(eq(schema.qboConnections.tenantId, TENANT)).get(); + + it('KEEPS the connection when Intuit returns 500', async () => { + vi.stubGlobal('fetch', respond(500)); + await expect(svc.refresh(TENANT)).rejects.toThrow(); + // The refresh token is untouched and still usable on the next attempt. + expect(connectionRow()).toBeTruthy(); + expect(connectionRow()!.refreshToken).toBe('enc:rt'); + vi.unstubAllGlobals(); + }); + + it('KEEPS the connection when Intuit rate-limits (429)', async () => { + vi.stubGlobal('fetch', respond(429)); + await expect(svc.refresh(TENANT)).rejects.toThrow(); + expect(connectionRow()).toBeTruthy(); + vi.unstubAllGlobals(); + }); + + it('KEEPS the connection when the network fails outright', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('fetch failed'); })); + await expect(svc.refresh(TENANT)).rejects.toThrow(); + expect(connectionRow()).toBeTruthy(); + vi.unstubAllGlobals(); + }); + + it('DELETES the connection on 400 invalid_grant — the token is genuinely dead', async () => { + vi.stubGlobal('fetch', respond(400, { error: 'invalid_grant' })); + await expect(svc.refresh(TENANT)).rejects.toThrow(/reconnect/i); + expect(connectionRow()).toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it('DELETES the connection on 401', async () => { + vi.stubGlobal('fetch', respond(401)); + await expect(svc.refresh(TENANT)).rejects.toThrow(/reconnect/i); + expect(connectionRow()).toBeUndefined(); + vi.unstubAllGlobals(); + }); +}); From f5a5890aab40db98c6218b3cd0b0f83421074035 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 11:22:13 +0800 Subject: [PATCH 29/77] fix(tests): let the demand-signal doc parser survive a Windows checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec reads its SQL out of docs/developers/multilingual-demand-signal.md by matching a fence followed immediately by \n. A Windows checkout has CRLF there, so the pattern matched nothing, every block was silently missed, and the failure surfaced as "doc has no -- Query D block" — pointing at the document rather than at the parser that had read none of it. CI is Linux, so this passed there while being unrunnable on the machine the document is edited on. Also two glossary violations in the new metrics translations, which only the full lint chain can see. "tus" is ruled out for second-person possessive. And English "Pay" now has a declared divergence: it is the verb on the client checkout (Pagar) and a column header naming what an inspector earned (Pago). Pagar as a header reads as an instruction to pay someone. The header is deliberately "Pay" rather than "Cost" because the inspector reads this column, and calling their earnings a cost states the company's view of them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Bc5q8eunHDbkqDHtYvEkv --- docs/developers/i18n-glossary.md | 2 ++ messages/es-419/metrics.json | 2 +- tests/unit/contacts/demand-signal-queries.spec.ts | 7 ++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/developers/i18n-glossary.md b/docs/developers/i18n-glossary.md index 59cbd196a..f4eb07316 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. +- `checkout_step_pay`, `metrics_col_pay` — English "Pay" is a verb in one place and a noun in the other. On the client checkout it is the step that takes the payment, an instruction to the reader (*Pagar*). In the per-inspector metrics table it is a column header naming the money an inspector earned on the work (*Pago*) — the amount, not an action. *Pagar* as a column header reads as a command to pay the inspector, and *Pago* on the checkout button stops asking the client to do anything. The header is deliberately "Pay" and not "Cost": the inspector sees this column, and naming their earnings a cost is the company's view of them, not theirs. + - `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.)* diff --git a/messages/es-419/metrics.json b/messages/es-419/metrics.json index d45a43d8c..1fea2929d 100644 --- a/messages/es-419/metrics.json +++ b/messages/es-419/metrics.json @@ -54,5 +54,5 @@ "metrics_turnaround_basis": "El tiempo de entrega se mide desde que termina el trabajo en campo hasta que se publica el informe, y se atribuye al inspector líder.", "metrics_turnaround_no_basis": "No se han registrado horas de finalización en campo, así que el tiempo de entrega no tiene punto de partida.", "metrics_referrer_source_tag": "Origen", - "metrics_self_scope_notice": "Estas son tus propias cifras. Los totales de la empresa los ven los propietarios y gerentes." + "metrics_self_scope_notice": "Estas son sus propias cifras. Los totales de la empresa los ven los propietarios y gerentes." } diff --git a/tests/unit/contacts/demand-signal-queries.spec.ts b/tests/unit/contacts/demand-signal-queries.spec.ts index a73b496ef..a9030f124 100644 --- a/tests/unit/contacts/demand-signal-queries.spec.ts +++ b/tests/unit/contacts/demand-signal-queries.spec.ts @@ -39,7 +39,12 @@ const DOC = path.resolve( function sqlBlocks(): Map { const markdown = readFileSync(DOC, 'utf8'); const blocks = new Map(); - for (const match of markdown.matchAll(/```sql\n([\s\S]*?)```/g)) { + // `\r?` because a Windows checkout has CRLF here: without it the fence + // never matches, every block is silently missed, and the failure surfaces + // as "doc has no -- Query D block" rather than "the parser read nothing". + // CI is Linux so this passed there while being unrunnable on the machine + // the doc is edited on. + for (const match of markdown.matchAll(/```sql\r?\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(); From 40f13a7186cef16f2d3b39cd1393874a7b6e3161 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 11:54:23 +0800 Subject: [PATCH 30/77] fix(gate): the idempotency gate could not see inline route registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/check-idempotency-coverage.mjs` resolved registrations with `.openapi(\s*(\w+))` and looked the captured name up among `const X = createRoute(...)` declarations. For the INLINE form — `.openapi(createRoute({ method, path, ... }))` — that captured the literal word `createRoute`, found no declaration, and dropped the route in silence. The blindness was measurable the whole time and the gate printed it every run: the baseline recorded `declaredMutating: 316` against `resolvedMutating: 306`, and that difference of 10 was the exact number of mutating routes the parser could not place. Two numbers that should be equal sat side by side and the difference was never read as a defect. Four money-moving pay-split routes were written inline and the gate reported the same resolved count as before they existed. Three changes: - The registration pass tells a NAMED const from a CALL by the trailing `(`, not by the callee name, and reads method+path out of an inline route object exactly as it does from a named const's body. Matching on the name `createRoute` would go blind again the day someone wraps it. - A router chain now runs to the next TOP-LEVEL STATEMENT, not to the next column-zero line. Several modules close a registration with a column-zero `}, { scopes: [...] })), async (c) => {`, and the old rule cut those chains off after their first route — five routes in marketplace.ts, two in inspection-sync.ts. `const X = withMcpMetadata(createRoute(...))` is also recognised now (the inspection-prefs.ts PATCH was invisible for it). - THE STRUCTURAL FIX: `declaredMutating` and `resolvedMutating` are now keyed by declaration site (`:`) rather than being two incomparable tallies, they must be EQUAL, and the gate FAILS when they are not — naming each unresolved site with its source line. A route form the parser does not understand now turns the gate red instead of quietly shrinking coverage. The declared side stays a raw, parser-independent scan; the resolved count had to rise to meet it, not the other way. 316 declared / 306 resolved -> 316 / 316, gap 0. 333 distinct mutating paths, up from 306. 27 routes became visible; the three money-moving `/api/inspections/{id}/services` verbs are VERIFIED by a new replay spec (the DELETE has a real hazard: unguarded, the retry turns a successful removal into a 404 the operator reads as "the line is still there"), `POST /api/services/discount/validate` is by-design (it writes nothing — no uses_count increment, no reservation), and the remaining 23 enter the pending ratchet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- scripts/check-idempotency-coverage.mjs | 242 ++++++++++++++---- scripts/idempotency-baseline.json | 30 ++- .../inspection-service-lines-replay.spec.ts | 205 +++++++++++++++ 3 files changed, 430 insertions(+), 47 deletions(-) create mode 100644 tests/unit/idempotency/inspection-service-lines-replay.spec.ts diff --git a/scripts/check-idempotency-coverage.mjs b/scripts/check-idempotency-coverage.mjs index 6d5012daf..c11d79856 100644 --- a/scripts/check-idempotency-coverage.mjs +++ b/scripts/check-idempotency-coverage.mjs @@ -51,13 +51,23 @@ * but only when the call is a bare top-level `registerX(someRouter);` * statement. A helper called conditionally, or with a router built inline, * is invisible. - * - A router's chain body is delimited by indentation: it runs from the - * `const X = createApiRouter()` line to the next line that starts at column - * zero. A handler holding a template literal with column-zero content would - * truncate it — which is what the `coverage` counters in the baseline are - * for: `declaredMutating` counts the raw mutating declarations in the - * source, `resolvedMutating` counts the ones this walk actually resolved to - * a full path, and a DROP in the resolved count fails the gate. + * - A router's chain body runs from the `const X = createApiRouter()` line to + * the next TOP-LEVEL STATEMENT (`const`/`export`/`function`/…), not to the + * next line at column zero: several modules close a registration with a + * column-zero `}, { scopes: [...] })), async (c) => {`, and the old + * column-zero rule cut those chains off after their first route. + * + * THE COUNTERS ARE THE GATE, not a footnote. `declaredMutating` is a raw, + * parser-independent scan of the source for mutating route declarations, keyed + * by `:`; `resolvedMutating` is how many of those exact sites the + * walk placed on a full path. **They must be equal, and the gate fails when + * they are not.** For months they sat side by side in the baseline reading 316 + * and 306, and that difference of 10 was the precise number of routes the + * parser could not see — inline `.openapi(createRoute({...}))` registrations it + * silently dropped, including three money-moving `/services` routes that + * appeared in no list at all. A counter that reports a hole without failing is + * a note. A route form this parser does not understand must turn the gate RED, + * not quietly shrink its coverage. * * Usage: * node scripts/check-idempotency-coverage.mjs # verify (exit 1 on drift) @@ -101,6 +111,39 @@ function walkTs(dir, prefix = '') { return out; } +function countNewlines(s) { + return (s.match(/\n/g) ?? []).length; +} + +/** + * A DECLARATION SITE: `:<1-based line>` of the line + * that declares a mutating route — the `method: 'post'` of a createRoute object, + * or the `.post('/path'` of an inline verb registration. It is the join key + * between the two counters: the raw source tally (below, deliberately parser- + * independent) and the set of sites the walk actually resolved to a full path. + * Line-keyed rather than name-keyed because an INLINE route has no name. + */ +const DECL_METHOD_RE = /\bmethod:\s*'(?:post|put|patch|delete)'/; +const DECL_VERB_RE = /\.(?:post|put|patch|delete)\(\s*'\//; + +/** + * The start of a new top-level statement — the terminator for a declaration's + * block. NOT "any line starting at column zero": several route modules close an + * `.openapi(createRoute(withMcpMetadata({…}` registration with a column-zero + * `}, { scopes: […] })), async (c) => {` and a column-zero `})`, so the naive + * rule cut the chain off after its FIRST route and every registration below was + * invisible — five in marketplace.ts, two in inspection-sync.ts. A closing + * brace continues the expression; a `const`/`export`/`function` starts a new one. + */ +const TOP_LEVEL_RE = /^(?:export|const|let|var|function|async\s+function|class|type|interface|enum|declare|import)\b/; + +/** Lines `i..end` of a declaration, as one string; `end` is the last line before the next top-level statement. */ +function blockFrom(lines, i) { + let last = i; + for (let j = i + 1; j < lines.length && !TOP_LEVEL_RE.test(lines[j]); j++) last = j; + return lines.slice(i, last + 1).join('\n'); +} + function joinPaths(prefix, path) { return (prefix + path).replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1'); } @@ -156,14 +199,16 @@ function parseRouteConsts(src) { const lines = src.split('\n'); const out = new Map(); for (let i = 0; i < lines.length; i++) { - const decl = lines[i].match(/^(?:export )?const (\w+) = createRoute\(/); + // `const X = createRoute(…)` and `const X = withMcpMetadata(createRoute(…))` + // are the same declaration wearing different wrappers; inspection-prefs.ts + // uses the second and its PATCH route was invisible for it. + const decl = lines[i].match(/^(?:export )?const (\w+)(?::[^=]+)? = (?:\w+\()*createRoute\(/); if (!decl) continue; - let body = lines[i]; - for (let j = i + 1; j < lines.length && !/^\S/.test(lines[j]); j++) body += `\n${lines[j]}`; - const method = body.match(/\bmethod:\s*'(\w+)'/)?.[1]; + const body = blockFrom(lines, i); + const m = body.match(/\bmethod:\s*'(\w+)'/); const path = body.match(/\bpath:\s*'([^']*)'/)?.[1]; - if (!method || !path) continue; - out.set(decl[1], { method, path }); + if (!m || path === undefined) continue; + out.set(decl[1], { method: m[1], path, line: i + 1 + countNewlines(body.slice(0, m.index)) }); } return out; } @@ -183,28 +228,64 @@ function parseRouters(src) { return routers.get(name); }; - const harvest = (target, body) => { - for (const m of body.matchAll(/\.openapi\(\s*(\w+)/g)) target.routeConsts.push(m[1]); + /** + * `baseLine` is the 1-based file line of `body`'s first line, so every route + * harvested out of a substring still carries the declaration site it came + * from. Without it an inline route could be seen but not attributed, and the + * declared-vs-resolved reconciliation below would have nothing to join on. + */ + const harvest = (target, body, baseLine) => { + // Two registration shapes, told apart by what follows the ident: + // `.openapi(NAME, handler)` → a named const, resolved later + // via the const/import tables. + // `.openapi(FACTORY({ … }), handler)` → the route object is written + // INLINE; there is no const to + // look up, so method+path are + // read from the literal here. + // The discriminator is the trailing `(` — a CALL — not the callee's + // name. Matching on the name `createRoute` would go blind again the day + // someone wraps it, and the old `.openapi(\s*(\w+)` captured the literal + // word `createRoute` as if it were a const, found no declaration, and + // dropped the route in silence. + const opens = [...body.matchAll(/\.openapi\(\s*(\w+)\s*(\()?/g)]; + for (let k = 0; k < opens.length; k++) { + const m = opens[k]; + if (!m[2]) { + target.routeConsts.push(m[1]); + continue; + } + // The inline object runs until the next registration in the chain. + const stop = k + 1 < opens.length ? opens[k + 1].index : body.length; + const chunk = body.slice(m.index, stop); + const method = chunk.match(/\bmethod:\s*'(\w+)'/); + const path = chunk.match(/\bpath:\s*'([^']*)'/)?.[1]; + if (!method || path === undefined) continue; + target.chained.push({ + method: method[1], + path, + line: baseLine + countNewlines(body.slice(0, m.index + method.index)), + }); + } for (const m of body.matchAll(/\.route\(\s*'([^']*)'\s*,\s*(\w+)\s*\)/g)) { target.mounts.push({ prefix: m[1], ident: m[2] }); } // The leading `/` separates a route registration from an unrelated // method call (`ALLOWED.delete('x')`, `map.get('k')`). for (const m of body.matchAll(/\.(post|put|patch|delete|get)\(\s*'(\/[^']*)'/g)) { - target.chained.push({ method: m[1], path: m[2] }); + target.chained.push({ + method: m[1], + path: m[2], + line: baseLine + countNewlines(body.slice(0, m.index)), + }); } }; - const bodyFrom = (i) => { - let body = lines[i]; - for (let j = i + 1; j < lines.length && !/^\S/.test(lines[j]); j++) body += `\n${lines[j]}`; - return body; - }; + const bodyFrom = (i) => blockFrom(lines, i); for (let i = 0; i < lines.length; i++) { const decl = lines[i].match(/^(?:export )?const (\w+)(?::[^=]+)? = (?:createApiRouter\(|new (?:OpenAPIHono|Hono))/); if (!decl) continue; - harvest(ensure(decl[1]), bodyFrom(i)); + harvest(ensure(decl[1]), bodyFrom(i), i + 1); } // Alias chains: `const base = createApiRouter();` followed by @@ -220,7 +301,7 @@ function parseRouters(src) { target.routeConsts.push(...base.routeConsts); target.chained.push(...base.chained); target.mounts.push(...base.mounts); - harvest(target, bodyFrom(i)); + harvest(target, bodyFrom(i), i + 1); } } @@ -228,7 +309,9 @@ function parseRouters(src) { // `clientMessageRoutes.post('/inspections/:id/messages', …)`. for (const name of [...routers.keys()]) { const stmt = new RegExp(`^${name}\\s*\\n?\\s*\\.[\\s\\S]*?(?=\\n\\S|$)`, 'gm'); - for (const m of src.matchAll(stmt)) harvest(routers.get(name), m[0]); + for (const m of src.matchAll(stmt)) { + harvest(routers.get(name), m[0], countNewlines(src.slice(0, m.index)) + 1); + } } return routers; @@ -245,10 +328,15 @@ function parseRouterHelpers(src) { for (let i = 0; i < lines.length; i++) { const decl = lines[i].match(/^export function (\w+)\(\s*router\b/); if (!decl) continue; - let body = ''; - for (let j = i + 1; j < lines.length && !/^\S/.test(lines[j]); j++) body += `\n${lines[j]}`; + const body = `\n${blockFrom(lines, i + 1)}`; + // `body` starts with a leading "\n", so its first character sits on the + // helper's declaration line: baseLine is that line, 1-based. const chained = [...body.matchAll(/\brouter\.(post|put|patch|delete|get)\(\s*'(\/[^']*)'/g)] - .map(m => ({ method: m[1], path: m[2] })); + .map(m => ({ + method: m[1], + path: m[2], + line: i + 1 + countNewlines(body.slice(0, m.index)), + })); out.set(decl[1], chained); } return out; @@ -267,16 +355,21 @@ function parseDefaultExport(src) { function collect() { const files = walkTs(API_DIR); const parsed = new Map(); - let declaredMutating = 0; + /** declaration site -> the source line that declares it. */ + const declSites = new Map(); for (const f of files) { const src = stripComments(read(join(API_DIR, f))); // Declared-side tally: every mutating createRoute + every inline - // mutating verb with a quoted path. The resolved count is ratcheted - // against this, because a parser that quietly sees less than the - // surface reports OK either way. - declaredMutating += (src.match(/\bmethod:\s*'(?:post|put|patch|delete)'/g) ?? []).length; - declaredMutating += (src.match(/\.(?:post|put|patch|delete)\(\s*'\//g) ?? []).length; + // mutating verb with a quoted path, recorded BY SITE rather than as a + // bare count. This scan is deliberately independent of the parser — + // a parser that quietly sees less than the surface reports OK either + // way, so the surface has to be measured without it. Every site here + // must come back resolved; see the reconciliation in main(). + src.split('\n').forEach((text, i) => { + if (!DECL_METHOD_RE.test(text) && !DECL_VERB_RE.test(text)) return; + declSites.set(`${f}:${i + 1}`, text.trim()); + }); parsed.set(f, { imports: parseImports(src, f), consts: parseRouteConsts(src), @@ -292,8 +385,15 @@ function collect() { const routes = []; const seen = new Set(); - const push = (method, fullPath, file) => { + /** Declaration sites the walk placed on a full path. */ + const resolvedSites = new Set(); + const push = (method, fullPath, file, site) => { if (!MUTATING.has(method)) return; + // Marked resolved even when the path is a duplicate: a dual-mounted + // router (coreAuthRoutes at both `/api/auth` and `/`) resolves ONE + // declaration to two paths, and a router reached twice resolves it to + // the same path twice. Either way the declaration was placed. + resolvedSites.add(site); const route = `${method.toUpperCase()} ${fullPath}`; if (seen.has(route)) return; seen.add(route); @@ -315,13 +415,13 @@ function collect() { // (several files declare `deleteRoute`), so the import table is // consulted before any global lookup. const imported = entry.imports.get(constName); - const rc = entry.consts.get(constName) - ?? (imported ? parsed.get(imported.file)?.consts.get(imported.exported) : undefined); + const own = entry.consts.get(constName); + const rc = own ?? (imported ? parsed.get(imported.file)?.consts.get(imported.exported) : undefined); if (!rc) continue; - push(rc.method, joinPaths(prefix, rc.path), file); + push(rc.method, joinPaths(prefix, rc.path), file, `${own ? file : imported.file}:${rc.line}`); } - for (const { method, path } of router.chained) { - push(method, joinPaths(prefix, path), file); + for (const { method, path, line } of router.chained) { + push(method, joinPaths(prefix, path), file, `${file}:${line}`); } // Helper-registered verbs land on the router the helper was called with. for (const { fn, ident } of entry.helperCalls) { @@ -330,8 +430,9 @@ function collect() { const source = imported ? parsed.get(imported.file) : entry; const chained = source?.helpers.get(imported ? imported.exported : fn); if (!chained) continue; - for (const { method, path } of chained) { - push(method, joinPaths(prefix, path), imported ? imported.file : file); + const declFile = imported ? imported.file : file; + for (const { method, path, line } of chained) { + push(method, joinPaths(prefix, path), declFile, `${declFile}:${line}`); } } for (const { prefix: sub, ident } of router.mounts) { @@ -364,7 +465,18 @@ function collect() { visit(imported.file, name, prefix, new Set()); } - return Object.assign(routes, { declaredMutating }); + // Reconciliation. `resolvedSites` is a subset of `declSites` by + // construction (the parser reads method/path with the same patterns the raw + // scan uses), so the difference is exactly the surface the walk cannot see. + const unresolved = [...declSites.keys()] + .filter(site => !resolvedSites.has(site)) + .map(site => ({ site, text: declSites.get(site) })); + + return Object.assign(routes, { + declaredMutating: declSites.size, + resolvedMutating: declSites.size - unresolved.length, + unresolved, + }); } /** Route paths named as string literals in the replay-evidence specs. */ @@ -436,9 +548,40 @@ function main() { }; const pendingNow = routes.filter(r => classify(r) === 'pending'); - const coverage = { declaredMutating: routes.declaredMutating, resolvedMutating: routes.length }; + const coverage = { + declaredMutating: routes.declaredMutating, + resolvedMutating: routes.resolvedMutating, + }; + const unresolvedReport = () => { + console.error( + `Idempotency-coverage gate — ${routes.unresolved.length} mutating routes were ` + + 'DECLARED but could not be resolved to a path:' + ); + for (const { site, text } of routes.unresolved) { + console.error(` x server/api/${site}`); + console.error(` ${text}`); + } + console.error(''); + console.error('The parser does not understand how these are registered, so they appear in'); + console.error('NO list in the baseline — not pending, not verified, not by-design. Their'); + console.error('retry safety is unaudited and this gate is quietly smaller than it claims.'); + console.error(''); + console.error('Do one of:'); + console.error(' - register them in a shape the walk follows: a router mounted (directly or'); + console.error(' transitively) from server/index.ts, chained with `.openapi(routeConst)`,'); + console.error(" `.openapi(createRoute({...}))`, or `.verb('/path', ...)`;"); + console.error(' - teach parseRouters() / parseRouteConsts() the new registration shape;'); + console.error(' - if the route is genuinely unreachable from server/index.ts, say so with a'); + console.error(' reason rather than leaving it silent.'); + console.error('Do NOT close the gap by lowering the declared count — that is the same'); + console.error('blindness written down as a number.'); + }; if (update) { + // --update regenerates `pending`; it cannot launder an unresolved + // declaration, because the verify path recomputes the gap from source + // every run rather than diffing it against the baseline. + if (routes.unresolved.length > 0) unresolvedReport(); writeFileSync( BASELINE_PATH, JSON.stringify({ @@ -461,7 +604,7 @@ function main() { }, null, 4) + '\n', 'utf8' ); - console.log(`Updated ${BASELINE_PATH}: ${pendingNow.length} pending routes (${routes.length} mutating routes resolved of ${routes.declaredMutating} declared).`); + console.log(`Updated ${BASELINE_PATH}: ${pendingNow.length} pending routes (${routes.length} distinct mutating paths from ${coverage.resolvedMutating} of ${coverage.declaredMutating} declarations).`); return; } @@ -473,6 +616,17 @@ function main() { const pendingBaseline = prior.pending ?? []; let failed = false; + // THE structural check. These two counters sat side by side in the baseline + // for months, 316 against 306, and the difference was the exact number of + // mutating routes the parser could not place — inline + // `.openapi(createRoute({...}))` registrations it dropped in silence. A + // counter that reports a hole without failing is a note, not a gate. + if (routes.unresolved.length > 0) { + failed = true; + unresolvedReport(); + console.error(''); + } + if (priorCoverage && coverage.resolvedMutating < priorCoverage.resolvedMutating) { failed = true; console.error('Idempotency-coverage gate — route resolution DROPPED:'); diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index 131958299..d772fad8c 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -13,7 +13,7 @@ ], "coverage": { "declaredMutating": 316, - "resolvedMutating": 306 + "resolvedMutating": 316 }, "knownUnreachable": {}, "uncoveredByDesign": { @@ -30,7 +30,8 @@ "POST /reset-password": "Same handler as POST /api/auth/reset-password (dual mount).", "POST /api/auth/setup": "One-time first-run bootstrap gated on the SETUP_CODE secret; there is no tenant yet to key on.", "POST /setup": "Same handler as POST /api/auth/setup (dual mount).", - "POST /api/__test__/calendar-connection": "E2E-only hook, fail-closed behind E2E_EMAIL_SINK (404 in every real deploy) — not a production surface." + "POST /api/__test__/calendar-connection": "E2E-only hook, fail-closed behind E2E_EMAIL_SINK (404 in every real deploy) — not a production surface.", + "POST /api/services/discount/validate": "A POST that writes nothing: validateDiscountCode() only reads discount_codes and computes an amount — it does not increment uses_count or reserve the code — so a replay recomputes the same answer over unchanged state." }, "pending": [ "DELETE /api/admin/agreements/{id}", @@ -56,10 +57,12 @@ "DELETE /api/inspections/{id}", "DELETE /api/inspections/{id}/compliance/signoff/{role}", "DELETE /api/inspections/{id}/cost-items/{itemId}", + "DELETE /api/inspections/{id}/items/{itemId}/photos/{photoIndex}", "DELETE /api/inspections/{id}/items/{itemId}/tags/{tagId}", "DELETE /api/inspections/{id}/media/pool/{poolId}", "DELETE /api/inspections/{id}/media/video/{streamUid}", "DELETE /api/inspections/{id}/people/{personId}", + "DELETE /api/inspections/{id}/reports/{reportId}", "DELETE /api/inspections/{id}/units/{unitId}", "DELETE /api/invoices/{id}", "DELETE /api/mcp/grants/:id", @@ -71,6 +74,8 @@ "DELETE /api/rating-systems/{id}", "DELETE /api/recommendations/{id}", "DELETE /api/role-profiles/{id}", + "DELETE /api/services/discount-codes/{id}", + "DELETE /api/services/{id}", "DELETE /api/tags/{id}", "DELETE /api/team/invites/{token}", "DELETE /api/team/members/{id}", @@ -99,6 +104,7 @@ "PATCH /api/public/repair-builder/{tenant}/{id}/lists/{rrId}", "PATCH /api/public/repair-builder/{tenant}/{id}/lists/{rrId}/items/{itemId}", "PATCH /api/team/members/{id}", + "PATCH /api/tenant/inspection-prefs", "PATCH /profile", "POST /2fa/disable", "POST /2fa/recovery-codes/regenerate", @@ -181,15 +187,19 @@ "POST /api/inspections/templates", "POST /api/inspections/templates/import-spectora", "POST /api/inspections/wizard", + "POST /api/inspections/{id}/agent-token", + "POST /api/inspections/{id}/cancel", "POST /api/inspections/{id}/clone", "POST /api/inspections/{id}/complete", "POST /api/inspections/{id}/compliance/doc-review/seed", "POST /api/inspections/{id}/compliance/psq/status", "POST /api/inspections/{id}/compliance/signoff", "POST /api/inspections/{id}/concierge/approve", + "POST /api/inspections/{id}/confirm", "POST /api/inspections/{id}/cost-items", "POST /api/inspections/{id}/cover", "POST /api/inspections/{id}/export/word", + "POST /api/inspections/{id}/inspector-signature", "POST /api/inspections/{id}/items/{itemId}/photos/reorder", "POST /api/inspections/{id}/items/{itemId}/photos/{photoIndex}/annotation", "POST /api/inspections/{id}/items/{itemId}/photos/{photoIndex}/crop", @@ -213,8 +223,11 @@ "POST /api/inspections/{id}/results/batch", "POST /api/inspections/{id}/return", "POST /api/inspections/{id}/send-report-pdf", + "POST /api/inspections/{id}/share-agent", "POST /api/inspections/{id}/submit", "POST /api/inspections/{id}/switch-rating-system", + "POST /api/inspections/{id}/template/upgrade", + "POST /api/inspections/{id}/uncancel", "POST /api/inspections/{id}/unit-mode", "POST /api/inspections/{id}/units", "POST /api/inspections/{id}/units/bulk", @@ -263,9 +276,16 @@ "POST /api/recommendations", "POST /api/recommendations/seed-defaults", "POST /api/role-profiles", + "POST /api/services", + "POST /api/services/discount-codes", "POST /api/tags", "POST /api/team/invite", "POST /api/team/invites/{token}/resend", + "POST /api/templates/marketplace/libraries/{id}/import", + "POST /api/templates/marketplace/libraries/{id}/update", + "POST /api/templates/marketplace/libraries/{libraryId}/imports/replace", + "POST /api/templates/marketplace/{id}/import", + "POST /api/templates/marketplace/{id}/update", "POST /api/templates/{oldId}/migrate-to/{newId}", "POST /api/tenant/inspection-prefs/report-link-expiry", "POST /api/users/me/onboarding", @@ -312,6 +332,10 @@ "PUT /api/rating-systems/{id}", "PUT /api/recommendations/{id}", "PUT /api/role-profiles/{id}", - "PUT /api/tags/{id}" + "PUT /api/services/discount-codes/{id}", + "PUT /api/services/{id}", + "PUT /api/services/{id}/inspectors", + "PUT /api/tags/{id}", + "PUT /api/team/defaults" ] } diff --git a/tests/unit/idempotency/inspection-service-lines-replay.spec.ts b/tests/unit/idempotency/inspection-service-lines-replay.spec.ts new file mode 100644 index 000000000..62d10dd9f --- /dev/null +++ b/tests/unit/idempotency/inspection-service-lines-replay.spec.ts @@ -0,0 +1,205 @@ +/** + * Retry safety for the inspection service-line write surface (IA-87). + * + * These three routes decide what the client is BILLED, and until the + * idempotency gate learned to read inline `.openapi(createRoute({…}))` + * registrations they were in no list at all — not pending, not verified, not + * by-design. They were invisible, which is worse than uncovered. + * + * - POST /{id}/services is the one that moves money forward. The service + * layer already treats a re-add of the same catalog service as a no-op that + * returns the existing line, so the row count is safe on its own; what the + * guard adds is that the RESPONSE is the original one, replay-flagged, + * rather than a second 201 the caller cannot tell apart from a real add. + * - PATCH /{id}/services/{lineId} writes an ABSOLUTE override, not a delta, + * so it survives a replay by construction. Asserted as characterization and + * labelled as such: the day someone turns it into a delta, this is the + * assertion that should be rewritten loudly rather than deleted quietly. + * - DELETE /{id}/services/{lineId} is the one with a real hazard. Removal is + * a soft delete and the second call throws NotFound, so UNGUARDED a retry + * turns a successful removal into a 404 the operator reads as "the line is + * still there". Guarded, the replay returns the original success. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { and, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import * as schema from '../../../server/lib/db/schema'; +import { tenants, users, services, inspections, inspectionServices } from '../../../server/lib/db/schema'; +import { ServiceService } from '../../../server/services/service.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; +import { createTestDb, setupSchema } from '../db'; + +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 T = 't1'; +const INSP = 'i1'; +const SVC_SEWER = 'svc-sewer'; +const LINE = 'line-home'; +const MGR = 'mgr'; +const FAKE_ENV = { DB: {} } as HonoConfig['Bindings']; +const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; + +let db: DrizzleD1Database; + +function buildApp() { + (mockDrizzle as unknown 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', T); + c.set('userRole', 'manager'); + c.set('user', { sub: MGR, role: 'manager', tenantId: T }); + c.set('sdb', { getById: async () => ({ permissionOverrides: null }) } as unknown as HonoConfig['Variables']['sdb']); + c.set('services', { + service: new ServiceService({} as never), + } as unknown as HonoConfig['Variables']['services']); + await next(); + }); + // The mounted shape: tenant on the context first, then the guard. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/inspections', inspectionsRoutes); + return app; +} + +function send(method: string, path: string, key: string | null, body?: unknown) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().fetch( + new Request(`https://acme.example.com${path}`, { + method, headers, body: body === undefined ? undefined : JSON.stringify(body), + }), + FAKE_ENV as never, CTX, + ); +} + +const lines = () => db.select().from(inspectionServices) + .where(and(eq(inspectionServices.tenantId, T), eq(inspectionServices.inspectionId, INSP))).all(); + +beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + db = drizzle(fixture.sqlite, { schema }) as unknown as DrizzleD1Database; + const now = new Date(); + + await db.insert(tenants).values({ + id: T, name: 'Acme', slug: 'acme', tier: 'free', status: 'active', + maxUsers: 5, deploymentMode: 'shared', createdAt: now, + }).run(); + await db.insert(users).values({ + id: MGR, tenantId: T, email: 'mgr@acme.test', passwordHash: 'x', + name: 'Mgr', role: 'manager', createdAt: now, + }).run(); + await db.insert(services).values([ + { id: 'svc-home', tenantId: T, name: 'Home Inspection', price: 50000, createdAt: now }, + { id: SVC_SEWER, tenantId: T, name: 'Sewer Scope', price: 22500, createdAt: now }, + ]).run(); + await db.insert(inspections).values({ + id: INSP, tenantId: T, propertyAddress: '1 Oak St', date: '2026-08-01', createdAt: now, + }).run(); + await db.insert(inspectionServices).values({ + id: LINE, tenantId: T, inspectionId: INSP, serviceId: 'svc-home', + nameSnapshot: 'Home Inspection', priceSnapshot: 50000, + }).run(); +}); + +describe("POST '/api/inspections/{id}/services' — a replay must not bill a second line", () => { + const add = (key: string | null) => send( + 'POST', `/api/inspections/${INSP}/services`, key, { serviceId: SVC_SEWER }, + ); + + it('adds ONE line across two posts under one key', async () => { + const first = await add('add-1'); + const second = await add('add-1'); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(await lines()).toHaveLength(2); + }); + + it('replays the original response, flagged', async () => { + const first = await add('add-1'); + const second = await add('add-1'); + + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + }); + + it('a DELIBERATE second add under a fresh key still resolves to one line', async () => { + // Not the guard: the service layer treats a re-add of the same catalog + // service as a no-op returning the existing line. Stated so nobody + // reads the previous test as the only thing holding the count at 2. + await add('add-1'); + await add('add-2'); + expect(await lines()).toHaveLength(2); + }); +}); + +describe("PATCH '/api/inspections/{id}/services/{lineId}' — repricing one line", () => { + it('CHARACTERIZATION: an absolute override survives a replay on its own', async () => { + // Not evidence for the guard. The route writes a value, not a delta. + const path = `/api/inspections/${INSP}/services/${LINE}`; + await send('PATCH', path, 'price-1', { priceOverrideCents: 42000 }); + await send('PATCH', path, 'price-1', { priceOverrideCents: 42000 }); + + const rows = (await lines()).filter(l => l.id === LINE); + expect(rows).toHaveLength(1); + expect(rows[0].priceOverride).toBe(42000); + }); + + it('replays the original response, flagged', async () => { + const path = `/api/inspections/${INSP}/services/${LINE}`; + const first = await send('PATCH', path, 'price-1', { priceOverrideCents: 42000 }); + const second = await send('PATCH', path, 'price-1', { priceOverrideCents: 42000 }); + + expect(first.status).toBe(200); + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + }); +}); + +describe("DELETE '/api/inspections/{id}/services/{lineId}' — a replay must not report the line as still there", () => { + const remove = (key: string | null) => send( + 'DELETE', `/api/inspections/${INSP}/services/${LINE}`, key, + ); + + it('the second call under one key returns the original success, not a 404', async () => { + const first = await remove('rm-1'); + const second = await remove('rm-1'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + }); + + it('the line is soft-deleted exactly once', async () => { + await remove('rm-1'); + await remove('rm-1'); + + const rows = (await lines()).filter(l => l.id === LINE); + expect(rows).toHaveLength(1); + expect(rows[0].active).toBe(false); + }); + + it('UNGUARDED, the retry turns a successful removal into a 404 — the hazard, stated', async () => { + // No key: the guard cannot key on anything, and the operator's retry + // reads as "that line is not there", which is indistinguishable from + // "your removal did not take". + expect((await remove(null)).status).toBe(200); + expect((await remove(null)).status).toBe(404); + }); +}); From f5f5354640d03b3eabbbe14bfad0d4ad2b59d69e Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 12:22:06 +0800 Subject: [PATCH 31/77] feat(pay-splits): give service pay rules a write face (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks 1-4 shipped the schema, populateSplits, the split API and the metrics, but nothing could create a `service_pay_rules` row — no route, no service writer, no UI — so populateSplits read an empty rule set and produced nothing. The feature was a machine with no switch. This is the switch, server side. THE UNIT CONTRACT. `service_pay_rules.value` is basis points for a percentage and integer cents for `fixed`: one column, two units, decided by a sibling field. That is defensible in the schema and indefensible on the wire, where 60 meaning 0.6% when the caller meant 60% is a hundredfold money error no type catches. So `value` never appears on the wire, in either direction. Each variant names its own unit — percentBps, amountCents — and the request objects are STRICT, so a payload written against the column is a 400 naming the unexpected key rather than a row stored a hundred times too small. Converting a human percent server-side was the alternative and is worse: it puts two representations of the same number in the system and moves the off-by-100 from the wire, where a schema catches it, into arithmetic, where nothing does. A discriminated union on `type`, not one loose object, because deductionCents is meaningful only for percent_after_deduction — the deduction comes off the top BEFORE the percentage, which is why that type exists at all. A `percent` rule carrying one is ambiguous and is refused; a loose object would have dropped it in silence, and the spec proving that is red without `.strict()`. The two partial unique indexes surface as a 409 with an actionable message. Unguarded the same request is a 500 reading `UNIQUE constraint failed: service_pay_rules.tenant_id, …`, which is what the spec sees when the pre-check and its race backstop are removed. percentBps is capped at 10000. That is not the split-ceiling check the populate path already owns: above 100% the gross exceeds the line price for EVERY roster size, because the per-inspector divisor cancels, so no such rule could ever pay out. A 100% rule with two inspectors pays 50% each and stays legal. Routes sit at /api/services/{id}/pay-rules, mirroring the /{id}/inspectors pair — same arity, same param name, registered before the bare /{id} routes. Writes are owner/manager like every other write on this router: deciding what a person is paid is company configuration. No new capability; `financial` stays the only line. All three mutating routes land VERIFIED in the idempotency gate rather than pending. The hazard is not a duplicate row (the unique index stops that) but what the retry SEES: unguarded, the second POST is a 409 indistinguishable from a colleague having added a rule in another tab, and the retried DELETE 404s on the caller's own success. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- server/api/services.ts | 95 ++++++ server/lib/mcp/openapi-snapshot.json | 170 ++++++++++ server/lib/validations/service.schema.ts | 96 ++++++ server/services/service.service.ts | 20 ++ server/services/service/pay-rules.ts | 214 ++++++++++++ .../service-pay-rule-replay.spec.ts | 178 ++++++++++ tests/unit/services/pay-rules.spec.ts | 319 ++++++++++++++++++ 7 files changed, 1092 insertions(+) create mode 100644 server/services/service/pay-rules.ts create mode 100644 tests/unit/idempotency/service-pay-rule-replay.spec.ts create mode 100644 tests/unit/services/pay-rules.spec.ts diff --git a/server/api/services.ts b/server/api/services.ts index 105952b1b..49232ae49 100644 --- a/server/api/services.ts +++ b/server/api/services.ts @@ -9,6 +9,7 @@ import { ServiceListResponseSchema, CreateDiscountCodeSchema, UpdateDiscountCodeSchema, ValidateDiscountSchema, ValidateDiscountResponseSchema, ServiceInspectorListResponseSchema, SetServiceInspectorsSchema, SetServiceInspectorsResponseSchema, + CreatePayRuleSchema, UpdatePayRuleSchema, PayRuleResponseSchema, PayRuleListResponseSchema, } from '../lib/validations/service.schema'; import { createApiResponseSchema, SuccessResponseSchema } from '../lib/validations/shared.schema'; import { withMcpMetadata } from "../lib/route-metadata-standards"; @@ -162,6 +163,100 @@ export const servicesRoutes = createApiRouter() const count = await c.var.services.service.setServiceInspectors(tenantId, id, userIds); return c.json({ success: true, data: { count } }); }) + // --- Pay rules (#278) -------------------------------------------------- + // Mounted here rather than under a pay-splits router because a rule is + // CATALOGUE configuration — it belongs to a service, not to an inspection — + // and this is where the service's other per-service settings already live. + // `/{id}/pay-rules` mirrors the `/{id}/inspectors` pair exactly: same + // arity, same `{id}` param name, registered before the bare `/{id}` routes. + // Write access is owner/manager, matching every other write on this router: + // deciding what a person is paid is company configuration, not field work. + // No new capability — `financial` remains the only line. + // GET /api/services/:id/pay-rules + .openapi(createRoute(withMcpMetadata({ + method: 'get', path: '/{id}/pay-rules', + tags: ["services"], summary: "List pay rules for a service", + middleware: [requireRole('owner', 'manager')] as const, + request: { params: z.object({ id: z.string().describe('Service ID') }) }, + responses: { + 200: { content: { 'application/json': { schema: PayRuleListResponseSchema } }, description: 'OK — the service default first, then per-inspector rules' }, + }, + operationId: "listServicePayRules", + description: "Returns what inspectors earn on this catalogue service. A rule with a null userId is the service default, applied to any inspector without one of their own. An empty list means pay splits are OFF for this service: nothing is populated when an inspection is assigned.", + }, { scopes: ['read'], tier: 'extended' })), async (c) => { + const tenantId = c.get('tenantId'); + const { id } = c.req.valid('param'); + const rows = await c.var.services.service.listPayRules(tenantId, id); + return c.json({ success: true, data: rows }); + }) + // POST /api/services/:id/pay-rules + .openapi(createRoute(withMcpMetadata({ + method: 'post', path: '/{id}/pay-rules', + tags: ["services"], summary: "Add a pay rule to a service", + middleware: [requireRole('owner', 'manager')] as const, + request: { + params: z.object({ id: z.string().describe('Service ID') }), + body: { content: { 'application/json': { schema: CreatePayRuleSchema } } }, + }, + responses: { + 201: { content: { 'application/json': { schema: PayRuleResponseSchema } }, description: 'Created' }, + 409: { content: { 'application/json': { schema: SuccessResponseSchema } }, description: 'A default rule, or a rule for that inspector, already exists on this service' }, + }, + operationId: "createServicePayRule", + description: "Writes what an inspector earns on this service. Percentages are BASIS POINTS (percentBps: 6000 = 60%) and fixed amounts are integer cents (amountCents) — the field name carries the unit, and the payload is strict, so a rule written in the wrong unit is refused rather than stored a hundred times too small. Omit userId for the service default. At most one default and one rule per inspector exist per service; a duplicate is 409.", + }, { scopes: ['write'], tier: 'extended' })), async (c) => { + const tenantId = c.get('tenantId'); + const { id } = c.req.valid('param'); + const input = c.req.valid('json'); + const row = await c.var.services.service.createPayRule(tenantId, id, input); + return c.json({ success: true, data: row }, 201); + }) + // PUT /api/services/:id/pay-rules/:ruleId + .openapi(createRoute(withMcpMetadata({ + method: 'put', path: '/{id}/pay-rules/{ruleId}', + tags: ["services"], summary: "Replace the rate of a pay rule", + middleware: [requireRole('owner', 'manager')] as const, + request: { + params: z.object({ + id: z.string().describe('Service ID'), + ruleId: z.string().describe('Pay rule ID'), + }), + body: { content: { 'application/json': { schema: UpdatePayRuleSchema } } }, + }, + responses: { + 200: { content: { 'application/json': { schema: PayRuleResponseSchema } }, description: 'OK' }, + }, + operationId: "updateServicePayRule", + description: "Changes the rate of an existing pay rule, including switching between the three types. The inspector the rule applies to is not editable here — that would move the rule into a different uniqueness slot, so it is a delete plus a create. Editing a rule never restates pay that was already recorded: splits are a frozen record, and an explicit refresh is what re-derives them.", + }, { scopes: ['write'], tier: 'extended' })), async (c) => { + const tenantId = c.get('tenantId'); + const { id, ruleId } = c.req.valid('param'); + const input = c.req.valid('json'); + const row = await c.var.services.service.updatePayRule(tenantId, id, ruleId, input); + return c.json({ success: true, data: row }); + }) + // DELETE /api/services/:id/pay-rules/:ruleId + .openapi(createRoute(withMcpMetadata({ + method: 'delete', path: '/{id}/pay-rules/{ruleId}', + tags: ["services"], summary: "Delete a pay rule", + middleware: [requireRole('owner', 'manager')] as const, + request: { + params: z.object({ + id: z.string().describe('Service ID'), + ruleId: z.string().describe('Pay rule ID'), + }), + }, + responses: { + 200: { content: { 'application/json': { schema: SuccessResponseSchema } }, description: 'Deleted' }, + }, + operationId: "deleteServicePayRule", + description: "Removes a pay rule. Deleting the last rule on a service turns pay splits off for it — future inspections populate nothing. Splits already recorded are left alone.", + }, { scopes: ['write'], tier: 'extended' })), async (c) => { + const tenantId = c.get('tenantId'); + const { id, ruleId } = c.req.valid('param'); + await c.var.services.service.deletePayRule(tenantId, id, ruleId); + return c.json({ success: true }); + }) // PUT /api/services/:id .openapi(createRoute(withMcpMetadata({ method: 'put', path: '/{id}', diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 7df071484..5318ebd77 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -5059,6 +5059,54 @@ "summary": "Create service discount codes", "description": "Auto-generated placeholder for createServiceDiscountCodes (POST /discount-codes, services domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "createServicePayRule", + "method": "POST", + "pathTemplate": "/api/services/{id}/pay-rules", + "scopes": [ + "write" + ], + "tag": "services", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Service ID", + "schema": { + "type": "string", + "description": "Service ID" + } + } + ], + "body": { + "oneOf": [ + { + "$ref": "#/components/schemas/PercentPayRule" + }, + { + "$ref": "#/components/schemas/FixedPayRule" + }, + { + "$ref": "#/components/schemas/PercentAfterDeductionPayRule" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "percent": "#/components/schemas/PercentPayRule", + "fixed": "#/components/schemas/FixedPayRule", + "percent_after_deduction": "#/components/schemas/PercentAfterDeductionPayRule" + } + }, + "description": "What one inspector earns on one catalogue service. See the unit contract above." + } + }, + "summary": "Add a pay rule to a service", + "description": "Writes what an inspector earns on this service. Percentages are BASIS POINTS (percentBps: 6000 = 60%) and fixed amounts are integer cents (amountCents) — the field name carries the unit, and the payload is strict, so a rule written in the wrong unit is refused rather than stored a hundred times too small. Omit userId for the service default. At most one default and one rule per inspector exist per service; a duplicate is 409." + }, { "operationId": "createTag", "method": "POST", @@ -6252,6 +6300,43 @@ "summary": "Delete service discount code", "description": "Auto-generated placeholder for deleteServiceDiscountCode (DELETE /discount-codes/{id}, services domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "deleteServicePayRule", + "method": "DELETE", + "pathTemplate": "/api/services/{id}/pay-rules/{ruleId}", + "scopes": [ + "write" + ], + "tag": "services", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Service ID", + "schema": { + "type": "string", + "description": "Service ID" + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "description": "Pay rule ID", + "schema": { + "type": "string", + "description": "Pay rule ID" + } + } + ], + "body": null + }, + "summary": "Delete a pay rule", + "description": "Removes a pay rule. Deleting the last rule on a service turns pay splits off for it — future inspections populate nothing. Splits already recorded are left alone." + }, { "operationId": "deleteTag", "method": "DELETE", @@ -11338,6 +11423,33 @@ "summary": "List service discount codes", "description": "Auto-generated placeholder for listServiceDiscountCodes (GET /discount-codes, services domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "listServicePayRules", + "method": "GET", + "pathTemplate": "/api/services/{id}/pay-rules", + "scopes": [ + "read" + ], + "tag": "services", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Service ID", + "schema": { + "type": "string", + "description": "Service ID" + } + } + ], + "body": null + }, + "summary": "List pay rules for a service", + "description": "Returns what inspectors earn on this catalogue service. A rule with a null userId is the service default, applied to any inspector without one of their own. An empty list means pay splits are OFF for this service: nothing is populated when an inspection is assigned." + }, { "operationId": "listServices", "method": "GET", @@ -21016,6 +21128,64 @@ "summary": "Update service discount code", "description": "Auto-generated placeholder for updateServiceDiscountCode (PUT /discount-codes/{id}, services domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "updateServicePayRule", + "method": "PUT", + "pathTemplate": "/api/services/{id}/pay-rules/{ruleId}", + "scopes": [ + "write" + ], + "tag": "services", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Service ID", + "schema": { + "type": "string", + "description": "Service ID" + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "description": "Pay rule ID", + "schema": { + "type": "string", + "description": "Pay rule ID" + } + } + ], + "body": { + "oneOf": [ + { + "$ref": "#/components/schemas/UpdatePercentPayRule" + }, + { + "$ref": "#/components/schemas/UpdateFixedPayRule" + }, + { + "$ref": "#/components/schemas/UpdatePercentAfterDeductionPayRule" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "percent": "#/components/schemas/UpdatePercentPayRule", + "fixed": "#/components/schemas/UpdateFixedPayRule", + "percent_after_deduction": "#/components/schemas/UpdatePercentAfterDeductionPayRule" + } + }, + "description": "Replace the rate of an existing pay rule. The inspector it applies to cannot be changed here." + } + }, + "summary": "Replace the rate of a pay rule", + "description": "Changes the rate of an existing pay rule, including switching between the three types. The inspector the rule applies to is not editable here — that would move the rule into a different uniqueness slot, so it is a delete plus a create. Editing a rule never restates pay that was already recorded: splits are a frozen record, and an explicit refresh is what re-derives them." + }, { "operationId": "updateTeamDefaults", "method": "PUT", diff --git a/server/lib/validations/service.schema.ts b/server/lib/validations/service.schema.ts index 1ce0c67da..22a56bb83 100644 --- a/server/lib/validations/service.schema.ts +++ b/server/lib/validations/service.schema.ts @@ -108,3 +108,99 @@ export const SetServiceInspectorsSchema = z.object({ export const SetServiceInspectorsResponseSchema = createApiResponseSchema(z.object({ count: z.number().int().describe('Number of restriction rows now in effect'), })); + +/* ------------------------------------------------------------------ */ +/* Pay rules — the switch that turns pay splits on (#278) */ +/* ------------------------------------------------------------------ */ + +/** + * THE UNIT CONTRACT, stated once here because it is where money goes wrong. + * + * `service_pay_rules.value` is a DUAL-UNIT column: basis points when `type` is + * a percentage, integer cents when it is `fixed`. That is defensible in the + * schema (the column comment explains why a `_cents` suffix would be a lie half + * the time) and indefensible on the wire, where the caller is a person or a + * script with no view of the type/unit coupling. `60` meaning 0.6% when the + * caller meant 60% is a hundredfold error that no type system catches. + * + * So `value` never appears on the wire. Each variant names its own unit + * (`percentBps`, `amountCents`) and the objects are STRICT: a payload written + * against the column — `{ type: 'percent', value: 6000 }` — is a 400 that names + * the unexpected key, not a row stored in the wrong unit. Converting a human + * percent server-side was the alternative and is worse: it puts two + * representations of the same number in the system, and the off-by-100 moves + * from the wire (where a strict schema catches it) into arithmetic (where + * nothing does). The UI does the ×100 where a human can see the "%" beside it. + * + * The union is discriminated on `type` for the same reason: `deductionCents` is + * meaningful ONLY for `percent_after_deduction`, where the deduction comes off + * the top BEFORE the percentage. A `percent` rule carrying one is ambiguous — + * the caller either wanted the other type or made a mistake — so it is refused + * rather than silently dropped. + */ +const PercentBps = z.number().int().min(1).max(10000) + .describe( + 'Share of the line price in BASIS POINTS: 6000 = 60%, 1 = 0.01%, 10000 = 100%. ' + + 'NOT a human percent — sending 60 here means 0.6%. ' + + 'Capped at 10000 because above 100% the gross exceeds the line price for every ' + + 'roster size, so no such rule could ever pay out; the split ceiling itself is ' + + 'checked at populate time, not here.', + ); + +const PayRuleTarget = z.string().min(1).nullable().optional() + .describe( + 'The inspector this rule is written for. Omit or send null for the SERVICE DEFAULT, ' + + 'which applies to any inspector without a rule of their own. At most one default ' + + 'and one rule per inspector exist per service.', + ); + +const PercentRule = z.object({ + type: z.literal('percent').describe('A straight share of the line price.'), + userId: PayRuleTarget, + percentBps: PercentBps, +}).strict().openapi('PercentPayRule'); + +const FixedRule = z.object({ + type: z.literal('fixed').describe('A flat amount, whatever the line is priced at.'), + userId: PayRuleTarget, + amountCents: z.number().int().min(1) + .describe('Flat amount in integer cents: 12500 = $125.00.'), +}).strict().openapi('FixedPayRule'); + +const PercentAfterDeductionRule = z.object({ + type: z.literal('percent_after_deduction') + .describe('A share of what is left after a fixed amount comes off the top.'), + userId: PayRuleTarget, + percentBps: PercentBps, + deductionCents: z.number().int().min(1) + .describe( + 'Taken off the line price BEFORE the percentage — materials, a franchise fee. ' + + '($500 − $100) × 60% = $240, which is not 60% of $500 less $100.', + ), +}).strict().openapi('PercentAfterDeductionPayRule'); + +export const CreatePayRuleSchema = z.discriminatedUnion('type', [ + PercentRule, FixedRule, PercentAfterDeductionRule, +]).describe('What one inspector earns on one catalogue service. See the unit contract above.'); + +/** Same shapes minus the target: `userId` identifies the rule, so moving it is a delete + create. */ +export const UpdatePayRuleSchema = z.discriminatedUnion('type', [ + PercentRule.omit({ userId: true }).strict().openapi('UpdatePercentPayRule'), + FixedRule.omit({ userId: true }).strict().openapi('UpdateFixedPayRule'), + PercentAfterDeductionRule.omit({ userId: true }).strict().openapi('UpdatePercentAfterDeductionPayRule'), +]).describe('Replace the rate of an existing pay rule. The inspector it applies to cannot be changed here.'); + +/** The read face mirrors the write face — again, no `value`. */ +const PayRuleSchema = z.object({ + id: z.string().describe('service_pay_rules row id.'), + serviceId: z.string().describe('Catalogue service this rule prices the work on.'), + userId: z.string().nullable().describe('Inspector this rule is for; null is the service default.'), + type: z.enum(['percent', 'fixed', 'percent_after_deduction']).describe('Which of the three rate shapes this is.'), + percentBps: z.number().int().nullable().describe('Basis points, on the two percentage types; null on a fixed rule.'), + amountCents: z.number().int().nullable().describe('Integer cents, on a fixed rule; null on the percentage types.'), + deductionCents: z.number().int().nullable().describe('Cents off the top, only on percent_after_deduction.'), + createdAt: z.string().nullable().describe('When the rule was written, ISO-8601.'), +}).openapi('PayRule'); + +export const PayRuleResponseSchema = createApiResponseSchema(PayRuleSchema); +export const PayRuleListResponseSchema = createApiResponseSchema(z.array(PayRuleSchema)); diff --git a/server/services/service.service.ts b/server/services/service.service.ts index f0dbd0289..e3487d945 100644 --- a/server/services/service.service.ts +++ b/server/services/service.service.ts @@ -3,6 +3,8 @@ import { eq, and, asc, inArray, sql } from 'drizzle-orm'; import { services, inspectionServices, discountCodes, inspections, eventTypes, reports } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { getServiceInspectors, setServiceInspectors } from './service/qualification'; +import { listPayRules, createPayRule, updatePayRule, deletePayRule } from './service/pay-rules'; +import type { CreatePayRuleInput, UpdatePayRuleInput } from './service/pay-rules'; import { syncSplitsQuietly } from './pay-split.service'; import { nanoid } from 'nanoid'; import type { z } from 'zod'; @@ -330,6 +332,24 @@ export class ServiceService { return setServiceInspectors(this.getDrizzle(), tenantId, serviceId, userIds); } + // #278 — pay rules. Implementation in service/pay-rules.ts, which owns the + // dual-unit boundary and the 409 for the two partial unique indexes. + async listPayRules(tenantId: string, serviceId: string) { + return listPayRules(this.getDrizzle(), tenantId, serviceId); + } + + async createPayRule(tenantId: string, serviceId: string, input: CreatePayRuleInput) { + return createPayRule(this.getDrizzle(), tenantId, serviceId, input); + } + + async updatePayRule(tenantId: string, serviceId: string, ruleId: string, input: UpdatePayRuleInput) { + return updatePayRule(this.getDrizzle(), tenantId, serviceId, ruleId, input); + } + + async deletePayRule(tenantId: string, serviceId: string, ruleId: string): Promise { + return deletePayRule(this.getDrizzle(), tenantId, serviceId, ruleId); + } + async validateDiscountCode(tenantId: string, code: string, subtotal: number): Promise<{ valid: boolean; discountAmount: number; diff --git a/server/services/service/pay-rules.ts b/server/services/service/pay-rules.ts new file mode 100644 index 000000000..1ef1a09dd --- /dev/null +++ b/server/services/service/pay-rules.ts @@ -0,0 +1,214 @@ +/** + * The write face for `service_pay_rules` — the switch that turns pay splits on + * (#278). + * + * The schema, `populateSplits`, the API for reading splits and the per-inspector + * metrics all shipped; nothing could create a RULE, and `populateSplits` + * produces nothing without one (`pickRule` returns undefined, the loop + * `continue`s, zero rows). This module is the missing half. + * + * Extracted from `service.service.ts` the way `./qualification` was, for the + * same two reasons: the file-size ratchet, and because "what an inspector earns + * on a catalogue service" is a different concern from what that service costs. + * + * Two things here are load-bearing: + * + * - THE UNIT BOUNDARY. `value` is dual-unit (basis points / cents, decided by + * `type`). It is translated to and from the per-variant wire names in + * exactly these two functions — `toColumns` and `toWire` — and nowhere else, + * so there is one place to read when a number looks a hundred times wrong. + * - THE 409. Two partial unique indexes refuse a second default rule, and a + * second rule for one inspector, at the DB. A raw driver error reaching the + * client would be a 500 reading `UNIQUE constraint failed: + * service_pay_rules.tenant_id, service_pay_rules.service_id`, which tells + * an admin nothing about the screen they are on. The pre-check answers the + * ordinary case; the catch is the race backstop, because between the SELECT + * and the INSERT another request can land. + */ +import { and, eq, isNull, ne } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { services, servicePayRules, users } from '../../lib/db/schema'; +import type { ServicePayRule } from '../../lib/db/schema'; +import { Errors } from '../../lib/errors'; +import { safeISODate } from '../../lib/date'; +import type { z } from 'zod'; +import type { CreatePayRuleSchema, UpdatePayRuleSchema } from '../../lib/validations/service.schema'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Db = any; + +export type CreatePayRuleInput = z.infer; +export type UpdatePayRuleInput = z.infer; + +export interface PayRuleWire { + id: string; + serviceId: string; + userId: string | null; + type: ServicePayRule['type']; + percentBps: number | null; + amountCents: number | null; + deductionCents: number | null; + createdAt: string | null; +} + +/** Wire shape → the dual-unit column. The ONLY place a percentage becomes `value`. */ +function toColumns(input: CreatePayRuleInput | UpdatePayRuleInput): { + type: ServicePayRule['type']; value: number; deductionCents: number | null; +} { + if (input.type === 'fixed') { + return { type: 'fixed', value: input.amountCents, deductionCents: null }; + } + if (input.type === 'percent_after_deduction') { + return { type: 'percent_after_deduction', value: input.percentBps, deductionCents: input.deductionCents }; + } + // A `percent` rule must carry NO deduction, including when it replaces a + // percent_after_deduction rule: a stale value left in the column would keep + // coming off the top and the arithmetic would quietly disagree with the type. + return { type: 'percent', value: input.percentBps, deductionCents: null }; +} + +/** The column → wire shape. `value` is never echoed under its own name. */ +function toWire(row: ServicePayRule): PayRuleWire { + return { + id: row.id, + serviceId: row.serviceId, + userId: row.userId, + type: row.type, + percentBps: row.type === 'fixed' ? null : row.value, + amountCents: row.type === 'fixed' ? row.value : null, + deductionCents: row.deductionCents, + createdAt: row.createdAt ? safeISODate(row.createdAt) : null, + }; +} + +/** Ordering the UI can rely on: the service default first, then inspectors by id. */ +function ordered(rows: ServicePayRule[]): ServicePayRule[] { + return [...rows].sort((a, b) => + (a.userId === null ? 0 : 1) - (b.userId === null ? 0 : 1) + || (a.userId ?? '').localeCompare(b.userId ?? '')); +} + +async function requireService(db: Db, tenantId: string, serviceId: string): Promise { + const svc = await db.select({ id: services.id }).from(services) + .where(and(eq(services.id, serviceId), eq(services.tenantId, tenantId))) + .limit(1).get(); + if (!svc) throw Errors.NotFound('Service not found'); +} + +/** + * A rule may only name a real, non-deleted, non-agent member of this tenant — + * the same eligibility `setServiceInspectors` enforces. A rule pointing at a + * stranger's id is unreachable (`pickRule` never matches it) and a rule pointing + * at an AGENT would try to pay a third-party realtor out of inspection revenue. + */ +async function requireMember(db: Db, tenantId: string, userId: string): Promise { + const member = await db.select({ id: users.id }).from(users) + .where(and( + eq(users.tenantId, tenantId), + eq(users.id, userId), + isNull(users.deletedAt), + ne(users.role, 'agent'), + )) + .limit(1).get(); + if (!member) throw Errors.BadRequest(`Invalid or ineligible user ID: ${userId}`); +} + +function duplicateError(userId: string | null) { + return Errors.Conflict( + userId === null + ? 'This service already has a default pay rule. Edit that rule instead of adding a second one.' + : `This service already has a pay rule for that inspector (${userId}). Edit it instead of adding a second one.`, + ); +} + +/** True when the driver refused the write on one of the two partial uniques. */ +function isUniqueViolation(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /UNIQUE constraint failed/i.test(msg) && /service_pay_rules/i.test(msg); +} + +export async function listPayRules(db: Db, tenantId: string, serviceId: string): Promise { + await requireService(db, tenantId, serviceId); + const rows = await db.select().from(servicePayRules) + .where(and(eq(servicePayRules.tenantId, tenantId), eq(servicePayRules.serviceId, serviceId))) + .all(); + return ordered(rows as ServicePayRule[]).map(toWire); +} + +export async function createPayRule( + db: Db, tenantId: string, serviceId: string, input: CreatePayRuleInput, +): Promise { + await requireService(db, tenantId, serviceId); + const userId = input.userId ?? null; + if (userId !== null) await requireMember(db, tenantId, userId); + + // The ordinary duplicate, answered before the driver sees it. + const clash = (await db.select().from(servicePayRules) + .where(and(eq(servicePayRules.tenantId, tenantId), eq(servicePayRules.serviceId, serviceId))) + .all() as ServicePayRule[]) + .some(r => r.userId === userId); + if (clash) throw duplicateError(userId); + + const id = nanoid(); + const cols = toColumns(input); + try { + await db.insert(servicePayRules).values({ + id, tenantId, serviceId, userId, ...cols, createdAt: new Date(), + }).run(); + } catch (err) { + // The race: another request wrote the same slot between the SELECT and + // here. Same answer, so the client cannot tell the two apart — which is + // correct, because the state it should act on is identical. + if (isUniqueViolation(err)) throw duplicateError(userId); + throw err; + } + return await requirePayRule(db, tenantId, serviceId, id); +} + +export async function updatePayRule( + db: Db, tenantId: string, serviceId: string, ruleId: string, input: UpdatePayRuleInput, +): Promise { + await requirePayRule(db, tenantId, serviceId, ruleId); + await db.update(servicePayRules) + .set(toColumns(input)) + .where(and( + eq(servicePayRules.tenantId, tenantId), + eq(servicePayRules.serviceId, serviceId), + eq(servicePayRules.id, ruleId), + )) + .run(); + return await requirePayRule(db, tenantId, serviceId, ruleId); +} + +/** + * Deleting the last rule for a service turns pay splits back OFF for it: + * `populateSplits` finds nothing and writes nothing. Splits ALREADY recorded + * are untouched — they are a record, not a derivation, and rewriting history + * because a rule changed is the failure the whole feature is built against. + */ +export async function deletePayRule( + db: Db, tenantId: string, serviceId: string, ruleId: string, +): Promise { + await requirePayRule(db, tenantId, serviceId, ruleId); + await db.delete(servicePayRules) + .where(and( + eq(servicePayRules.tenantId, tenantId), + eq(servicePayRules.serviceId, serviceId), + eq(servicePayRules.id, ruleId), + )) + .run(); +} + +async function requirePayRule( + db: Db, tenantId: string, serviceId: string, ruleId: string, +): Promise { + const row = await db.select().from(servicePayRules) + .where(and( + eq(servicePayRules.tenantId, tenantId), + eq(servicePayRules.serviceId, serviceId), + eq(servicePayRules.id, ruleId), + )) + .limit(1).get(); + if (!row) throw Errors.NotFound('Pay rule not found'); + return toWire(row as ServicePayRule); +} diff --git a/tests/unit/idempotency/service-pay-rule-replay.spec.ts b/tests/unit/idempotency/service-pay-rule-replay.spec.ts new file mode 100644 index 000000000..ad1c5af7b --- /dev/null +++ b/tests/unit/idempotency/service-pay-rule-replay.spec.ts @@ -0,0 +1,178 @@ +/** + * Retry safety for the pay-rule write surface (#278). + * + * A pay rule is not itself money, but it is the multiplier every future split + * on that service is derived from, so a duplicate is a money-shaped defect: two + * rules in the same slot and `pickRule` picks one arbitrarily, which means what + * an inspector earns depends on insertion order. + * + * The partial unique indexes stop the duplicate ROW. What they do not fix is + * what a retry SEES: unguarded, the second POST of the same create is a 409 + * saying a rule already exists — indistinguishable, from the client's seat, from + * a colleague having added one in another tab. The guard turns the retry into a + * replay of the original 201, which is the honest answer: the request succeeded, + * this is what it created. That is the difference these specs pin. + * + * PUT and DELETE are asserted for the same reason and are weaker hazards: PUT + * writes an absolute rate, and a retried DELETE unguarded 404s on a rule that + * the caller did in fact delete. Both are labelled for what they are. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import * as schema from '../../../server/lib/db/schema'; +import { tenants, users, services, servicePayRules } from '../../../server/lib/db/schema'; +import { ServiceService } from '../../../server/services/service.service'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; +import { createTestDb, setupSchema } from '../db'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order +import { servicesRoutes } from '../../../server/api/services'; + +const T = 't1'; +const SVC = 'svc-home'; +// Assembled rather than written out, and this comment names no path either. +// `isVerified` in the coverage gate marks a route verified when a replay spec +// contains its full path as a QUOTED string — anywhere in the file, comments +// included. Writing the bare router mount out here would mark the create-service +// route, which this file does not exercise, as having a replay story. A false +// verification is worse than a pending entry, so the mount is built at runtime. +const MOUNT = ['/api', 'services'].join('/'); +const FAKE_ENV = { DB: {} } as HonoConfig['Bindings']; +const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; + +let db: DrizzleD1Database; + +function buildApp() { + (mockDrizzle as unknown 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', T); + c.set('userRole', 'manager'); + c.set('user', { sub: 'mgr', role: 'manager', tenantId: T }); + c.set('sdb', { getById: async () => ({ permissionOverrides: null }) } as unknown as HonoConfig['Variables']['sdb']); + c.set('services', { service: new ServiceService({} as D1Database) } as unknown as HonoConfig['Variables']['services']); + await next(); + }); + // The mounted shape: tenant on the context first, then the guard. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route(MOUNT, servicesRoutes); + return app; +} + +function send(method: string, path: string, key: string | null, body?: unknown) { + const headers: Record = { 'content-type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().fetch( + new Request(`https://acme.example.com${path}`, { + method, headers, body: body === undefined ? undefined : JSON.stringify(body), + }), + FAKE_ENV as never, CTX, + ); +} + +const RULES = `/api/services/${SVC}/pay-rules`; +const allRules = () => db.select().from(servicePayRules).where(eq(servicePayRules.tenantId, T)).all(); + +beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + db = drizzle(fixture.sqlite, { schema }) as unknown as DrizzleD1Database; + const now = new Date(); + + await db.insert(tenants).values({ + id: T, name: 'Acme', slug: 'acme', tier: 'free', status: 'active', + maxUsers: 5, deploymentMode: 'shared', createdAt: now, + }).run(); + await db.insert(users).values({ + id: 'u1', tenantId: T, email: 'u1@acme.test', passwordHash: 'x', + name: 'U1', role: 'inspector', createdAt: now, + }).run(); + await db.insert(services).values({ + id: SVC, tenantId: T, name: 'Home Inspection', price: 50000, createdAt: now, + }).run(); +}); + +describe("POST '/api/services/{id}/pay-rules' — a replay must not read as someone else's rule", () => { + const create = (key: string | null) => send('POST', RULES, key, { type: 'percent', percentBps: 6000 }); + + it('answers the SAME 201 twice under one key, not a 409', async () => { + const first = await create('rule-1'); + const second = await create('rule-1'); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(await second.json()).toEqual(await first.clone().json()); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(first.headers.get('Idempotency-Replayed')).toBeNull(); + expect(await allRules()).toHaveLength(1); + }); + + it('UNGUARDED, the retry is a 409 the caller cannot tell from a real clash', async () => { + // The hazard, stated. The unique index does stop the second ROW — what + // it cannot do is tell the retrying client that its own first attempt is + // the thing in the way. + expect((await create(null)).status).toBe(201); + expect((await create(null)).status).toBe(409); + expect(await allRules()).toHaveLength(1); + }); + + it('a DELIBERATE second rule under a fresh key still lands, for a different inspector', async () => { + await create('rule-1'); + const other = await send('POST', RULES, 'rule-2', { type: 'percent', percentBps: 7000, userId: 'u1' }); + expect(other.status).toBe(201); + expect(await allRules()).toHaveLength(2); + }); +}); + +describe("PUT '/api/services/{id}/pay-rules/{ruleId}' and DELETE '/api/services/{id}/pay-rules/{ruleId}'", () => { + let ruleId: string; + + beforeEach(async () => { + const body = await (await send('POST', RULES, 'seed', { type: 'percent', percentBps: 6000 })) + .json() as { data: { id: string } }; + ruleId = body.data.id; + }); + + it('CHARACTERIZATION: PUT writes an absolute rate, so a replay is the same state', async () => { + // Not evidence for the guard. Stated so that turning this into a + // relative adjustment later fails HERE, loudly, rather than in payroll. + const path = `${RULES}/${ruleId}`; + await send('PUT', path, 'set-1', { type: 'fixed', amountCents: 15000 }); + await send('PUT', path, 'set-1', { type: 'fixed', amountCents: 15000 }); + + const rows = await allRules(); + expect(rows).toHaveLength(1); + expect(rows[0].value).toBe(15000); + }); + + it('DELETE replays its 200 instead of 404ing on the caller\'s own success', async () => { + const path = `${RULES}/${ruleId}`; + const first = await send('DELETE', path, 'del-1'); + const second = await send('DELETE', path, 'del-1'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.headers.get('Idempotency-Replayed')).toBe('true'); + expect(await allRules()).toHaveLength(0); + }); + + it('UNGUARDED, the retried DELETE 404s on a rule the caller did delete', async () => { + const path = `${RULES}/${ruleId}`; + expect((await send('DELETE', path, null)).status).toBe(200); + expect((await send('DELETE', path, null)).status).toBe(404); + }); +}); diff --git a/tests/unit/services/pay-rules.spec.ts b/tests/unit/services/pay-rules.spec.ts new file mode 100644 index 000000000..7f7ac3ffc --- /dev/null +++ b/tests/unit/services/pay-rules.spec.ts @@ -0,0 +1,319 @@ +/** + * The switch for pay splits (#278). + * + * Tasks 1-4 shipped the schema, the populate logic, the API surface and the + * metrics — but nothing could create a `service_pay_rules` row, so + * `populateSplits` had nothing to read and the whole feature was a machine with + * no switch. These are the specs for the write face. + * + * Three of them are load-bearing and the rest is scaffolding: + * + * 1. THE UNIT CONTRACT. `service_pay_rules.value` is basis points for a + * percentage and integer cents for `fixed` — one column, two units. A + * wire field also called `value` would carry that ambiguity to every + * caller, and `60` meaning 0.6% when the caller meant 60% is a 100× money + * error that no type checks. So `value` never appears on the wire at all: + * each variant names its own unit (`percentBps` / `amountCents`) and the + * objects are STRICT, so a payload written in the wrong unit-name fails + * loudly instead of being stored a hundred times too small. + * 2. THE SECOND DEFAULT. Two partial unique indexes make a duplicate rule a + * DB-level refusal; the client must see a 409 it can act on, never a raw + * SQLite constraint string. + * 3. `percent` + `deductionCents`. The deduction is meaningful only for + * `percent_after_deduction` (it comes off the top BEFORE the percentage). + * A `percent` rule carrying one is ambiguous, so it is refused rather than + * silently ignored — which is what a single loose object would have done. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import * as schema from '../../../server/lib/db/schema'; +import { + tenants, users, services, inspections, inspectionServices, servicePayRules, + inspectionServicePaySplits, +} from '../../../server/lib/db/schema'; +import { syncInspectionAssignments } from '../../../server/lib/db/assignment-links'; +import { populateSplits } from '../../../server/services/pay-split.service'; +import { loadRules, pickRule } from '../../../server/services/pay-split/core'; +import { ServiceService } from '../../../server/services/service.service'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; +import { createTestDb, setupSchema } from '../db'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order +import { servicesRoutes } from '../../../server/api/services'; + +const T = 't1'; +const SVC = 'svc-home'; +const INSP = 'i1'; +const LINE = 'line1'; +const FAKE_ENV = { DB: {} } as HonoConfig['Bindings']; +const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; + +let db: DrizzleD1Database; + +function buildApp(role: 'owner' | 'manager' | 'inspector' = 'manager') { + (mockDrizzle as unknown 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', T); + c.set('userRole', role); + c.set('user', { sub: 'mgr', role, tenantId: T }); + c.set('sdb', { getById: async () => ({ permissionOverrides: null }) } as unknown as HonoConfig['Variables']['sdb']); + c.set('services', { service: new ServiceService({} as D1Database) } as unknown as HonoConfig['Variables']['services']); + await next(); + }); + app.route('/api/services', servicesRoutes); + return app; +} + +function send(method: string, path: string, body?: unknown, role?: 'owner' | 'manager' | 'inspector') { + return buildApp(role).fetch( + new Request(`https://acme.example.com${path}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + FAKE_ENV as never, CTX, + ); +} + +const RULES = `/api/services/${SVC}/pay-rules`; +const allRules = () => db.select().from(servicePayRules).where(eq(servicePayRules.tenantId, T)).all(); + +beforeEach(async () => { + const fixture = createTestDb(); + await setupSchema(fixture.sqlite); + db = drizzle(fixture.sqlite, { schema }) as unknown as DrizzleD1Database; + const now = new Date(); + + await db.insert(tenants).values({ + id: T, name: 'Acme', slug: 'acme', tier: 'free', status: 'active', + maxUsers: 5, deploymentMode: 'shared', createdAt: now, + }).run(); + for (const id of ['u1', 'u2']) { + await db.insert(users).values({ + id, tenantId: T, email: `${id}@acme.test`, passwordHash: 'x', + name: id.toUpperCase(), role: 'inspector', createdAt: now, + }).run(); + } + await db.insert(services).values({ + id: SVC, tenantId: T, name: 'Home Inspection', price: 50000, createdAt: now, + }).run(); + await db.insert(inspections).values({ + id: INSP, tenantId: T, propertyAddress: '1 Oak St', date: '2026-08-01', createdAt: now, + }).run(); + await db.insert(inspectionServices).values({ + id: LINE, tenantId: T, inspectionId: INSP, serviceId: SVC, + nameSnapshot: 'Home Inspection', priceSnapshot: 50000, + }).run(); +}); + +/* ------------------------------------------------------------------ */ +/* 1. The unit contract */ +/* ------------------------------------------------------------------ */ + +describe('the unit contract for a percentage rule', () => { + it('stores `percentBps: 6000` as 6000 basis points and pays 60%', async () => { + const res = await send('POST', RULES, { type: 'percent', percentBps: 6000 }); + expect(res.status).toBe(201); + + const rows = await allRules(); + expect(rows).toHaveLength(1); + // The column is basis points here, and this is the assertion that says so. + expect(rows[0].value).toBe(6000); + + await syncInspectionAssignments(db, T, INSP, { leadInspectorId: 'u1', helperInspectorIds: [] }); + await populateSplits(db, T, INSP); + const splits = await db.select().from(inspectionServicePaySplits) + .where(eq(inspectionServicePaySplits.tenantId, T)).all(); + // 60% of $500.00 — NOT 0.6% ($3.00), which is what a human "60" written + // straight into the column would have produced. + expect(splits[0].amountCents).toBe(30000); + }); + + it('REFUSES a human percent sent as `percent`, rather than storing 0.6%', async () => { + // The 100× error, attempted. A loose object would drop the unknown key + // and fail on a missing `percentBps`; a strict one names the mistake. + const res = await send('POST', RULES, { type: 'percent', percent: 60 }); + expect(res.status).toBe(400); + expect(await allRules()).toHaveLength(0); + }); + + it('REFUSES the ambiguous wire field `value`, whichever unit the caller meant', async () => { + const res = await send('POST', RULES, { type: 'percent', value: 6000 }); + expect(res.status).toBe(400); + expect(await allRules()).toHaveLength(0); + }); + + it('accepts the whole legal basis-point range and nothing above 100%', async () => { + // 1 bp = 0.01%, the smallest expressible share. + expect((await send('POST', RULES, { type: 'percent', percentBps: 1, userId: 'u1' })).status).toBe(201); + // 10000 bp = 100%. Legal on its own: with two eligible inspectors it + // pays 50% each and the line sums to exactly its price. + expect((await send('POST', RULES, { type: 'percent', percentBps: 10000, userId: 'u2' })).status).toBe(201); + // Above 100% the GROSS exceeds the line price for every roster size + // (the divisor cancels), so no such rule can ever populate. Refusing it + // is not the split-ceiling check — that stays at populate time. + expect((await send('POST', RULES, { type: 'percent', percentBps: 10001 })).status).toBe(400); + expect((await send('POST', RULES, { type: 'percent', percentBps: 0 })).status).toBe(400); + }); + + it('names cents `amountCents` on a fixed rule, and the same column holds cents', async () => { + const res = await send('POST', RULES, { type: 'fixed', amountCents: 12500 }); + expect(res.status).toBe(201); + expect((await allRules())[0].value).toBe(12500); + // And the response never echoes the dual-unit column name back. + const body = await res.json() as { data: Record }; + expect(body.data).toMatchObject({ type: 'fixed', amountCents: 12500 }); + expect(body.data).not.toHaveProperty('value'); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 2. The second default */ +/* ------------------------------------------------------------------ */ + +describe('the partial unique indexes, surfaced', () => { + it('a SECOND default rule for one service is a 409, not a SQLite error', async () => { + expect((await send('POST', RULES, { type: 'percent', percentBps: 6000 })).status).toBe(201); + + const second = await send('POST', RULES, { type: 'percent', percentBps: 5500 }); + expect(second.status).toBe(409); + const body = await second.json() as { error: { code: string; message: string } }; + expect(body.error.code).toBe('conflict'); + // Actionable, and free of driver noise. + expect(body.error.message).toMatch(/already has a default pay rule/i); + expect(body.error.message).not.toMatch(/UNIQUE constraint|SQLITE/i); + // The first rule is untouched — a refused create never half-writes. + const rows = await allRules(); + expect(rows).toHaveLength(1); + expect(rows[0].value).toBe(6000); + }); + + it('a second rule for the SAME inspector is a 409 naming that inspector', async () => { + expect((await send('POST', RULES, { type: 'percent', percentBps: 6000, userId: 'u1' })).status).toBe(201); + const second = await send('POST', RULES, { type: 'fixed', amountCents: 9000, userId: 'u1' }); + expect(second.status).toBe(409); + expect(await allRules()).toHaveLength(1); + }); + + it('a default AND a per-inspector rule coexist, and the specific one wins', async () => { + await send('POST', RULES, { type: 'percent', percentBps: 6000 }); + await send('POST', RULES, { type: 'percent', percentBps: 7000, userId: 'u1' }); + expect(await allRules()).toHaveLength(2); + + // The reason the default row exists at all: `pickRule` precedence. + const rules = await loadRules(db, T, [SVC]); + expect(pickRule(rules, SVC, 'u1')?.value).toBe(7000); + expect(pickRule(rules, SVC, 'u2')?.value).toBe(6000); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 3. `percent` carrying a deduction */ +/* ------------------------------------------------------------------ */ + +describe('deductionCents belongs to exactly one type', () => { + it('REFUSES `percent` + deductionCents instead of silently dropping it', async () => { + const res = await send('POST', RULES, { type: 'percent', percentBps: 6000, deductionCents: 5000 }); + expect(res.status).toBe(400); + expect(await allRules()).toHaveLength(0); + }); + + it('REFUSES `fixed` + deductionCents for the same reason', async () => { + const res = await send('POST', RULES, { type: 'fixed', amountCents: 9000, deductionCents: 5000 }); + expect(res.status).toBe(400); + expect(await allRules()).toHaveLength(0); + }); + + it('takes the deduction off the top BEFORE the percentage on the type that owns it', async () => { + const res = await send('POST', RULES, { + type: 'percent_after_deduction', percentBps: 6000, deductionCents: 10000, + }); + expect(res.status).toBe(201); + expect((await allRules())[0].deductionCents).toBe(10000); + + await syncInspectionAssignments(db, T, INSP, { leadInspectorId: 'u1', helperInspectorIds: [] }); + await populateSplits(db, T, INSP); + const splits = await db.select().from(inspectionServicePaySplits) + .where(eq(inspectionServicePaySplits.tenantId, T)).all(); + // ($500.00 − $100.00) × 60% = $240.00. Not 60% of $500 less $100 ($200), + // which is the arithmetic a "percent with a discount" reading produces. + expect(splits[0].amountCents).toBe(24000); + }); +}); + +/* ------------------------------------------------------------------ */ +/* The rest of the write face */ +/* ------------------------------------------------------------------ */ + +describe('list, update, delete', () => { + it('lists the rules of one service, default first', async () => { + await send('POST', RULES, { type: 'percent', percentBps: 7000, userId: 'u1' }); + await send('POST', RULES, { type: 'percent', percentBps: 6000 }); + + const body = await (await send('GET', RULES)).json() as { data: { userId: string | null }[] }; + expect(body.data.map(r => r.userId)).toEqual([null, 'u1']); + }); + + it('updates a rule in place, including switching its type', async () => { + const created = await (await send('POST', RULES, { type: 'percent', percentBps: 6000 })) + .json() as { data: { id: string } }; + const res = await send('PUT', `${RULES}/${created.data.id}`, { type: 'fixed', amountCents: 15000 }); + expect(res.status).toBe(200); + + const rows = await allRules(); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe('fixed'); + expect(rows[0].value).toBe(15000); + // Switching away from percent_after_deduction must clear the deduction, + // or a stale one silently changes the arithmetic of the new type. + expect(rows[0].deductionCents).toBeNull(); + }); + + it('deletes a rule, which turns the feature back off for that service', async () => { + const created = await (await send('POST', RULES, { type: 'percent', percentBps: 6000 })) + .json() as { data: { id: string } }; + expect((await send('DELETE', `${RULES}/${created.data.id}`)).status).toBe(200); + expect(await allRules()).toHaveLength(0); + + await syncInspectionAssignments(db, T, INSP, { leadInspectorId: 'u1', helperInspectorIds: [] }); + expect(await populateSplits(db, T, INSP)).toBe(0); + }); + + it('404s on a service that is not this tenant\'s, and on an unknown rule', async () => { + expect((await send('POST', '/api/services/nope/pay-rules', { type: 'percent', percentBps: 6000 })).status) + .toBe(404); + expect((await send('DELETE', `${RULES}/nope`)).status).toBe(404); + }); + + it('refuses a userId that is not a tenant member', async () => { + const res = await send('POST', RULES, { type: 'percent', percentBps: 6000, userId: 'stranger' }); + expect(res.status).toBe(400); + expect(await allRules()).toHaveLength(0); + }); +}); + +describe('who may set what a person is paid', () => { + it('an inspector may not write a pay rule', async () => { + const res = await send('POST', RULES, { type: 'percent', percentBps: 6000 }, 'inspector'); + expect(res.status).toBe(403); + expect(await allRules()).toHaveLength(0); + }); + + it('an owner may', async () => { + expect((await send('POST', RULES, { type: 'percent', percentBps: 6000 }, 'owner')).status).toBe(201); + }); +}); From c9f1d203812b8e00244ea8075ff28be072d26fb5 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 13:21:41 +0800 Subject: [PATCH 32/77] =?UTF-8?q?fix(pay-splits):=20the=20pay-rule=20union?= =?UTF-8?q?=20blew=20tsc's=20heap=20=E2=80=94=20same=20contract,=20one=20o?= =?UTF-8?q?bject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit shipped the pay-rule body as a `z.discriminatedUnion` on `type`, which is the shape the rule genuinely has. It also broke `npm run type-check:app`, and not with an error: tsc runs for five minutes and dies with "Ineffective mark-compacts near heap limit — JavaScript heap out of memory" at its 8 GB cap. Bisected rather than guessed. Same tree with these three server files reverted to their previous versions and pay-rules.ts moved aside, type-check:app exits 0; put back, OOM. The cause is hono/client, which expands every route's request type into the app-wide RPC type that `createApi` returns — three strict union members plus the three `.omit()` members of the update union is enough to take the whole program past the limit. The previous commit's own pre-commit hook could not see this: staging only `server/**` selects the api tier, and the api tsconfig does not build the client type. That is a real hole in the ladder for any route-shape change, not a one-off. The fix keeps the contract and changes only its encoding. One strict object carrying every rate field as optional, plus `exactlyTheFieldsFor` — a table of which fields each type REQUIRES and, just as load-bearing, which it FORBIDS, applied as a superRefine. Identical rejections: `percent` with a `deductionCents` is still a 400, an unknown key is still a 400, a missing `percentBps` is still a 400. The proof nothing moved is that not one line of either spec changed and all 24 still pass — the wire shape, the field names and the statuses are byte-identical, so no caller can tell the two encodings apart. `toColumns` gains a `need()` guard where the union used to narrow for it. Deliberately a throw and not a `!`: the refinement that guarantees the field lives in another file, and the failure being guarded is the money column silently taking undefined. The path back is written next to the schema. If hono's RPC inference ever gets cheaper, the union is the better model and that is where it goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- server/lib/mcp/openapi-snapshot.json | 42 +------- server/lib/validations/service.schema.ts | 125 ++++++++++++++++------- server/services/service/pay-rules.ts | 24 ++++- 3 files changed, 110 insertions(+), 81 deletions(-) diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 5318ebd77..9e3b316ba 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -5082,26 +5082,7 @@ } ], "body": { - "oneOf": [ - { - "$ref": "#/components/schemas/PercentPayRule" - }, - { - "$ref": "#/components/schemas/FixedPayRule" - }, - { - "$ref": "#/components/schemas/PercentAfterDeductionPayRule" - } - ], - "discriminator": { - "propertyName": "type", - "mapping": { - "percent": "#/components/schemas/PercentPayRule", - "fixed": "#/components/schemas/FixedPayRule", - "percent_after_deduction": "#/components/schemas/PercentAfterDeductionPayRule" - } - }, - "description": "What one inspector earns on one catalogue service. See the unit contract above." + "$ref": "#/components/schemas/CreatePayRule" } }, "summary": "Add a pay rule to a service", @@ -21161,26 +21142,7 @@ } ], "body": { - "oneOf": [ - { - "$ref": "#/components/schemas/UpdatePercentPayRule" - }, - { - "$ref": "#/components/schemas/UpdateFixedPayRule" - }, - { - "$ref": "#/components/schemas/UpdatePercentAfterDeductionPayRule" - } - ], - "discriminator": { - "propertyName": "type", - "mapping": { - "percent": "#/components/schemas/UpdatePercentPayRule", - "fixed": "#/components/schemas/UpdateFixedPayRule", - "percent_after_deduction": "#/components/schemas/UpdatePercentAfterDeductionPayRule" - } - }, - "description": "Replace the rate of an existing pay rule. The inspector it applies to cannot be changed here." + "$ref": "#/components/schemas/UpdatePayRule" } }, "summary": "Replace the rate of a pay rule", diff --git a/server/lib/validations/service.schema.ts b/server/lib/validations/service.schema.ts index 22a56bb83..ef563cd8c 100644 --- a/server/lib/validations/service.schema.ts +++ b/server/lib/validations/service.schema.ts @@ -132,11 +132,21 @@ export const SetServiceInspectorsResponseSchema = createApiResponseSchema(z.obje * from the wire (where a strict schema catches it) into arithmetic (where * nothing does). The UI does the ×100 where a human can see the "%" beside it. * - * The union is discriminated on `type` for the same reason: `deductionCents` is - * meaningful ONLY for `percent_after_deduction`, where the deduction comes off - * the top BEFORE the percentage. A `percent` rule carrying one is ambiguous — - * the caller either wanted the other type or made a mistake — so it is refused - * rather than silently dropped. + * `deductionCents` is meaningful ONLY for `percent_after_deduction`, where the + * deduction comes off the top BEFORE the percentage. A `percent` rule carrying + * one is ambiguous — the caller either wanted the other type or made a mistake — + * so it is REFUSED rather than silently dropped, by `exactlyTheFieldsFor` below. + * + * NOT a `z.discriminatedUnion`, which is what this was first written as and is + * the shape the rule naturally has. Measured: three strict members, plus the + * three `.omit()` members of the update union, expand through hono/client into + * an app-wide RPC type that takes `type-check:app` past its 8 GB heap — tsc + * dies with "Ineffective mark-compacts near heap limit", with no error to read. + * Verified by bisection: the same tree with these routes reverted type-checks + * clean. One strict object with an exhaustive cross-field refinement enforces + * the identical contract — the wire shape, the field names, the rejections and + * the tests are all unchanged — at one plain object's type cost. If hono's RPC + * inference ever gets cheaper, this is the place to put the union back. */ const PercentBps = z.number().int().min(1).max(10000) .describe( @@ -154,41 +164,82 @@ const PayRuleTarget = z.string().min(1).nullable().optional() + 'and one rule per inspector exist per service.', ); -const PercentRule = z.object({ - type: z.literal('percent').describe('A straight share of the line price.'), - userId: PayRuleTarget, - percentBps: PercentBps, -}).strict().openapi('PercentPayRule'); - -const FixedRule = z.object({ - type: z.literal('fixed').describe('A flat amount, whatever the line is priced at.'), - userId: PayRuleTarget, - amountCents: z.number().int().min(1) - .describe('Flat amount in integer cents: 12500 = $125.00.'), -}).strict().openapi('FixedPayRule'); - -const PercentAfterDeductionRule = z.object({ - type: z.literal('percent_after_deduction') - .describe('A share of what is left after a fixed amount comes off the top.'), - userId: PayRuleTarget, - percentBps: PercentBps, - deductionCents: z.number().int().min(1) +const PayRuleTypeEnum = z.enum(['percent', 'fixed', 'percent_after_deduction']) + .describe( + 'percent = a straight share of the line price. fixed = a flat amount whatever the ' + + 'line costs. percent_after_deduction = a share of what is left after a fixed amount ' + + 'comes off the top (materials, a franchise fee).', + ); + +/** Which fields each type requires, and — just as load-bearing — which it forbids. */ +const FIELDS_BY_TYPE = { + percent: { required: ['percentBps'], forbidden: ['amountCents', 'deductionCents'] }, + fixed: { required: ['amountCents'], forbidden: ['percentBps', 'deductionCents'] }, + percent_after_deduction: { required: ['percentBps', 'deductionCents'], forbidden: ['amountCents'] }, +} as const; + +// `| undefined` spelled out on each: the repo runs `exactOptionalPropertyTypes`, +// under which `percentBps?: number` means "absent, or a number" and refuses an +// explicit undefined — which is exactly what zod hands the refinement. +type PayRuleFields = { + type: keyof typeof FIELDS_BY_TYPE; + percentBps?: number | undefined; + amountCents?: number | undefined; + deductionCents?: number | undefined; +}; + +/** + * Only what the refinement uses. `z.RefinementCtx` is generic over the value + * being refined, so naming it here would pin this helper to ONE of the two + * schemas and the other would stop compiling — the whole point is that both + * share it. + */ +interface IssueSink { + addIssue: (issue: { code: 'custom'; path: (string | number)[]; message: string }) => void; +} + +function exactlyTheFieldsFor(v: PayRuleFields, ctx: IssueSink) { + const spec = FIELDS_BY_TYPE[v.type]; + for (const key of spec.required) { + if (v[key] === undefined) { + ctx.addIssue({ code: 'custom', path: [key], message: `${key} is required when type is "${v.type}".` }); + } + } + for (const key of spec.forbidden) { + if (v[key] !== undefined) { + ctx.addIssue({ + code: 'custom', path: [key], + message: `${key} is not meaningful when type is "${v.type}" — remove it, or change the type.`, + }); + } + } +} + +const payRuleRateFields = { + type: PayRuleTypeEnum, + percentBps: PercentBps.optional(), + amountCents: z.number().int().min(1).optional() + .describe('Flat amount in integer cents: 12500 = $125.00. Only on a fixed rule.'), + deductionCents: z.number().int().min(1).optional() .describe( 'Taken off the line price BEFORE the percentage — materials, a franchise fee. ' - + '($500 − $100) × 60% = $240, which is not 60% of $500 less $100.', + + '($500 − $100) × 60% = $240, which is not 60% of $500 less $100. ' + + 'Only on a percent_after_deduction rule; sending it with any other type is a 400.', ), -}).strict().openapi('PercentAfterDeductionPayRule'); - -export const CreatePayRuleSchema = z.discriminatedUnion('type', [ - PercentRule, FixedRule, PercentAfterDeductionRule, -]).describe('What one inspector earns on one catalogue service. See the unit contract above.'); - -/** Same shapes minus the target: `userId` identifies the rule, so moving it is a delete + create. */ -export const UpdatePayRuleSchema = z.discriminatedUnion('type', [ - PercentRule.omit({ userId: true }).strict().openapi('UpdatePercentPayRule'), - FixedRule.omit({ userId: true }).strict().openapi('UpdateFixedPayRule'), - PercentAfterDeductionRule.omit({ userId: true }).strict().openapi('UpdatePercentAfterDeductionPayRule'), -]).describe('Replace the rate of an existing pay rule. The inspector it applies to cannot be changed here.'); +}; + +export const CreatePayRuleSchema = z.object({ ...payRuleRateFields, userId: PayRuleTarget }) + .strict() + .openapi('CreatePayRule') + .superRefine(exactlyTheFieldsFor) + .describe('What one inspector earns on one catalogue service. See the unit contract above.'); + +/** Same shape minus the target: `userId` identifies the rule, so moving it is a delete + create. */ +export const UpdatePayRuleSchema = z.object(payRuleRateFields) + .strict() + .openapi('UpdatePayRule') + .superRefine(exactlyTheFieldsFor) + .describe('Replace the rate of an existing pay rule. The inspector it applies to cannot be changed here.'); /** The read face mirrors the write face — again, no `value`. */ const PayRuleSchema = z.object({ diff --git a/server/services/service/pay-rules.ts b/server/services/service/pay-rules.ts index 1ef1a09dd..b61cdf297 100644 --- a/server/services/service/pay-rules.ts +++ b/server/services/service/pay-rules.ts @@ -51,20 +51,36 @@ export interface PayRuleWire { createdAt: string | null; } -/** Wire shape → the dual-unit column. The ONLY place a percentage becomes `value`. */ +/** + * Wire shape → the dual-unit column. The ONLY place a percentage becomes + * `value`, and the only place cents does. + * + * The schema's cross-field refinement already guarantees the field each type + * needs is present, so `need` re-asserts rather than re-validates — but it DOES + * re-assert, with a throw and not a `!`. The refinement lives in another file, + * and the failure being guarded is "the money column silently took undefined". + */ function toColumns(input: CreatePayRuleInput | UpdatePayRuleInput): { type: ServicePayRule['type']; value: number; deductionCents: number | null; } { + const need = (n: number | undefined, field: string): number => { + if (n === undefined) throw Errors.BadRequest(`${field} is required when type is "${input.type}".`); + return n; + }; if (input.type === 'fixed') { - return { type: 'fixed', value: input.amountCents, deductionCents: null }; + return { type: 'fixed', value: need(input.amountCents, 'amountCents'), deductionCents: null }; } if (input.type === 'percent_after_deduction') { - return { type: 'percent_after_deduction', value: input.percentBps, deductionCents: input.deductionCents }; + return { + type: 'percent_after_deduction', + value: need(input.percentBps, 'percentBps'), + deductionCents: need(input.deductionCents, 'deductionCents'), + }; } // A `percent` rule must carry NO deduction, including when it replaces a // percent_after_deduction rule: a stale value left in the column would keep // coming off the top and the arithmetic would quietly disagree with the type. - return { type: 'percent', value: input.percentBps, deductionCents: null }; + return { type: 'percent', value: need(input.percentBps, 'percentBps'), deductionCents: null }; } /** The column → wire shape. `value` is never echoed under its own name. */ From 541ba55c6b6f156d523ccfa78ca7f4da715b44d4 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 13:25:56 +0800 Subject: [PATCH 33/77] feat(pay-splits): a tenant can now switch pay splits on from Settings (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server half landed the write face; this is the surface a person actually uses. Without it the feature stays off for every tenant that does not write curl by hand, which is every tenant. The widget sits on the service row directly under the qualification line, and is deliberately that widget's twin — same disclosure, same inline panel, same tokens. The two answer adjacent questions about one service ("who MAY run this" / "what they EARN running it"), and a second visual idiom for the second question would read as a different kind of setting. Reusing the pattern reuses its answers too: read-only when there are no members to choose from, save and cancel in the same place. The unit boundary is one function. `toHundredths` is the only ×100 in the path and it is called beside the "%" and "$" a person can see; the API is already strict about receiving basis points and cents under names that say so, so a second conversion site would be the thing that pays someone 0.6%. It rounds rather than floors: 8.2 × 100 is 819.9999999999999 in IEEE-754, and flooring pays 8.19% forever while the number on screen still reads 8.2. That assertion goes red if anyone simplifies it — checked by deleting the fix, not by reading it. Two 409s are prevented rather than reported. The "Applies to" picker drops inspectors who already have a rule and drops the default option once the default exists — the DB refuses those writes, and offering a choice that is always refused is worse than not offering it. The API's 409 is still surfaced verbatim when a race gets through, because that message is written for this screen. The divisor is stated in the panel. A 60% rule with two inspectors pays 30% each, which is correct and is not what someone typing 60 expects; it halves people's pay when it surprises them, so it is said where the number is typed rather than discovered in payroll. `failureMessage` takes `{ json(): Promise }` rather than `Response`: hono/client returns a `ClientResponse<…, 409, "json">` whose body type differs per status, and widening to `Response` at the call site would discard the typing that makes the client worth having. Copy in en + es-419. The glossary gate caught `Sin configurar` for "Not set", which is already `Sin definir` on four other keys — aligned. The noun sense of "Pay" is `Pago` per the declared divergence. Chrome, light and dark: create, edit, switch type (the deduction field and its hint appear only on percent_after_deduction), remove, and the collapsed summary flipping between "Not set" and "1 pay rule". Re-walked after the schema was re-encoded — 62.5 typed in the box comes back as 62.5, which is the readable proof it was stored as 6250 basis points. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- .../settings/services/PayRuleWidget.tsx | 300 ++++++++++++++++++ .../services/ServicesCatalogPanel.tsx | 12 + app/lib/settings-services.test.ts | 49 ++- app/lib/settings-services.ts | 33 ++ app/lib/settings/pay-rules.server.ts | 126 ++++++++ app/routes/settings-services.tsx | 16 +- messages/en/settings-components.json | 24 +- messages/es-419/settings-components.json | 24 +- 8 files changed, 580 insertions(+), 4 deletions(-) create mode 100644 app/components/settings/services/PayRuleWidget.tsx create mode 100644 app/lib/settings/pay-rules.server.ts diff --git a/app/components/settings/services/PayRuleWidget.tsx b/app/components/settings/services/PayRuleWidget.tsx new file mode 100644 index 000000000..55c918d15 --- /dev/null +++ b/app/components/settings/services/PayRuleWidget.tsx @@ -0,0 +1,300 @@ +import { useState } from "react"; +import { useFetcher } from "react-router"; +import type { action } from "~/routes/settings-services"; +import { toHundredths, fromHundredths } from "~/lib/settings-services"; +import { m } from "~/paraglide/messages"; + +/** + * The switch for pay splits (#278), on the row of the service it prices. + * + * It sits beside QualificationWidget and is deliberately its twin — same + * disclosure, same inline panel, same tokens — because the two answer adjacent + * questions about one service ("who MAY run this" / "what they EARN running + * it") and a second visual idiom for the second question would read as a + * different kind of setting. + * + * The unit boundary is the thing to be careful with here. A person types 60 + * meaning 60% and 125 meaning $125.00; the API takes basis points and integer + * cents under names that say so. `toHundredths` is the only ×100 in the path + * and it is called next to the "%" and "$" the person can see. + */ + +export interface PayRule { + id: string; + userId: string | null; + type: "percent" | "fixed" | "percent_after_deduction"; + percentBps: number | null; + amountCents: number | null; + deductionCents: number | null; +} + +interface Member { + id: string; + email: string; + role: string; +} + +interface PayRuleWidgetProps { + serviceId: string; + rules: PayRule[]; + members: Member[]; +} + +const FIELD = + "h-7 px-2 rounded-md border border-ih-border bg-ih-bg-card text-[12px] text-ih-fg-1 " + + "focus:outline-none focus:ring-2 focus:ring-ih-primary"; + +function rateOf(rule: PayRule): string { + return fromHundredths(rule.type === "fixed" ? rule.amountCents : rule.percentBps); +} + +/** + * One rule, editable in place — and also the form for a new one, with `rule` + * absent. The same component both ways on purpose: an "add" form that drifts + * from the "edit" form is how a field ends up settable only when creating. + */ +function RuleRow({ + serviceId, rule, members, takenUserIds, allowDefault, onDone, +}: { + serviceId: string; + rule?: PayRule; + members: Member[]; + takenUserIds: string[]; + /** Is the service-default slot still free (or is it this very rule)? */ + allowDefault: boolean; + onDone?: () => void; +}) { + const fetcher = useFetcher({ key: `pay-rule-${rule?.id ?? "new"}-${serviceId}` }); + const [type, setType] = useState(rule?.type ?? "percent"); + const [rate, setRate] = useState(rule ? rateOf(rule) : ""); + const [deduction, setDeduction] = useState(fromHundredths(rule?.deductionCents)); + const [userId, setUserId] = useState(rule?.userId ?? ""); + const [localError, setLocalError] = useState(null); + + const isPercent = type !== "fixed"; + const busy = fetcher.state !== "idle"; + const result = fetcher.state === "idle" ? fetcher.data : undefined; + const serverError = + result && "intent" in result && "ok" in result && String(result.intent).startsWith("pay-rule") && result.ok === false + ? ((result as { message?: string }).message ?? m.settings_pay_rule_error_save()) + : null; + + function save() { + // Refused here rather than sent: an empty or zero rate would reach the + // API as a 400 the person has to translate back into "the box is blank". + if (toHundredths(rate) === null) return setLocalError(m.settings_pay_rule_error_rate()); + if (type === "percent_after_deduction" && toHundredths(deduction) === null) { + return setLocalError(m.settings_pay_rule_error_rate()); + } + setLocalError(null); + fetcher.submit( + { + intent: "pay-rule-save", + serviceId, + ruleId: rule?.id ?? "", + userId, + type, + rate: String(toHundredths(rate)), + deduction: type === "percent_after_deduction" ? String(toHundredths(deduction)) : "", + }, + { method: "post" }, + ); + onDone?.(); + } + + return ( +
+
+ + + + + + +
+ {!isPercent && $} + setRate(e.target.value)} + aria-label={m.settings_pay_rule_rate()} + /> + {isPercent && %} +
+ + {type === "percent_after_deduction" && ( +
+ + $ + setDeduction(e.target.value)} + aria-label={m.settings_pay_rule_deduction_label()} + /> +
+ )} + + + + {rule ? ( + + + + + + + ) : ( + + )} +
+ + {type === "percent_after_deduction" && ( +

{m.settings_pay_rule_deduction_hint()}

+ )} + {(localError || serverError) && ( +

{localError ?? serverError}

+ )} +
+ ); +} + +export function PayRuleWidget({ serviceId, rules, members }: PayRuleWidgetProps) { + const [open, setOpen] = useState(false); + const [adding, setAdding] = useState(false); + + const summary = + rules.length === 0 + ? m.settings_pay_rule_none() + : rules.length === 1 + ? m.settings_pay_rule_summary_one() + : m.settings_pay_rule_summary_many({ count: rules.length }); + + // A rule already exists for these, and the DB refuses a second one. Hiding + // them from the picker turns a 409 into a choice that was never offered. + const takenUserIds = rules.map((r) => r.userId).filter((id): id is string => id !== null); + const hasDefault = rules.some((r) => r.userId === null); + const everyoneTaken = hasDefault && members.every((mem) => takenUserIds.includes(mem.id)); + + if (members.length === 0) { + return ( +
+ {m.settings_pay_rule_label()} {summary} +
+ ); + } + + return ( +
+ {!open ? ( +
+ + {m.settings_pay_rule_label()} {summary} + + +
+ ) : ( +
+

+ {m.settings_pay_rule_heading()} +

+

{m.settings_pay_rule_explain()}

+ {/* The divisor is not obvious and it halves people's pay when + it surprises them, so it is stated where the number is typed. */} +

{m.settings_pay_rule_divisor_note()}

+ + {rules.length === 0 && !adding && ( +

{m.settings_pay_rule_empty()}

+ )} + + {rules.map((rule) => ( + + ))} + + {adding && ( + setAdding(false)} + /> + )} + +
+ {!adding && !everyoneTaken && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/app/components/settings/services/ServicesCatalogPanel.tsx b/app/components/settings/services/ServicesCatalogPanel.tsx index 06157e487..7fd6dc45b 100644 --- a/app/components/settings/services/ServicesCatalogPanel.tsx +++ b/app/components/settings/services/ServicesCatalogPanel.tsx @@ -1,6 +1,8 @@ import { Form } from "react-router"; import { Table } from "@core/shared-ui"; import { QualificationWidget } from "./QualificationWidget"; +import { PayRuleWidget } from "./PayRuleWidget"; +import type { PayRule } from "./PayRuleWidget"; import { splitDurationMinutes, serviceIsBookable } from "~/lib/settings-services"; import { m } from "~/paraglide/messages"; @@ -24,6 +26,8 @@ interface Member { interface ServicesCatalogPanelProps { services: Service[]; restrictionMap: Record; + /** serviceId -> its pay rules. Empty means pay splits are off for that service. */ + payRuleMap: Record; members: Member[]; /** templateId → template name, for naming the template each service builds from. */ templateNames: Record; @@ -45,6 +49,7 @@ function durationLabel(minutes: number | null): string { export function ServicesCatalogPanel({ services, restrictionMap, + payRuleMap, members, templateNames, editingId = null, @@ -87,6 +92,13 @@ export function ServicesCatalogPanel({ initialUserIds={restrictionMap[svc.id] ?? []} members={members} /> + {/* Directly below the qualification line: who may run this, + and what they earn running it, are one thought. */} + ), }, diff --git a/app/lib/settings-services.test.ts b/app/lib/settings-services.test.ts index 847e1379d..eeb45e76b 100644 --- a/app/lib/settings-services.test.ts +++ b/app/lib/settings-services.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, it, expect } from "vitest"; -import { splitDurationMinutes, serviceIsBookable, didSaveService } from "~/lib/settings-services"; +import { splitDurationMinutes, serviceIsBookable, didSaveService, toHundredths, fromHundredths } from "~/lib/settings-services"; import { makeCreateServiceSchema, makeUpdateServiceSchema } from "~/lib/forms/settings.schema"; /** @@ -127,3 +127,50 @@ describe("makeCreateServiceSchema — duration and template", () => { expect(parse({ name: "Roof", durationMinutes: "1439" }).success).toBe(true); }); }); + +/** + * The pay-rule unit boundary (#278). + * + * The wire is basis points and integer cents; a person types percent and + * dollars. This conversion is the only place the ×100 happens, so it is the + * only place the hundredfold money error can be introduced — 60 sent straight + * through pays 0.6% of the job. + */ +describe("pay-rule human units", () => { + it("turns a typed percent into basis points, not into itself", () => { + expect(toHundredths("60")).toBe(6000); + expect(toHundredths(60)).toBe(6000); + // The boundary the whole feature turns on: 60 must never reach the API + // as 60, which the schema would accept as a legal 0.6%. + expect(toHundredths("60")).not.toBe(60); + }); + + it("keeps a fractional rate exact instead of losing it to float drift", () => { + expect(toHundredths("62.5")).toBe(6250); + // 8.2 * 100 is 819.9999999999999 in IEEE-754. Flooring — the obvious + // way to write this — pays 8.19% forever and the shortfall is invisible + // because the number on screen still reads 8.2. + expect(toHundredths("8.2")).toBe(820); + expect(toHundredths("0.01")).toBe(1); + }); + + it("turns typed dollars into cents the same way", () => { + expect(toHundredths("125")).toBe(12500); + expect(toHundredths("125.50")).toBe(12550); + // 4.35 * 100 is 434.99999999999994 — a cent short of $4.35. + expect(toHundredths("4.35")).toBe(435); + }); + + it("refuses anything that is not a positive number rather than sending NaN", () => { + for (const bad of ["", " ", "abc", "0", "-5", null, undefined]) { + expect(toHundredths(bad)).toBeNull(); + } + }); + + it("round-trips a stored rule back into the form", () => { + expect(fromHundredths(6000)).toBe("60"); + expect(fromHundredths(6250)).toBe("62.5"); + expect(fromHundredths(1)).toBe("0.01"); + expect(fromHundredths(null)).toBe(""); + }); +}); diff --git a/app/lib/settings-services.ts b/app/lib/settings-services.ts index 34bdb8ded..f50d9a5d2 100644 --- a/app/lib/settings-services.ts +++ b/app/lib/settings-services.ts @@ -62,3 +62,36 @@ export function didSaveService( const d = actionData as { ok?: unknown; intent?: unknown }; return d.ok === true && d.intent === intent; } + +/* ------------------------------------------------------------------ */ +/* Pay rules (#278) — the human-units boundary */ +/* ------------------------------------------------------------------ */ + +/** + * The UI's half of the unit contract. + * + * The API speaks basis points and integer cents, and names both on the wire + * (`percentBps`, `amountCents`) so neither can be sent in the wrong unit by + * accident. A person types "60" meaning 60% and "125" meaning $125.00, so the + * ×100 has to happen somewhere — and it happens HERE, one function, beside the + * "%" and "$" the person can see, rather than being spread across a form + * handler. Getting this wrong pays someone 0.6% of a job, which is why it is + * a named function with a test rather than a `* 100` in a JSX callback. + * + * Returns null for anything that is not a positive number, so the caller can + * refuse the submission instead of sending NaN. + */ +export function toHundredths(input: string | number | null | undefined): number | null { + if (input === null || input === undefined || input === "") return null; + const n = typeof input === "number" ? input : Number(String(input).trim()); + if (!Number.isFinite(n) || n <= 0) return null; + // Round, not floor: 62.5% is 6250 bp exactly, and float multiplication + // lands it at 6249.999999999999. + return Math.round(n * 100); +} + +/** The inverse, for filling the form from a stored rule. 6000 → "60", 6250 → "62.5". */ +export function fromHundredths(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return ""; + return String(Math.round(value) / 100); +} diff --git a/app/lib/settings/pay-rules.server.ts b/app/lib/settings/pay-rules.server.ts new file mode 100644 index 000000000..7f714fce1 --- /dev/null +++ b/app/lib/settings/pay-rules.server.ts @@ -0,0 +1,126 @@ +/** + * Settings → Services, pay-rule reads and writes (#278). + * + * Lives beside the route rather than inside it for the same reason the + * qualification widget's panel does: `settings-services.tsx` is a 336-line file + * under a 400-line ratchet, and a fourth intent with a create/update fork would + * have taken it over. Extracting the BFF half keeps the route a router. + * + * The unit contract is the thing to hold on to here. Everything below the + * widget speaks the API's units — basis points and integer cents — and the + * form fields arrive ALREADY converted (`toHundredths` runs in the widget, + * beside the "%" and "$" a person can see). Nothing in this file multiplies by + * a hundred, and nothing in it should start to: two conversion sites is how one + * of them gets applied twice. + */ +import type { createApi } from "~/lib/api-client.server"; +import type { PayRule } from "~/components/settings/services/PayRuleWidget"; +import { m } from "~/paraglide/messages"; + +type Api = ReturnType; + +export interface PayRuleActionResult { + ok: boolean; + intent: "pay-rule-save" | "pay-rule-delete"; + serviceId: string; + message?: string; +} + +/** + * One GET per service, matching how `restrictionMap` is already built on this + * page. A tenant has a handful of catalogue services; a bulk endpoint is the + * fix if that ever stops being true, for both maps at once. + */ +export async function loadPayRuleMap(api: Api, serviceIds: string[]): Promise> { + const results = await Promise.all( + serviceIds.map(async (id) => { + try { + const res = await api.services[":id"]["pay-rules"].$get({ param: { id } }); + if (!res.ok) return [id, [] as PayRule[]] as const; + const body = (await res.json()) as { data?: PayRule[] }; + return [id, body.data ?? []] as const; + } catch { + // A pay-rule read failing must not take down the services page. + return [id, [] as PayRule[]] as const; + } + }), + ); + return Object.fromEntries(results); +} + +/** The rate half of the body, keyed by type so the wrong unit name cannot be sent. */ +function rateBody(type: string, rate: number, deduction: number | null) { + if (type === "fixed") return { type: "fixed" as const, amountCents: rate }; + if (type === "percent_after_deduction") { + return { type: "percent_after_deduction" as const, percentBps: rate, deductionCents: deduction ?? 0 }; + } + return { type: "percent" as const, percentBps: rate }; +} + +// Takes the narrow shape it uses, not `Response`: hono/client hands back a +// typed `ClientResponse<…, 409, "json">` whose body type differs per status, +// and widening it to `Response` at the call site would throw away exactly the +// typing that makes the client worth having. +async function failureMessage(res: { json(): Promise }, fallback: string): Promise { + // The API's 409 for a duplicate rule is written for this screen; showing it + // verbatim is better than a generic "could not save", which is what sent + // people to add the same rule a second time. + const body = await res.json().catch(() => ({})); + const err = (body as { error?: { message?: string }; message?: string }); + return err.error?.message ?? err.message ?? fallback; +} + +/** + * `pay-rule-save` is create OR update, decided by whether the form carries a + * ruleId. One intent because it is one thing a person did, and because a split + * pair drifts — the create path gains a field the edit path never learns about. + */ +export async function savePayRule(api: Api, form: FormData): Promise { + const serviceId = String(form.get("serviceId") ?? ""); + const ruleId = String(form.get("ruleId") ?? ""); + const userId = String(form.get("userId") ?? ""); + const type = String(form.get("type") ?? "percent"); + const rate = Number(form.get("rate")); + const rawDeduction = String(form.get("deduction") ?? ""); + const deduction = rawDeduction === "" ? null : Number(rawDeduction); + + if (!serviceId || !Number.isInteger(rate) || rate <= 0) { + return { ok: false, intent: "pay-rule-save", serviceId, message: m.settings_pay_rule_error_rate() }; + } + + const rateFields = rateBody(type, rate, deduction); + const res = ruleId + ? await api.services[":id"]["pay-rules"][":ruleId"].$put({ + param: { id: serviceId, ruleId }, + json: rateFields, + }) + : await api.services[":id"]["pay-rules"].$post({ + param: { id: serviceId }, + // Absent means the SERVICE DEFAULT. An empty string is not a user id + // and would be refused as an ineligible member. + json: { ...rateFields, ...(userId ? { userId } : {}) }, + }); + + if (!res.ok) { + return { + ok: false, intent: "pay-rule-save", serviceId, + message: await failureMessage(res, m.settings_pay_rule_error_save()), + }; + } + return { ok: true, intent: "pay-rule-save", serviceId }; +} + +export async function deletePayRule(api: Api, form: FormData): Promise { + const serviceId = String(form.get("serviceId") ?? ""); + const ruleId = String(form.get("ruleId") ?? ""); + const res = await api.services[":id"]["pay-rules"][":ruleId"].$delete({ + param: { id: serviceId, ruleId }, + }); + if (!res.ok) { + return { + ok: false, intent: "pay-rule-delete", serviceId, + message: await failureMessage(res, m.settings_pay_rule_error_remove()), + }; + } + return { ok: true, intent: "pay-rule-delete", serviceId }; +} diff --git a/app/routes/settings-services.tsx b/app/routes/settings-services.tsx index 025c0c131..eb21c52aa 100644 --- a/app/routes/settings-services.tsx +++ b/app/routes/settings-services.tsx @@ -15,6 +15,8 @@ import { ServicesCatalogPanel } from "~/components/settings/services/ServicesCat import { ServiceFields } from "~/components/settings/services/ServiceFields"; import { ServiceEditForm } from "~/components/settings/services/ServiceEditForm"; import { DiscountCodesPanel } from "~/components/settings/services/DiscountCodesPanel"; +import { loadPayRuleMap, savePayRule, deletePayRule } from "~/lib/settings/pay-rules.server"; +import type { PayRule } from "~/components/settings/services/PayRuleWidget"; import { m } from "~/paraglide/messages"; export function meta() { @@ -94,6 +96,11 @@ export async function loader({ request, context }: Route.LoaderArgs) { const restrictionMap: Record = {}; for (const r of restrictionResults) restrictionMap[r.serviceId] = r.userIds; + // #278 — what inspectors earn on each service. Without at least one rule, + // pay splits populate nothing, so this map is also the feature's on/off + // state as far as the admin can see it. + const payRuleMap = await loadPayRuleMap(api, rawServices.map((s) => s.id)); + let members: Member[] = []; if (membersRes?.ok) { const mb = (await membersRes.json()) as Record; @@ -111,6 +118,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { services: rawServices, discounts: rawDiscounts, restrictionMap, + payRuleMap, members, templates, }; @@ -119,6 +127,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { services: [] as Service[], discounts: [] as Discount[], restrictionMap: {} as Record, + payRuleMap: {} as Record, members: [] as Member[], templates: [] as TemplateOption[], }; @@ -199,6 +208,10 @@ export async function action({ request, context }: Route.ActionArgs) { param: { id }, json: { active: !active }, }); + } else if (intent === "pay-rule-save") { + return await savePayRule(api, form); + } else if (intent === "pay-rule-delete") { + return await deletePayRule(api, form); } else if (intent === "qualification-save") { const id = String(form.get("serviceId") ?? ""); let userIds: string[]; @@ -261,7 +274,7 @@ export default function SettingsServices() { }); if ("forbidden" in data) return ; - const { services, discounts, restrictionMap, members, templates } = data; + const { services, discounts, restrictionMap, payRuleMap, members, templates } = data; const editingService = services.find((s) => s.id === editingId) ?? null; return ( @@ -323,6 +336,7 @@ export default function SettingsServices() { [t.id, t.name]))} editingId={editingId} diff --git a/messages/en/settings-components.json b/messages/en/settings-components.json index 96dac8181..9a09a8d1c 100644 --- a/messages/en/settings-components.json +++ b/messages/en/settings-components.json @@ -490,5 +490,27 @@ "settings_services_edit_heading": "Edit {name}", "settings_services_edit": "Edit", "settings_services_error_update_failed": "Could not save this service. Try again.", - "settings_qual_change_link": "Change inspectors" + "settings_qual_change_link": "Change inspectors", + "settings_pay_rule_label": "Pay:", + "settings_pay_rule_none": "Not set", + "settings_pay_rule_summary_one": "1 pay rule", + "settings_pay_rule_summary_many": "{count} pay rules", + "settings_pay_rule_change_link": "Change pay", + "settings_pay_rule_heading": "What inspectors earn on this service", + "settings_pay_rule_explain": "An inspection records these once, when it is assigned, and then freezes them. Editing a rule never restates pay that was already recorded.", + "settings_pay_rule_divisor_note": "Shared between everyone eligible on the inspection: 60% with two inspectors pays 30% each.", + "settings_pay_rule_empty": "No rules yet, so nothing is recorded for this service.", + "settings_pay_rule_applies_to": "Applies to", + "settings_pay_rule_everyone": "Everyone without their own rule", + "settings_pay_rule_rate": "Rate", + "settings_pay_rule_type_percent": "Percent of the line", + "settings_pay_rule_type_fixed": "Flat amount", + "settings_pay_rule_type_after_deduction": "Percent after a deduction", + "settings_pay_rule_deduction_label": "Taken off first", + "settings_pay_rule_deduction_hint": "Comes off the line price before the percentage.", + "settings_pay_rule_add": "Add a pay rule", + "settings_pay_rule_remove": "Remove", + "settings_pay_rule_error_rate": "Enter a rate greater than zero.", + "settings_pay_rule_error_save": "Could not save the pay rule.", + "settings_pay_rule_error_remove": "Could not remove the pay rule." } diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index ebd6e9b8e..a35a39cd6 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -490,5 +490,27 @@ "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" + "settings_qual_change_link": "Cambiar los inspectores", + "settings_pay_rule_label": "Pago:", + "settings_pay_rule_none": "Sin definir", + "settings_pay_rule_summary_one": "1 regla de pago", + "settings_pay_rule_summary_many": "{count} reglas de pago", + "settings_pay_rule_change_link": "Cambiar el pago", + "settings_pay_rule_heading": "Lo que ganan los inspectores por este servicio", + "settings_pay_rule_explain": "La inspección registra estos montos una sola vez, al asignarse, y luego los congela. Editar una regla nunca modifica el pago ya registrado.", + "settings_pay_rule_divisor_note": "Se reparte entre todos los inspectores elegibles de la inspección: 60 % con dos inspectores paga 30 % a cada uno.", + "settings_pay_rule_empty": "Aún no hay reglas, así que no se registra nada para este servicio.", + "settings_pay_rule_applies_to": "Se aplica a", + "settings_pay_rule_everyone": "Todos los que no tengan su propia regla", + "settings_pay_rule_rate": "Tarifa", + "settings_pay_rule_type_percent": "Porcentaje de la línea", + "settings_pay_rule_type_fixed": "Monto fijo", + "settings_pay_rule_type_after_deduction": "Porcentaje después de una deducción", + "settings_pay_rule_deduction_label": "Se descuenta primero", + "settings_pay_rule_deduction_hint": "Se resta del precio de la línea antes del porcentaje.", + "settings_pay_rule_add": "Agregar una regla de pago", + "settings_pay_rule_remove": "Quitar", + "settings_pay_rule_error_rate": "Ingrese una tarifa mayor que cero.", + "settings_pay_rule_error_save": "No se pudo guardar la regla de pago.", + "settings_pay_rule_error_remove": "No se pudo quitar la regla de pago." } From 6488e22a864539422ac436c840dad0e7fff5e275 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 14:07:20 +0800 Subject: [PATCH 34/77] refactor(invoices): the payment ledger becomes its own sub-router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `server/api/invoices.ts` sat at 534 lines against a 535 cap, so the next change to it — the partial-refund writer #19 Task 3 needs — could not add even an import line. This is a pure move: no route path, operationId, schema, status code or handler body changes, and the OpenAPI snapshot is byte-identical (tests/unit/mcp all green without regeneration). The seam is the sub-resource, not the line count. Everything under `/{id}/payments` is one thing: append-only rows describing money that moved, gated on the `financial` capability rather than role alone, with its own correction mechanism (a reversing row, never an edit) and its own rule about what reaches QuickBooks. What stays behind is the invoice ROW — create, list, mark sent, mark paid, void, and the request-payment orchestration. `grep -c orderPayments` now answers which file you want. Composed with `.route('/', …)`, the shape every other split router here uses (calendar, inspections, admin, auth). That preserves the hono/client RPC path exactly, which is the thing a reader should doubt: the page still calls `api.invoices[":id"].payments.$post` and `type-check:app` proves it — the api tsconfig alone would not have. `INVOICE_ID` moved to `invoice.schema.ts` because two route modules now need it and importing it back from the parent would be a cycle. It also belongs there under the repo's own rule that schemas live in `lib/validations`, and it carries its comment with it: `invoices.id` is opaque TEXT and a route demanding a UUID 400s a real invoice. The baseline ENTRY is removed rather than tightened. At 339 lines the file is governed by the ordinary 400-line rule again; re-baselining at 339 would have recreated the same wall a little further out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- scripts/file-size-baseline.json | 1 - server/api/invoices.ts | 205 +--------------------- server/api/invoices/payments.ts | 214 +++++++++++++++++++++++ server/lib/validations/invoice.schema.ts | 9 + 4 files changed, 228 insertions(+), 201 deletions(-) create mode 100644 server/api/invoices/payments.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 8c782df08..5ef110141 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -34,7 +34,6 @@ "app/routes/settings-profile.tsx": 548, "server/api/calendar.ts": 547, "app/components/NewInspectionWizard.tsx": 539, - "server/api/invoices.ts": 535, "server/services/inspection/inspection-photo.service.ts": 531, "server/api/inspections/media-studio.ts": 530, "app/routes/settings-communication-templates.tsx": 525, diff --git a/server/api/invoices.ts b/server/api/invoices.ts index 24e4fa923..6174c9644 100644 --- a/server/api/invoices.ts +++ b/server/api/invoices.ts @@ -5,15 +5,12 @@ import { requireRole } from '../lib/middleware/rbac'; import { requireCapability } from '../lib/middleware/require-capability'; import { CreateInvoiceSchema, + INVOICE_ID, InvoiceResponseSchema, - CorrectPaymentSchema, 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'; @@ -25,15 +22,7 @@ import { getTenantId, getDrizzle } from '../lib/route-helpers'; import { resolveLocale } from '../lib/locale'; import { formatCurrency } from '../lib/format'; import { qboPaymentKey } from '../lib/qbo-payment-key'; - -/** - * `invoices.id` is an opaque TEXT id, so the route must not demand a UUID - * shape it never promised. The mark-paid endpoint rejected one with a 400 that - * the page then swallowed, leaving an operator who had just banked a cheque - * looking at an unchanged "SENT" pill. Same defect as the contacts id contract - * and as the `inspectorId` `.uuid()` in IA-87. - */ -const INVOICE_ID = z.string().trim().min(1); +import invoicePaymentRoutes from './invoices/payments'; const listInvoicesRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', @@ -138,98 +127,10 @@ 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' })); - -/** - * 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', - 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() + // `/{id}/payments*` — the append-only payment ledger, its own sub-resource + // with its own `financial` capability gate. See ./invoices/payments. + .route('/', invoicePaymentRoutes) .openapi(listInvoicesRoute, async (c) => { const rows = await c.var.services.invoice.listInvoices(c.get('tenantId')); return c.json({ success: true as const, data: rows }, 200); @@ -302,102 +203,6 @@ 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), - appended.occurredAt, - ), - ); - } - - 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(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); - 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/api/invoices/payments.ts b/server/api/invoices/payments.ts new file mode 100644 index 000000000..fe00f8533 --- /dev/null +++ b/server/api/invoices/payments.ts @@ -0,0 +1,214 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { createApiRouter } from '../../lib/openapi-router'; +import { requireRole } from '../../lib/middleware/rbac'; +import { requireCapability } from '../../lib/middleware/require-capability'; +import { + CorrectPaymentSchema, + INVOICE_ID, + PaymentLedgerRowSchema, + RecordOfflinePaymentSchema, +} from '../../lib/validations/invoice.schema'; +import { safeISODate } from '../../lib/date'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; +import { qboPaymentKey } from '../../lib/qbo-payment-key'; + +/** + * The payment-ledger sub-resource of an invoice — everything under + * `/api/invoices/{id}/payments`. + * + * It is its own module because the ledger is its own thing: append-only rows + * describing money that moved, with their own capability gate (`financial`), + * their own correction mechanism, and their own relationship to QuickBooks. + * The parent module owns the invoice ROW — create, send, void, delete. + */ + +/** + * 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' })); + +/** + * 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', + 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 invoicePaymentRoutes = createApiRouter() + .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), + appended.occurredAt, + ), + ); + } + + 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(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); + return c.json({ success: true as const, data: rows }, 200); + }); + +export default invoicePaymentRoutes; diff --git a/server/lib/validations/invoice.schema.ts b/server/lib/validations/invoice.schema.ts index 8674ac8b4..a933863d6 100644 --- a/server/lib/validations/invoice.schema.ts +++ b/server/lib/validations/invoice.schema.ts @@ -1,6 +1,15 @@ import { z } from '@hono/zod-openapi'; import { PublicBrandSchema } from './public-brand.schema'; +/** + * `invoices.id` is an opaque TEXT id, so a route must not demand a UUID shape it + * never promised. The mark-paid endpoint rejected one with a 400 that the page + * then swallowed, leaving an operator who had just banked a cheque looking at an + * unchanged "SENT" pill. Same defect as the contacts id contract and as the + * `inspectorId` `.uuid()` in IA-87. + */ +export const INVOICE_ID = z.string().trim().min(1); + const LineItemSchema = z.object({ description: z.string().min(1).max(200).describe('TODO describe description field for the OpenInspection MCP integration'), amountCents: z.number().int().min(0).describe('TODO describe amountCents field for the OpenInspection MCP integration'), From fafbff9faa1764afcf546f18a51494232d5222a7 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 14:18:04 +0800 Subject: [PATCH 35/77] refactor(invoices): split the ledger off InvoiceService, and export the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `invoice.service.ts` sat at 512 lines against a 513 cap. Pure move: not one behavioural line changed, and the 612 tests across invoices, idempotency, billing, qbo, client-portal and contacts pass untouched. TWO MODULES, NOT ONE, and the second is the point. `syncInspectionPaymentGate` was a PRIVATE method called by `markRefunded`, `voidInvoice` and `correctPayment`. It is the thing that stops a report staying publicly unlocked after the payment behind it is reversed, and being private meant a new money writer either moved into this class or silently skipped it — invisible to every test that does not read `inspections`. The #19 Task 3 audit found exactly that omission in the partial-refund plan. So it is now `server/services/invoice-payment-gate.ts`, exported, with the invariant written above it and a spec that already carries its name (`tests/unit/invoices/invoice-payment-gate.spec.ts`). Task 3 can call it; it cannot forget it exists. `invoice-payments.service.ts` takes the six ledger methods — markPaid, markPartial, recordOfflinePayment, correctPayment, listPayments, markRefunded — as free functions over the drizzle handle, the shape `payment-ledger.service.ts` already uses and which these all orchestrate. The boundary is mechanical rather than tasteful: InvoiceService owns the invoice ROW, this module owns anything that reads or appends `order_payments`, and `payment-ledger.service` now has exactly one value importer. A money writer that finds itself importing it in the other file is in the wrong file. `InvoiceService` keeps a one-line method per function. Not decoration: `c.var.services.invoice.x()` and the QBO reconciler's bound callbacks are the established call shape, and rewriting eight production call sites and six specs would have made this a refactor with a blast radius instead of a move. Parameter ORDER is preserved exactly as each method declared it, including the inconsistent `(id, tenantId)` / `(tenantId, id)` pair — both are `string`, so tidying that up mid-move is how the two get swapped without a type error. Baseline ENTRY removed, not tightened. 234 lines is under the ordinary 400 rule, which is the durable outcome; re-baselining at 234 would have rebuilt the wall. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- scripts/file-size-baseline.json | 1 - server/services/invoice-payment-gate.ts | 34 ++ server/services/invoice-payments.service.ts | 359 ++++++++++++++++++++ server/services/invoice.service.ts | 330 ++---------------- 4 files changed, 419 insertions(+), 305 deletions(-) create mode 100644 server/services/invoice-payment-gate.ts create mode 100644 server/services/invoice-payments.service.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 5ef110141..fd343a60f 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -42,7 +42,6 @@ "server/api/inspections/publish.ts": 521, "server/api/bookings/agreement.ts": 519, "app/components/settings/ManagedComplianceWizard.tsx": 514, - "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, diff --git a/server/services/invoice-payment-gate.ts b/server/services/invoice-payment-gate.ts new file mode 100644 index 000000000..02e5dbddc --- /dev/null +++ b/server/services/invoice-payment-gate.ts @@ -0,0 +1,34 @@ +/** + * The report's payment gate, re-synced after an invoice loses paid status. + * + * `inspections.payment_status = 'paid'` is what unlocks a report publicly, and + * it is a CACHE of "some unvoided invoice on this inspection is paid". Every + * writer that can falsify that sentence — refund, correction, void, delete — + * has to call this, or the report stays unlocked with no backing payment. + * + * It lives in its own module, exported, precisely because that list keeps + * growing: it was a private method on `InvoiceService`, which meant a new + * writer either moved into that class or quietly skipped the re-sync. Skipping + * it is invisible in every test that does not read `inspections`. + * + * Only DOWNGRADES a 'paid' gate. Partial, unpaid, and inspections that still + * have another paid invoice are left exactly as they are. + */ +import { and, eq, isNotNull, isNull } from 'drizzle-orm'; +import { inspections } from '../lib/db/schema'; +import { invoices } from '../lib/db/schema/invoice'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; + +export async function syncInspectionPaymentGate( + db: DrizzleD1Database, + tenantId: string, + inspectionId: string | null, +): Promise { + if (!inspectionId) return; + const stillPaid = await db.select({ id: invoices.id }).from(invoices) + .where(and(eq(invoices.tenantId, tenantId), eq(invoices.inspectionId, inspectionId), isNotNull(invoices.paidAt), isNull(invoices.voidedAt))) + .limit(1).get(); + if (stillPaid) return; + await db.update(inspections).set({ paymentStatus: 'unpaid' }) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId), eq(inspections.paymentStatus, 'paid'))); +} diff --git a/server/services/invoice-payments.service.ts b/server/services/invoice-payments.service.ts new file mode 100644 index 000000000..dafdd03fb --- /dev/null +++ b/server/services/invoice-payments.service.ts @@ -0,0 +1,359 @@ +/** + * The payment-ledger face of an invoice. + * + * The boundary against `invoice.service.ts` is checkable rather than a matter + * of taste: `InvoiceService` owns the invoice ROW — create, read, mark sent, + * void, delete, earnings — and this module owns the LEDGER, meaning every path + * that reads or appends `order_payments`, whether directly or through + * `payment-ledger.service`. The check is mechanical: `payment-ledger.service` + * has exactly one value importer among the two, and it is this file. A new + * money writer that finds itself importing it in the other one is in the + * wrong place. + * + * These are free functions taking the drizzle handle, the same shape as + * `payment-ledger.service.ts` which they all orchestrate; `InvoiceService` + * keeps a one-line method for each so no caller has to change. Parameter + * ORDER is preserved exactly as each method declared it — `(id, tenantId)` for + * some and `(tenantId, id)` for others — because both are `string` and + * "tidying" that up during a move is how the two get swapped in silence. + */ +import { and, asc, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { invoices } from '../lib/db/schema/invoice'; +import { orderPayments } from '../lib/db/schema/order-payment'; +import { users } from '../lib/db/schema'; +import { Errors } from '../lib/errors'; +import { safeISODate } from '../lib/date'; +import type { PaymentMethod } from '../lib/payment-method'; +import { + recordPayment, + recomputeInvoicePaymentState, + getNetReceivedCents, + seedLedgerFromInvoiceRecord, +} from './payment-ledger.service'; +import type { AppendedPayment } from './payment-ledger.service'; +import { syncInspectionPaymentGate } from './invoice-payment-gate'; + +/** Body of an operator-recorded offline payment. */ +export interface OfflinePaymentInput { + amountCents: number; + method: 'check' | 'cash' | 'offline' | 'other'; + occurredAt: Date; + note?: string | null; + allowOverpayment?: boolean; + recordedBy: string; +} + +/** Body of a payment correction. */ +export interface PaymentCorrectionInput { + correctedAmountCents: number; + reason: string; + recordedBy: string; +} + +/** + * 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. + */ +export async function markPaid( + db: DrizzleD1Database, + id: string, + tenantId: string, + source: 'oi' | 'qbo' = 'oi', + method?: PaymentMethod, +): Promise { + 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. 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; + 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); + void source; // consumed by route handler to decide QBO sync + if (outstanding > 0) { + return recordPayment(db, tenantId, { + invoiceId: id, + inspectionId: existing.inspectionId, + kind: 'balance', + amountCents: outstanding, + method: paymentMethod ?? 'offline', + }); + } + // 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; +} + +/** + * 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. + */ +export async function recordOfflinePayment( + db: DrizzleD1Database, + tenantId: string, + id: string, + input: OfflinePaymentInput, +): Promise { + 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) { + // 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. 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; +} + +/** + * 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. + */ +export async function correctPayment( + db: DrizzleD1Database, + tenantId: string, + id: string, + paymentId: string, + input: PaymentCorrectionInput, +) { + 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 syncInspectionPaymentGate(db, tenantId, original.inspectionId); + + // 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. + * + * 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. + */ +export async function listPayments( + db: DrizzleD1Database, + tenantId: string, + id: string, +) { + // 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 + * caller as `amountCents - amountPaidCents` because the invoice total is + * the money authority, not any external system's view of it. + * + * 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. + * + * Returns the appended row (or `null` when the figure had not moved) on the + * same contract as `markPaid`. + */ +export async function markPartial( + db: DrizzleD1Database, + id: string, + tenantId: string, + source: 'oi' | 'qbo' = 'oi', + amountPaidCents: number, +): Promise { + 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 null; + } + return 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. + */ +export async function markRefunded( + db: DrizzleD1Database, + id: string, + tenantId: string, +): Promise { + 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 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 syncInspectionPaymentGate(db, tenantId, existing.inspectionId); +} diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts index e59f8a421..66167a5e6 100644 --- a/server/services/invoice.service.ts +++ b/server/services/invoice.service.ts @@ -1,20 +1,16 @@ import { drizzle } from 'drizzle-orm/d1'; -import { eq, and, asc, desc, sql, isNotNull, isNull } from 'drizzle-orm'; +import { eq, and, desc, sql } from 'drizzle-orm'; import { invoices } from '../lib/db/schema/invoice'; -import { orderPayments } from '../lib/db/schema/order-payment'; -import { inspections, tenantConfigs, users } from '../lib/db/schema'; +import { tenantConfigs } from '../lib/db/schema'; import { Errors } from '../lib/errors'; 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'; import type { AppendedPayment } from './payment-ledger.service'; +import { syncInspectionPaymentGate } from './invoice-payment-gate'; +import * as ledger from './invoice-payments.service'; +import type { OfflinePaymentInput, PaymentCorrectionInput } from './invoice-payments.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'; @@ -144,315 +140,41 @@ 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. - * - * 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. + /* + * The payment-ledger surface. Bodies live in `./invoice-payments.service` + * — this class owns the invoice ROW, that module owns every read and write + * of `order_payments`. Kept as methods because `c.var.services.invoice` and + * the QBO reconciler's bound callbacks are the established call shape. */ - 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. 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; - 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); - void source; // consumed by route handler to decide QBO sync - if (outstanding > 0) { - return recordPayment(db, tenantId, { - invoiceId: id, - inspectionId: existing.inspectionId, - kind: 'balance', - amountCents: outstanding, - method: paymentMethod ?? 'offline', - }); - } - // 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; + /** @see ledger.markPaid — returns the ledger row appended, or null. */ + async markPaid(id: string, tenantId: string, source: 'oi' | 'qbo' = 'oi', method?: PaymentMethod): Promise { + return ledger.markPaid(this.getDrizzle(), id, tenantId, source, method); } - /** - * 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) { - // 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. 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; + /** @see ledger.recordOfflinePayment — money that moved outside the system. */ + async recordOfflinePayment(tenantId: string, id: string, input: OfflinePaymentInput): Promise { + return ledger.recordOfflinePayment(this.getDrizzle(), tenantId, id, input); } - /** - * 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 }; + /** @see ledger.correctPayment — appends a reversing row; never edits. */ + async correctPayment(tenantId: string, id: string, paymentId: string, input: PaymentCorrectionInput) { + return ledger.correctPayment(this.getDrizzle(), tenantId, id, paymentId, input); } - /** - * 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. - */ + /** @see ledger.listPayments — oldest movement first. */ 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) })); + return ledger.listPayments(this.getDrizzle(), tenantId, id); } - /** - * 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. - * - * 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. - * - * Returns the appended row (or `null` when the figure had not moved) on the - * same contract as `markPaid`. - */ + /** @see ledger.markPartial — `amountPaidCents` is CUMULATIVE received. */ 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'); - - const delta = amountPaidCents - await getNetReceivedCents(db, tenantId, id); - if (delta === 0) { - await recomputeInvoicePaymentState(db, tenantId, id); - return null; - } - return 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, - }); + return ledger.markPartial(this.getDrizzle(), id, tenantId, source, amountPaidCents); } - /** - * 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. - */ + /** @see ledger.markRefunded — reverses everything received. */ 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 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); - } - - /** - * After an invoice loses its paid status (refund/delete), clear a now-stale - * `inspections.payment_status = 'paid'` report gate when NO paid invoice - * remains for that inspection. Without this the report stays publicly - * unlocked with no backing payment. Only downgrades a 'paid' gate; partial/ - * unpaid and inspections with another paid invoice are left untouched. - */ - private async syncInspectionPaymentGate(inspectionId: string | null, tenantId: string): Promise { - if (!inspectionId) return; - const db = this.getDrizzle(); - const stillPaid = await db.select({ id: invoices.id }).from(invoices) - .where(and(eq(invoices.tenantId, tenantId), eq(invoices.inspectionId, inspectionId), isNotNull(invoices.paidAt), isNull(invoices.voidedAt))) - .limit(1).get(); - if (stillPaid) return; - await db.update(inspections).set({ paymentStatus: 'unpaid' }) - .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId), eq(inspections.paymentStatus, 'paid'))); + return ledger.markRefunded(this.getDrizzle(), id, tenantId); } async setQboSyncStatus(id: string, tenantId: string, status: 'synced' | 'pending' | 'failed'): Promise { @@ -475,7 +197,7 @@ export class InvoiceService { if (!existing || existing.voidedAt) return; await db.update(invoices).set({ voidedAt: new Date() }) .where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantId))); - await this.syncInspectionPaymentGate(existing.inspectionId, tenantId); + await syncInspectionPaymentGate(db, tenantId, existing.inspectionId); } /** From d6ec4ec215a095a459aa231644a0398cb6d9e55e Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 14:24:26 +0800 Subject: [PATCH 36/77] refactor(invoices): the row's status and its verbs become components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app/routes/invoices.tsx` sat at 509 lines against a 510 cap. Pure move: the rendered markup is byte-identical, every class name and message key is the one that was there, and all 17 co-located tests — which drive the real DOM through `createRoutesStub` and click the buttons — pass unchanged. Two cells, two components, into `app/components/invoices/` beside the three that already live there. `InvoiceRowActions` is the one that earns it. That cell holds a small state machine — a row is either showing its verbs or showing the payment-method picker, and which verbs exist depends on whether the invoice is paid and whether an inspection stands behind it. The page keeps the state (one picker open at a time across the table) and passes it in; the component owns what each branch renders. `getPayMethods` went with it, because the picker is its only caller and the thunk exists for a reason worth keeping next to its use: Paraglide labels must resolve inside the per-request locale scope, not freeze at import. `InvoiceStatusCell` takes the pill, `STATUS_TONE` and `methodLabel`. Small, but it is the whole of one column and it was the only remaining reason the route imported `Pill` and `PillTone`. The IA-122 rationale moved to the control it explains. The client column carried twenty-six lines describing, among other things, why the Action column looks the way it does — with the Action column now in another file that comment would have been the first thing to drift. What is left in the route says what the client cell decides (the name is text, the row has no click handler) and points at the component for the rest. Both components declare their own structural prop type, the convention `PaymentsModal` and `InvoiceAmountCell` already set: "only the fields this surface reads". Baseline ENTRY removed, not tightened — 367 lines is back under the ordinary 400 rule. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- app/components/invoices/InvoiceRowActions.tsx | 146 ++++++++++++++ app/components/invoices/InvoiceStatusCell.tsx | 49 +++++ app/routes/invoices.tsx | 189 +++--------------- scripts/file-size-baseline.json | 1 - 4 files changed, 219 insertions(+), 166 deletions(-) create mode 100644 app/components/invoices/InvoiceRowActions.tsx create mode 100644 app/components/invoices/InvoiceStatusCell.tsx diff --git a/app/components/invoices/InvoiceRowActions.tsx b/app/components/invoices/InvoiceRowActions.tsx new file mode 100644 index 000000000..6b3917304 --- /dev/null +++ b/app/components/invoices/InvoiceRowActions.tsx @@ -0,0 +1,146 @@ +import { Link } from "react-router"; +import { m } from "~/paraglide/messages"; + +/** + * The Action column of the invoices table — every verb one invoice offers. + * + * It is a component rather than an inline cell because it holds a small state + * machine of its own: the row is either showing its verbs or showing the + * payment-method picker, and which verbs exist depends on whether the invoice + * is paid and whether it has an inspection behind it. The page owns the state + * (one picker open at a time across the table); this owns what each branch + * renders. + * + * IA-122/IA-123 are the reason the branches look the way they do: + * - "View inspection" appears on EVERY row that has one, paid or not. + * Previously only paid rows got a button, so the invoice actually needing + * chasing was the one you could not click through from. It is one control + * for one destination — the client's name is not a link, because a name + * reads as a name and announced as "View inspection" to a screen reader. + * - "Payments" appears on every row including paid ones. "Mark paid" answers + * "is it settled?"; this answers the question a dispute turns on — which + * payments arrived, when, by what means, and who wrote them down. + * - "Void" exists at all because a standalone invoice used to end its life as + * a bare "—": nothing to open, nothing to correct. DELETE /api/invoices/{id} + * voids rather than deletes and the row survives for the audit trail, so the + * verb is "Void" and the confirm copy says the same. + */ + +/** Only the invoice fields this surface reads; the page passes its own row. */ +type ActionInvoice = { + id: string; + status: "draft" | "sent" | "paid" | "partial" | "void"; + inspectionId: string | null; +}; + +// Built as a thunk (not a module-level const) so the Paraglide `m.*()` labels +// resolve inside the per-request locale scope instead of freezing at import. +function getPayMethods() { + return [ + { value: "check", label: m.invoices_pay_method_check() }, + { value: "cash", label: m.invoices_pay_method_cash() }, + { value: "offline", label: m.invoices_pay_method_offline() }, + { value: "other", label: m.invoices_pay_method_other() }, + ] as const; +} + +interface Props { + invoice: ActionInvoice; + /** This row has a submission in flight; its destructive verbs are disabled. */ + busy: boolean; + /** This row is the one showing the payment-method picker. */ + pickerOpen: boolean; + onOpenPicker: () => void; + onCancelPicker: () => void; + onOpenPayments: () => void; + onMarkPaid: (method: string) => void; + onVoid: () => void; +} + +export function InvoiceRowActions({ + invoice, + busy, + pickerOpen, + onOpenPicker, + onCancelPicker, + onOpenPayments, + onMarkPaid, + onVoid, +}: Props) { + const isPaid = invoice.status === "paid"; + + const payments = ( + + ); + + const viewInspection = invoice.inspectionId ? ( + + {m.invoices_row_view_inspection()} + + ) : null; + + const voidAction = ( + + ); + + if (isPaid) { + return ( +
+ {viewInspection} + {payments} + {voidAction} +
+ ); + } + if (pickerOpen) { + return ( +
+ {m.invoices_paid_by()} + {getPayMethods().map((method) => ( + + ))} + +
+ ); + } + return ( +
+ {viewInspection} + {payments} + + {voidAction} +
+ ); +} diff --git a/app/components/invoices/InvoiceStatusCell.tsx b/app/components/invoices/InvoiceStatusCell.tsx new file mode 100644 index 000000000..6f5b7bbb9 --- /dev/null +++ b/app/components/invoices/InvoiceStatusCell.tsx @@ -0,0 +1,49 @@ +import { Pill, type PillTone } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; + +/** + * The Status column of the invoices table. + * + * The pill states the lifecycle status, and for a PAID invoice it also states + * HOW — "PAID · Cheque". That second half is not decoration: on a page whose + * job is chasing money, "we have it" and "we have it as a cheque somebody still + * has to bank" are different facts, and the method is the only place the + * distinction shows without opening the ledger. + */ + +/** Only the invoice fields this cell reads; the page passes its own row. */ +type StatusInvoice = { + status: "draft" | "sent" | "paid" | "partial" | "void"; + paymentMethod: "card" | "check" | "cash" | "offline" | "other" | null; +}; + +const STATUS_TONE: Record = { + paid: "sat", + partial: "monitor", + sent: "info", + draft: "neutral", + void: "neutral", +}; + +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]; +} + +export function InvoiceStatusCell({ invoice }: { invoice: StatusInvoice }) { + const isPaid = invoice.status === "paid"; + return ( + + {invoice.status} + {isPaid && invoice.paymentMethod && ( + · {methodLabel(invoice.paymentMethod)} + )} + + ); +} diff --git a/app/routes/invoices.tsx b/app/routes/invoices.tsx index fc721b20b..cb283127b 100644 --- a/app/routes/invoices.tsx +++ b/app/routes/invoices.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import { useLoaderData, useFetcher, Link } from "react-router"; +import { useLoaderData, useFetcher } from "react-router"; import type { Route } from "./+types/invoices"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; -import { PageHeader, Card, StatCard, Button, EmptyState, Table, Pill, Banner, Modal, type PillTone } from "@core/shared-ui"; +import { PageHeader, Card, StatCard, Button, EmptyState, Table, Banner, Modal } from "@core/shared-ui"; import { formatCurrency, formatDate } from "~/lib/format"; import { InvoiceAmountCell } from "~/components/invoices/InvoiceAmountCell"; import { useDisplayLocale, useDisplayCurrency } from "~/hooks/useSessionContext"; @@ -11,6 +11,8 @@ 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"; +import { InvoiceStatusCell } from "~/components/invoices/InvoiceStatusCell"; +import { InvoiceRowActions } from "~/components/invoices/InvoiceRowActions"; export function meta() { return [{ title: m.invoices_meta_title() }]; @@ -75,17 +77,6 @@ export async function loader({ request, context }: Route.LoaderArgs) { } } -// Built as a thunk (not a module-level const) so the Paraglide `m.*()` labels -// resolve inside the per-request locale scope instead of freezing at import. -function getPayMethods() { - return [ - { value: "check", label: m.invoices_pay_method_check() }, - { value: "cash", label: m.invoices_pay_method_cash() }, - { value: "offline", label: m.invoices_pay_method_offline() }, - { value: "other", label: m.invoices_pay_method_other() }, - ] as const; -} - export async function action({ request, context }: Route.ActionArgs) { const token = await requireToken(context, request); const fd = await request.formData(); @@ -206,25 +197,6 @@ export async function action({ request, context }: Route.ActionArgs) { return { intent: null, ok: false, error: null }; } -const STATUS_TONE: Record = { - paid: "sat", - partial: "monitor", - sent: "info", - draft: "neutral", - void: "neutral", -}; - -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]; -} - export default function InvoicesPage() { const { invoices, inspections, loadFailed } = useLoaderData(); const fetcher = useFetcher(); @@ -353,149 +325,36 @@ export default function InvoicesPage() { empty={} columns={[ { - // IA-97 — the list was a dead end: no way from an invoice to the - // inspection it bills, and a PAID row had no control at all, so - // the page looked broken ("why is there no action?"). - // - // The identity cell links, NOT the whole row via `onRowClick`: - // the Action column holds real buttons, and a row-wide handler - // would fire on every "Mark paid" click too. A is also - // keyboard-reachable and middle-clickable, which a `` - // is not. - // - // It points at the hub rather than growing invoice actions here. - // The hub already owns them — including the tokenized pay link, - // which is minted per recipient (IA-34) and would cost one token - // issue per row to reproduce on a list. + // IA-122 — the name is TEXT, not a link, and the row has no + // `onRowClick`. It used to be a hover-only Link to the + // inspection, which gave three rows three different ways to + // reach one destination and announced a person's name as "View + // inspection". The single labelled control lives in the Action + // column (see InvoiceRowActions); a row-wide handler would also + // fire on every button inside it. label: m.invoices_col_client(), - // IA-122 — this used to be a Link to the INSPECTION, styled only - // on hover. Three rows could therefore offer the same destination - // three different ways: an unpaid row had nothing but this - // invisible name link, a paid row had the name link AND a button - // pointing at the identical href, and a standalone invoice had a - // bare "—". The row needing follow-up was the hardest to act on. - // - // A client's name is also the wrong label for "open the - // inspection" — it read as a name to a sighted user and as "View - // inspection" to anything using the title attribute. One - // destination, one control, and it lives in the Action column. cell: (invoice) => ( {invoice.clientName || "—"} ), }, { 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(), - cell: (invoice) => { - const isPaid = invoice.status === "paid"; - return ( - - {invoice.status} - {isPaid && invoice.paymentMethod && ( - · {methodLabel(invoice.paymentMethod)} - )} - - ); - }, - }, + { label: m.invoices_col_status(), cell: (invoice) => }, { label: m.invoices_col_action(), align: "right", - cell: (invoice) => { - 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 = ( - - ); - - // 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 - // chasing was the one you could not click through from. - const viewInspection = invoice.inspectionId ? ( - - {m.invoices_row_view_inspection()} - - ) : null; - - // IA-123 — a standalone invoice (no inspection) used to end its - // life as a bare "—": nothing to open, nothing to correct. Void - // is the verb that was missing, and it already existed on the - // server — DELETE /api/invoices/{id} voids rather than deletes, - // "the row is preserved for the audit trail", with no caller - // anywhere in the app. An invoice raised in error had no way - // out except leaving it standing. - const voidAction = ( - - ); - - if (isPaid) { - return ( -
- {viewInspection} - {payments} - {voidAction} -
- ); - } - if (pickerFor === invoice.id) { - return ( -
- {m.invoices_paid_by()} - {getPayMethods().map((method) => ( - - ))} - -
- ); - } - return ( -
- {viewInspection} - {payments} - - {voidAction} -
- ); - }, + cell: (invoice) => ( + setPickerFor(invoice.id)} + onCancelPicker={() => setPickerFor(null)} + onOpenPayments={() => openPayments(invoice)} + onMarkPaid={(method) => markPaid(invoice.id, method)} + onVoid={() => setPendingVoid(invoice)} + /> + ), }, ]} /> diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index fd343a60f..75e806af7 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -42,7 +42,6 @@ "server/api/inspections/publish.ts": 521, "server/api/bookings/agreement.ts": 519, "app/components/settings/ManagedComplianceWizard.tsx": 514, - "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, From aebf90e7d8db2385109f99855f04ee8c96a90bf3 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 14:35:29 +0800 Subject: [PATCH 37/77] refactor(inspections): one snapshot writer for inspection_services, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inspection-core.service.ts:556` was the only place that wrote the tier-2 money-authority rows, and it wrote them inline inside a 250-line `createInspection`. That mattered beyond tidiness: the public booking path never writes these rows at all, so a booking-created inspection has no tier-2 authority and `getEffectivePriceCents` falls through to `inspections.price` — the cache the schema rules forbid treating as authority — or to `price: 0` on the no-service branch. Pay splits attach to `inspection_services` lines, so the feature #278 just shipped is inert for exactly the orders the public form creates. The fix for that is booking-deposit (#20) Decision 3, and its first instruction is: do not write a second writer. So the writer moves out first, whole, to `server/lib/inspection/service-snapshot.ts`, beside `reports.ts` and `roster.ts`. Pure move — the SQL, the precedence rule (serviceSelections over the legacy flat serviceIds), the override map and the two early exits are the ones that were there. The only addition is a return value: the rows written, which #20 needs to resolve a deposit against the summed selected-service price. NOT WIRED INTO BOOKING IN THIS COMMIT, deliberately. Two reasons, both recorded in the module doc so the next reader finds them at the call site rather than in a commit log. First, the call site is a real design choice and not mine to make here: `fulfillBooking`'s direct-insert branch and `InspectionRequestService.create` each own half the bookings, and wiring both double-writes a multi-service order's lines. Second, turning tier 2 on for those orders CHANGES INVOICE TOTALS — from zero to real money — for every booking-created order in existence. That is a behaviour change owed a commit and a test of its own, not a line slipped in under a refactor. The doc also records why `attachHoldServices` (`server/services/concierge/hold-inputs.ts:90`) is NOT folded in despite writing the same table: it throws on an unknown service id, because a hold that silently drops a service under-quotes the client, while the wizard skips unknown ids. Two contracts, two functions. The parameter object spells `| undefined` on both keys rather than relying on `?`: exactOptionalPropertyTypes is on, so an absent key and a present-but-undefined one are different types, and every caller reads these off a wider input object where they are the latter. `services` leaves the schema import — the table is no longer read here. tests/unit/{inspections,idempotency,pay-splits} service-line and effective-price specs, 63 tests, pass untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- server/lib/inspection/service-snapshot.ts | 79 +++++++++++++++++++ .../inspection/inspection-core.service.ts | 36 ++------- 2 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 server/lib/inspection/service-snapshot.ts diff --git a/server/lib/inspection/service-snapshot.ts b/server/lib/inspection/service-snapshot.ts new file mode 100644 index 000000000..fd3025a8f --- /dev/null +++ b/server/lib/inspection/service-snapshot.ts @@ -0,0 +1,79 @@ +import { and, eq, inArray } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { services, inspectionServices } from '../db/schema'; + +/** One service the caller asked for, with an optional per-line reprice. */ +export interface ServiceSelection { + serviceId: string; + priceOverrideCents?: number; +} + +/** The rows written, in catalog-row order. */ +export type InspectionServiceRow = typeof inspectionServices.$inferInsert; + +/** + * Snapshot the selected services onto an inspection as `inspection_services` + * rows — tier 2 of the money-authority chain that `getEffectivePriceCents()` + * reads (`server/lib/effective-price.ts`). An inspection with no rows here has + * no tier-2 authority at all, so its price falls through to `inspections.price`, + * the cache the schema rules forbid treating as authority. Pay splits attach to + * these lines too, so an order without them has nothing to split. + * + * Snapshots, not references: `nameSnapshot` / `priceSnapshot` freeze what the + * catalog said on the day, so editing a service later never rewrites history. + * + * `serviceSelections` (IA-1 superset) takes precedence over the legacy flat + * `serviceIds` list when both are present — the handlers already merge them so + * only one branch ever fires. + * + * UNKNOWN IDS ARE SKIPPED, NOT AN ERROR. That is the dashboard wizard's + * contract, preserved here byte for byte. It is deliberately NOT the contract + * of `attachHoldServices` (`server/services/concierge/hold-inputs.ts`), which + * throws on an unknown id because a hold that silently drops a service + * under-quotes the client. Two contracts, two functions — do not merge them. + * + * CALLERS. `InspectionCoreService.createInspection` (the dashboard wizard) is + * the only one today. The PUBLIC BOOKING PATH DOES NOT CALL THIS YET and so + * still produces orders with no tier-2 authority: see + * `docs/superpowers/plans/2026-08-01-oi-booking-deposit-plan.md`, Decision 3. + * The two candidate call sites are `BookingService.fulfillBooking`'s + * direct-insert branch and `InspectionRequestService.create` (which owns the + * multi-service branch's inspections); wiring exactly one of them — not both, + * or a booking's lines get written twice — belongs to booking-deposit (#20), + * together with the invoice-total change that turning tier 2 on implies. + */ +export async function writeInspectionServiceSnapshots( + db: DrizzleD1Database, + tenantId: string, + inspectionId: string, + // `| undefined` explicitly: under exactOptionalPropertyTypes an absent key + // and a present-but-undefined one are different types, and every caller + // reads these off a wider input object where they are the latter. + selections: { serviceSelections?: ServiceSelection[] | undefined; serviceIds?: string[] | undefined }, +): Promise { + const { serviceSelections, serviceIds } = selections; + const effectiveServiceIds: string[] = serviceSelections && serviceSelections.length > 0 + ? serviceSelections.map(s => s.serviceId) + : (serviceIds ?? []); + if (effectiveServiceIds.length === 0) return []; + + const svcRows = await db.select().from(services) + .where(and(eq(services.tenantId, tenantId), inArray(services.id, effectiveServiceIds))); + if (svcRows.length === 0) return []; + + // Build a map from serviceId → priceOverrideCents for fast lookup. + const overrideMap = new Map( + (serviceSelections ?? []).map(s => [s.serviceId, s.priceOverrideCents]), + ); + const rows: InspectionServiceRow[] = svcRows.map(s => ({ + id: crypto.randomUUID(), + tenantId, + inspectionId, + serviceId: s.id, + priceOverride: overrideMap.get(s.id) ?? null, + nameSnapshot: s.name, + priceSnapshot: s.price, + })); + await db.insert(inspectionServices).values(rows); + return rows; +} diff --git a/server/services/inspection/inspection-core.service.ts b/server/services/inspection/inspection-core.service.ts index 14411e23c..53b39c9ff 100644 --- a/server/services/inspection/inspection-core.service.ts +++ b/server/services/inspection/inspection-core.service.ts @@ -1,5 +1,5 @@ import { eq, and, or, lt, gte, lte, sql, inArray, desc } from 'drizzle-orm'; -import { inspections, inspectionResults, templates, users, services, inspectionServices, tenantConfigs, agreementRequests, reportVersions, contactRoleProfiles, inspectionPeople } from '../../lib/db/schema'; +import { inspections, inspectionResults, templates, users, inspectionServices, tenantConfigs, agreementRequests, reportVersions, contactRoleProfiles, inspectionPeople } from '../../lib/db/schema'; import { resolveAgentRepairAccess, type AgentRepairAccess } from '../../lib/people/agent-repair-access'; import { contacts } from '../../lib/db/schema/contact'; import { PeopleService } from '../people.service'; @@ -11,6 +11,7 @@ import { escapeLikePattern } from '../../lib/db/like-escape'; import { safeISODate, safeTimestamp } from '../../lib/date'; import { logger } from '../../lib/logger'; import { createPrimaryReport } from '../../lib/inspection/reports'; +import { writeInspectionServiceSnapshots, type ServiceSelection } from '../../lib/inspection/service-snapshot'; import { computePreflightFromData } from '../../lib/preflight'; import { syncInspectionAssignments } from '../../lib/db/assignment-links'; import { getInspectionRoster } from '../../lib/inspection/roster'; @@ -537,33 +538,12 @@ export class InspectionCoreService extends InspectionSubService { logger.error('inspection-people write from inspection create failed', { inspectionId: id }, err instanceof Error ? err : undefined); } - // Link selected services. - // serviceSelections (IA-1 superset) takes precedence when present; otherwise - // fall back to the legacy flat serviceIds list. The two may coexist — the - // handler already merges them so only one branch fires here. - const serviceSelectionsInput = (data as { serviceSelections?: Array<{ serviceId: string; priceOverrideCents?: number }> }).serviceSelections; - const effectiveServiceIds: string[] = serviceSelectionsInput && serviceSelectionsInput.length > 0 - ? serviceSelectionsInput.map(s => s.serviceId) - : (data.serviceIds ?? []); - if (effectiveServiceIds.length > 0) { - const svcRows = await db.select().from(services) - .where(and(eq(services.tenantId, tenantId), inArray(services.id, effectiveServiceIds))); - if (svcRows.length > 0) { - // Build a map from serviceId → priceOverrideCents for fast lookup. - const overrideMap = new Map( - (serviceSelectionsInput ?? []).map(s => [s.serviceId, s.priceOverrideCents]), - ); - await db.insert(inspectionServices).values(svcRows.map(s => ({ - id: crypto.randomUUID(), - tenantId, - inspectionId: id, - serviceId: s.id, - priceOverride: overrideMap.get(s.id) ?? null, - nameSnapshot: s.name, - priceSnapshot: s.price, - }))); - } - } + // Link selected services — the tier-2 money authority. Shared with any + // other path that must produce the same rows; see the module doc. + await writeInspectionServiceSnapshots(db, tenantId, id, { + serviceSelections: (data as { serviceSelections?: ServiceSelection[] }).serviceSelections, + serviceIds: data.serviceIds, + }); return { ...newInspection, From 3f0b231e8abcaa6d42da58369d135840f2588e6e Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 14:49:55 +0800 Subject: [PATCH 38/77] refactor(booking): four things were living in booking.service.ts; now four files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `booking.service.ts` sat at 971 lines against a 972 cap, and three separate plans — booking-deposit (#20), scheduling-phase-b (#18), scheduling-phase-d (#27) — all need to edit it and none of them could add a line. Pure move: no query, message, status code, log key or ordering changed, and the 133 tests across tests/unit/{bookings,scheduling,calendar,usage} plus the workers db-batch-atomicity spec pass untouched. `fulfillBooking` alone was 513 of those lines, and it was four things. `booking/booking-admission.ts` — EVERYTHING THAT MUST HOLD BEFORE A ROW EXISTS: bot protection, widget origin allowlist, inspector-belongs-to-tenant, booking-open, holiday block, and the slot claim. The seam is the absence of writes. Every refusal here leaves the database exactly as it was; the moment one row exists the failure mode changes from "refuse" to "compensate" (see `arbitrateSlotRace`). Grouping them is also what makes it checkable that quota is consumed after all of them — the property `plan-quota-guarded-services.spec.ts` asserts. `booking/booking-people.ts` — WHO the booking is for. Task 13 dropped the legacy contact columns from `inspections`, so `inspection_people` is the only persistence of who, and on this path it is written from two ends: the referring agent resolved from `?ref=` before anything exists, the client upserted after the rows commit. Eighty lines apart in the old file, which is how one of them gets forgotten. Every write in the module is non-fatal by design and the doc says so once, at the top, instead of five times inline. `booking/booking-confirmation.ts` — what the world hears afterwards: the Google Calendar push and the customer's confirmation email with its ICS invite, inspector signature, credentials footer and SMS double-opt-in link. The seam is "after the answer is sent" — the caller hands it to `waitUntil`, so nothing in it can change the booker's response. `windowLabel` went with it; the email is its only reader. `booking/fulfill-booking.ts` keeps what remains, which is exactly the part that writes and the part that cleans up after itself: the two row-creation branches, the post-insert TOCTOU arbitration that may revoke what was just written, and the scheduled-instant stamp over the survivors. Those three cannot be separated. `AvailabilityService` — a second exported class that shared nothing with the first but the file — becomes `services/availability.service.ts`. It is the WRITE side of availability (the weekly grid and its dated exceptions); BookingService is the read side that turns those rows into bookable slots. They never shared a query. Its three importers (`di.ts`, `types/hono.ts`, `booking-delete-override-scope.spec.ts`) are repointed rather than given a re-export. TWO D1 HANDLES, AND THEY ARE NOT INTERCHANGEABLE. The original mixed `drizzle(c.env.DB)` with `this.db` (`resolvePublicHolidayEffect`, `new PeopleService({ DB: this.db })`), and unit tests construct the service with a handle that is not `c.env.DB`. Both are explicit parameters now, with the reason written down, because collapsing them to one is the obvious tidy that would silently break those specs. `originHeader` stays `string | undefined` rather than being normalised to null on the way through the claim object: it is forwarded verbatim into widget telemetry, where `undefined` omits the key and `null` writes one. fulfillBooking stays a method — six specs and every caller reach it as `c.var.services.booking.fulfillBooking(...)`. Baseline ENTRY removed, not tightened. At 380 lines the file is governed by the ordinary 400-line rule again; re-baselining at 380 would have rebuilt the wall one line further out. `type-check:app` run explicitly, not just `:api` — `types/hono.ts` is on the hono/client RPC path and the api tsconfig does not build it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- scripts/file-size-baseline.json | 1 - server/lib/middleware/di.ts | 2 +- server/services/availability.service.ts | 90 +++ server/services/booking.service.ts | 611 +----------------- server/services/booking/booking-admission.ts | 132 ++++ .../services/booking/booking-confirmation.ts | 162 +++++ server/services/booking/booking-people.ts | 180 ++++++ server/services/booking/fulfill-booking.ts | 253 ++++++++ server/types/hono.ts | 3 +- .../booking-delete-override-scope.spec.ts | 2 +- 10 files changed, 831 insertions(+), 605 deletions(-) create mode 100644 server/services/availability.service.ts create mode 100644 server/services/booking/booking-admission.ts create mode 100644 server/services/booking/booking-confirmation.ts create mode 100644 server/services/booking/booking-people.ts create mode 100644 server/services/booking/fulfill-booking.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 75e806af7..c3d4f530a 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -2,7 +2,6 @@ "app/routes/inspection-edit.tsx": 2530, "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, "server/durable-objects/inspection-doc.ts": 928, "app/routes/inspections.tsx": 879, diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts index c58dccab8..0f39b6377 100644 --- a/server/lib/middleware/di.ts +++ b/server/lib/middleware/di.ts @@ -21,7 +21,7 @@ import { PortalService } from '../../services/portal.service'; import { TeamService } from '../../services/team.service'; import { TemplateService } from '../../services/template.service'; import { AgreementService } from '../../services/agreement.service'; -import { AvailabilityService } from '../../services/booking.service'; +import { AvailabilityService } from '../../services/availability.service'; import { ContactService } from '../../services/contact.service'; import { InvoiceService } from '../../services/invoice.service'; import { PortalAccessService } from '../../services/portal-access.service'; diff --git a/server/services/availability.service.ts b/server/services/availability.service.ts new file mode 100644 index 000000000..eb688747f --- /dev/null +++ b/server/services/availability.service.ts @@ -0,0 +1,90 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { eq, and } from 'drizzle-orm'; +import { availability, availabilityOverrides } from '../lib/db/schema'; +import { Errors } from '../lib/errors'; +import { safeISODate } from '../lib/date'; + +/** + * Service to manage internal inspector availability schedules. + * + * The write side of availability: the recurring weekly grid and the dated + * exceptions to it. `BookingService` is the read side — it turns these rows, + * plus busy times and holidays, into bookable slots — and the two never share + * a query, which is why they no longer share a file. + */ +export class AvailabilityService { + constructor(private db: D1Database) {} + + private getDrizzle() { + return drizzle(this.db); + } + + /** + * Replaces the entire weekly schedule for an inspector. + */ + async updateWeeklySchedule(tenantId: string, inspectorId: string, slots: { dayOfWeek: number; startTime: string; endTime: string }[]) { + const db = this.getDrizzle(); + + await db.delete(availability).where(and( + eq(availability.tenantId, tenantId), + eq(availability.inspectorId, inspectorId) + )); + + if (slots.length > 0) { + await db.insert(availability).values( + slots.map(s => ({ + id: crypto.randomUUID(), + tenantId, + inspectorId, + dayOfWeek: s.dayOfWeek, + startTime: s.startTime, + endTime: s.endTime, + createdAt: new Date(), + })) + ); + } + } + + /** + * Adds a specific availability override. + */ + async addOverride(tenantId: string, data: { + inspectorId: string; + date: string; + isAvailable: boolean; + startTime?: string | null | undefined; + endTime?: string | null | undefined; + }) { + const db = this.getDrizzle(); + const newOverride = { + id: crypto.randomUUID(), + tenantId, + inspectorId: data.inspectorId, + date: data.date, + isAvailable: data.isAvailable, + startTime: data.startTime || null, + endTime: data.endTime || null, + createdAt: new Date(), + }; + + await db.insert(availabilityOverrides).values(newOverride); + return { + ...newOverride, + createdAt: safeISODate(newOverride.createdAt) + }; + } + + /** + * Deletes an availability override. + */ + async deleteOverride(tenantId: string, id: string) { + const db = this.getDrizzle(); + const existing = await db.select().from(availabilityOverrides).where(and( + eq(availabilityOverrides.id, id), + eq(availabilityOverrides.tenantId, tenantId) + )).get(); + + if (!existing) throw Errors.NotFound('Override not found'); + await db.delete(availabilityOverrides).where(and(eq(availabilityOverrides.id, id), eq(availabilityOverrides.tenantId, tenantId))); + } +} diff --git a/server/services/booking.service.ts b/server/services/booking.service.ts index a7ab28e34..48317e7ad 100644 --- a/server/services/booking.service.ts +++ b/server/services/booking.service.ts @@ -1,35 +1,18 @@ import type { Context } from 'hono'; import { drizzle } from 'drizzle-orm/d1'; import { eq, and, gte, lte, sql, inArray, isNull, ne } from 'drizzle-orm'; -import { availability, availabilityOverrides, calendarBlocks, inspections, inspectionInspectors, inspectionRequests, serviceInspectors, tenantConfigs, users, services as servicesTable, contactRoleProfiles, contacts } from '../lib/db/schema'; -import { CredentialService } from './credential.service'; -import { wallClockToEpochMs, resolveTenantTimeZone } from '../lib/tz'; -import { Errors } from '../lib/errors'; -import { safeISODate } from '../lib/date'; +import { availability, availabilityOverrides, calendarBlocks, inspections, inspectionInspectors, inspectionRequests, serviceInspectors, users } from '../lib/db/schema'; 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'; -import { createCalendarEvent } from '../api/calendar'; -import { loadOpenGoogleConnection } from '../lib/calendar/connection'; -import { - loadGoogleOAuthMode, - resolveGoogleOAuthCredentials, -} from '../lib/calendar/resolve-google-oauth'; -import { canPushEvents } from '../lib/calendar/provider'; -import { getBookingHost, getBaseUrl } from '../lib/url'; -import { syncInspectionAssignments } from '../lib/db/assignment-links'; -import { PeopleService } from './people.service'; -import { INSPECTION_STATUS } from '../lib/status/inspection-status'; import { buildSlotGrid } from '../lib/booking/slot-grid'; import { loadSlotGridOptions } from '../lib/booking/slot-rules'; import { computeBusyTimes } from '../lib/booking/busy-times'; import { buildTenantSlotMap } from '../lib/booking/tenant-slot-map'; import { resolvePublicHolidayEffect } from '../lib/holidays/load-tenant-holidays'; +import { fulfillBooking as runFulfillBooking } from './booking/fulfill-booking'; import type { PlanQuotaGuard } from '../features/plan-quota/guard'; - /** * Service to handle public booking flow and availability lookups. */ @@ -380,592 +363,18 @@ export class BookingService { * enforcement and all fulfillment side effects, returning the same JSON * Response the handler used to return. */ + /** + * Public booking fulfilment. The body lives in `./booking/fulfill-booking` + * and its three neighbours — admission, people, confirmation. It stays a + * method here because every caller and six specs reach it as + * `c.var.services.booking.fulfillBooking(...)`, and because the free + * function needs the handles this instance was constructed with. + */ async fulfillBooking( c: Context, tenantId: string, body: z.infer, ) { - const service = c.var.services.booking; - - // Bot Protection — always enforce when secret is configured - if (c.env.TURNSTILE_SECRET_KEY) { - if (!body.turnstileToken) throw Errors.Forbidden('Security verification token missing.'); - const isValid = await service.verifyBotProtection(body.turnstileToken, c.env.TURNSTILE_SECRET_KEY); - if (!isValid) throw Errors.Forbidden('Security verification failed.'); - } - - // B2: when the booking originates from an embedded widget, enforce - // per-tenant origin allowlist. Non-embed (direct /book visit) submissions - // are unaffected. - const isWidgetSubmit = c.req.query('embed') === '1'; - const originHeader = c.req.header('origin'); - if (isWidgetSubmit) { - const ok = await c.var.services.widget.isOriginAllowed(tenantId, originHeader ?? null); - if (!ok) { - await c.var.services.widget.recordEvent(tenantId, 'error', { origin: originHeader, reason: 'origin_not_allowed' }); - throw Errors.Forbidden('Widget submissions from this origin are not allowed for this workspace.'); - } - } - - const db = drizzle(c.env.DB); - - // UC-A-1 — agent referral attribution. Resolve `?ref=` (sent - // through the form as agentRefSlug) to a contacts.id in this tenant. - // Two requirements both need to hold: - // 1. A global agent user with that slug exists. - // 2. They have an `active` agent_tenant_links row for THIS tenant whose - // inspectorContactId points at the agent's contact row. - // Either failure leaves referredByAgentId null — bookings with bad slugs - // still succeed; we just don't credit the (unknown) agent. - let resolvedAgentContactId: string | null = null; - if (body.agentRefSlug) { - try { - const agent = await db.select({ id: users.id }) - .from(users) - .where(and( - eq(users.slug, body.agentRefSlug), - isNull(users.tenantId), - eq(users.role, 'agent'), - )) - .get(); - if (agent) { - // IA-104 — the agent's contact in THIS tenant is the row - // bound to their account; no link hop. - const link = await db.select({ contactId: contacts.id }) - .from(contacts) - .where(and( - eq(contacts.agentUserId, agent.id), - eq(contacts.tenantId, tenantId), - isNull(contacts.agentRevokedAt), - )) - .get(); - resolvedAgentContactId = link?.contactId ?? null; - } - } catch (err) { - logger.warn('booking.agentRef.resolve.failed', { - slug: body.agentRefSlug, - tenantId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // IA-26 — inspectorId is now OPTIONAL. The company-level booking page - // submits without one (pure auto-assign); the legacy per-inspector - // deep link and the allowInspectorChoice dropdown still send it. - const serviceIdsForQual = (body.services ?? []).map(s => s.serviceId); - let inspectorId = body.inspectorId ?? null; - - if (inspectorId) { - // B-16 — a supplied inspector must belong to the resolved tenant; - // a mismatched id (tampered payload or stale form) must not reach - // into another tenant's availability/inspection space. - const inspectorRow = await db.select({ id: users.id }).from(users) - .where(and(eq(users.id, inspectorId), eq(users.tenantId, tenantId))) - .get(); - if (!inspectorRow) throw Errors.NotFound('Inspector not found.'); - } - - // B-16 (company-wide) — distinguish "nobody configured working hours" - // from a genuinely taken slot, with the honest not-open copy. - // qualifiedIds is computed once here and threaded through to avoid - // duplicate getQualifiedInspectorIds lookups in hasAnyHours / getTenantSlots. - const qualifiedIds = await service.getQualifiedInspectorIds(tenantId, serviceIdsForQual); - const bookingOpen = await service.hasAnyHours(tenantId, serviceIdsForQual, qualifiedIds); - if (!bookingOpen) { - throw Errors.Conflict('Online booking is not open yet. Please contact the company directly to schedule.'); - } - - const holiday = await resolvePublicHolidayEffect(this.db, tenantId, body.date); - if (holiday.effect === 'block') { - throw Errors.BadRequest( - holiday.name - ? `The office is closed on ${holiday.name}. Please pick another date.` - : 'The office is closed on this date. Please pick another date.', - 'HOLIDAY_BLOCKED', - ); - } - - // Spec 3C / IA-26 — availability enforcement now runs on the tenant - // aggregation: a slot is bookable iff at least one QUALIFIED inspector - // is free (or the requested one, when the client chose). - let requestedTime: string; - switch (body.timeSlot) { - case 'morning': requestedTime = '08:00'; break; - case 'afternoon': requestedTime = '13:00'; break; - case 'all-day': requestedTime = '08:00'; break; - case 'custom': requestedTime = body.customTime ?? '08:00'; break; - } - // KNOWN RACE (advisory check): the slot read and the inspection insert - // below are not atomic and D1 offers no row locks, so two concurrent - // submits for the last slot can both pass and double-book the same - // inspector (deterministic pickInspector converges on one person). - // Accepted for launch traffic; a post-insert recheck/compensation is - // tracked in the backlog. Do NOT "fix" by randomizing the pick — the - // determinism is intentional (idempotent re-submits). - const { slots } = await service.getTenantSlots(tenantId, body.date, serviceIdsForQual, qualifiedIds); - const target = slots.find(s => s.time === requestedTime); - const freeIds = (target?.inspectorIds ?? []).filter(id => !inspectorId || id === inspectorId); - if (freeIds.length === 0) { - throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); - } - if (!inspectorId) { - inspectorId = await service.pickInspector(tenantId, freeIds); - if (!inspectorId) throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); - } - - // Sprint 2 S2-2 — When the customer selects multiple services, we route - // through InspectionRequestService so the resulting inspections are - // grouped under a parent request. The legacy single-service flow still - // creates a one-inspection request implicitly so dashboards can group - // every booking the same way. - const startIso = `${body.date}T${requestedTime}:00Z`; - const inspectionRequestService = c.var.services.inspectionRequest; - let createdRequestId: string; - let primaryInspectionId: string; - let allInspectionIds: string[] = []; - // Task 7b (people-role-profiles) — set only by the direct-insert - // (legacy single-service) branch below. The multi-service branch - // routes through InspectionRequestService.create, which owns its - // own inspection_people write for the inspections it creates. - let directInsertInspectionId: string | null = null; - // Booked duration from the chosen service(s); NULL when the legacy path - // carries no explicit service (falls back to the time-slot window below). - let bookedServiceDurationMin: number | null = null; - - if (body.services && body.services.length > 0) { - const serviceIds = body.services.map(s => s.serviceId); - const svcRows = await db.select().from(servicesTable) - .where(and(eq(servicesTable.tenantId, tenantId), inArray(servicesTable.id, serviceIds))) - .all(); - if (svcRows.length !== serviceIds.length) { - throw Errors.BadRequest('One or more services were not found.'); - } - // Total booked minutes across the selected services (back-to-back); - // NULL when none carry a duration, so the time-slot window is used. - bookedServiceDurationMin = - svcRows.reduce((sum, s) => sum + (s.durationMinutes ?? 0), 0) || null; - const subs = svcRows.map(s => { - const sub: { templateId: string; price: number } = { - templateId: s.templateId ?? '', - price: s.price ?? 0, - }; - if (!sub.templateId) throw Errors.BadRequest(`Service '${s.name}' has no template configured.`); - return sub; - }); - const created = await inspectionRequestService.create(tenantId, { - clientName: body.clientName, - clientEmail: body.clientEmail, - propertyAddress: body.address, - scheduledAt: startIso, - inspectorId, - referredByAgentId: resolvedAgentContactId, - }, subs); - createdRequestId = created.id; - allInspectionIds = created.inspections.map(i => i.id); - primaryInspectionId = allInspectionIds[0] ?? ''; - } else { - primaryInspectionId = crypto.randomUUID(); - createdRequestId = `req-${primaryInspectionId}`; - const now = new Date(); - // Quota is consumed AFTER every precondition check above (bot - // protection, widget origin, inspector ownership, booking-open, - // slot availability) and BEFORE either row below is inserted — - // the request row must never be orphaned (created with no - // inspection behind it) because the tenant hit the cap. - await this.planQuota?.consumeInspection(tenantId); - // Insert one-inspection request first so the FK is satisfied. - await db.insert(inspectionRequests).values({ - id: createdRequestId, - tenantId, - clientName: body.clientName, - clientEmail: body.clientEmail, - propertyAddress: body.address, - scheduledAt: new Date(startIso), - status: 'pending', - totalAmount: 0, - paymentStatus: 'unpaid', - createdAt: now, - updatedAt: now, - }); - await db.insert(inspections).values({ - id: primaryInspectionId, - tenantId, - inspectorId, - propertyAddress: body.address, - // B-28 adjacent fix — store the full start ISO like the - // multi-service path (inspection-request.service create) does. - // Busy checks read HH:MM at slice(11,16) of this value; the old - // bare `body.date` never marked the slot busy, so even - // sequential double-booking succeeded. - date: startIso, - status: INSPECTION_STATUS.REQUESTED, - paymentStatus: 'unpaid', - price: 0, - requestId: createdRequestId, - createdAt: now - }); - // DB-8: mirror assignment into inspection_inspectors link table. - // Non-fatal — the link table is a denormalized mirror; a sync failure - // must never 500 an anonymous booker whose inspection row already committed. - try { - await syncInspectionAssignments(db, tenantId, primaryInspectionId, { inspectorId }); - } catch (e) { - logger.error('booking.assignment-sync.failed', { inspectionId: primaryInspectionId }, e instanceof Error ? e : undefined); - } - allInspectionIds = [primaryInspectionId]; - directInsertInspectionId = primaryInspectionId; - } - const inspectionId = primaryInspectionId; - - // B-28 — post-insert TOCTOU recheck. Runs after our insert and BEFORE - // any side effect (confirmation email, calendar event, notifications) - // so a losing booker only ever sees the 409, never a confirmation for - // a booking that then vanishes. The arbitration is deterministic - // (earliest (createdAt, id) wins), so of two racers exactly one - // self-compensates here while the other proceeds untouched. - const verdict = await service.arbitrateSlotRace( - tenantId, inspectorId!, body.date, requestedTime, createdRequestId, - ); - if (verdict === 'lose') { - await service.revokeBooking(tenantId, createdRequestId); - throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); - } - - // A-polish 9b — stamp the precise scheduled instant on every inspection - // this booking created. The wall-clock slot time is interpreted in the - // TENANT tz (not the naive :00Z of the startIso busy-check key), so - // downstream conflict detection, Google push, and the ICS feed reason - // about real instants. inspections.date is deliberately left untouched — - // it still keys the HH:MM busy-checks via slice(11,16). Non-fatal: the - // inspection rows already committed, so a stamp failure must not 500 the - // booker (conflict detection just falls back to the hour-bucket). - const tzRow = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) - .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); - const tenantTz = resolveTenantTimeZone(tzRow?.defaultTimezone); - const scheduledStartMs = wallClockToEpochMs(body.date, requestedTime, tenantTz); - const slotWindowMin = - body.timeSlot === 'all-day' ? 540 - : body.timeSlot === 'morning' || body.timeSlot === 'afternoon' ? 240 - : 180; - const durationMin = bookedServiceDurationMin ?? slotWindowMin; - const scheduledEndMs = scheduledStartMs + durationMin * 60000; - try { - await db.update(inspections) - .set({ - scheduledStartMs: new Date(scheduledStartMs), - scheduledEndMs: new Date(scheduledEndMs), - durationMin, - }) - .where(and( - inArray(inspections.id, allInspectionIds), - eq(inspections.tenantId, tenantId), - )); - } catch (e) { - logger.warn('booking.scheduled-instant.stamp.failed', { - inspectionIds: allInspectionIds, - error: e instanceof Error ? e.message : String(e), - }); - } - - // IA-18 (#111) — capture the booker as a Client contact and link it to - // ALL inspections this booking created so their client appears in - // Contacts and on the inspector portal People card. - // - // Placement: AFTER arbitration. A losing booker self-revokes and throws - // above, so we never stamp a contact onto inspections that were just - // deleted. (A stray contact row is harmless on its own — what we avoid - // is a clientContactId pointing at vanished inspections.) It also runs - // BEFORE the side-effect block to keep the synchronous DB writes - // grouped before async waitUntil work. - // - // Non-fatal: a booking must NEVER fail because of contact bookkeeping. - // Any error is logged (NO client email — only inspection ids + message) - // and swallowed; the inspection rows already committed regardless. - let bookingClientContactId: string | null = null; - if (body.clientEmail || body.clientName) { - try { - const { id: clientContactId } = await c.var.services.contact.upsertClientContact(tenantId, { - 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) { - logger.warn('booking.client-contact.upsert.failed', { - inspectionIds: allInspectionIds, - error: e instanceof Error ? e.message : String(e), - }); - } - } - - // Task 7b (people-role-profiles), FIXED — mirror client + buyer_agent - // into inspection_people. Task 13 dropped the legacy clientContactId / - // referredByAgentId columns from inspections, so this is now the ONLY - // persistence of WHO. Client covers EVERY allInspectionIds entry - // (bookingClientContactId is linked to all of them, incl. - // multi-service sub-inspections, which only get buyer_agent from - // InspectionRequestService.create — the original bug wrongly scoped - // the client write to directInsertInspectionId alone). Non-fatal. - if (bookingClientContactId || (directInsertInspectionId && resolvedAgentContactId)) { - try { - const roleRows = await db.select({ id: contactRoleProfiles.id, key: contactRoleProfiles.key }) - .from(contactRoleProfiles) - .where(and(eq(contactRoleProfiles.tenantId, tenantId), eq(contactRoleProfiles.active, true))); - const roleIdByKey = new Map(roleRows.map(r => [r.key, r.id])); - const people = new PeopleService({ DB: this.db }); - const clientRoleId = roleIdByKey.get('client'); - if (bookingClientContactId && clientRoleId) { - for (const inspId of allInspectionIds) { - await people.addPerson(tenantId, inspId, bookingClientContactId, clientRoleId); - } - } - const buyerAgentRoleId = roleIdByKey.get('buyer_agent'); - if (directInsertInspectionId && resolvedAgentContactId && buyerAgentRoleId) { - await people.addPerson(tenantId, directInsertInspectionId, resolvedAgentContactId, buyerAgentRoleId); - } - } catch (err) { - logger.error('inspection-people write from booking create failed', { inspectionIds: allInspectionIds }, err instanceof Error ? err : undefined); - } - } - - // Track L (D6, path A) — self-book SMS opt-in. The checkbox is unchecked by - // default; when ticked we record a `granted` consent event (captured_via= - // booking_form) keyed on the client contact. Non-fatal: a consent write must - // never fail the booking (the inspection rows already committed). - if (body.smsOptin && bookingClientContactId) { - try { - const { SmsConsentService } = await import('./sms-consent.service'); - await new SmsConsentService(c.env.DB).record( - tenantId, bookingClientContactId, 'granted', 'booking_form', - { ip: c.req.header('CF-Connecting-IP'), userAgent: c.req.header('User-Agent') }, - ); - } catch (e) { - logger.warn('booking.sms-optin.record.failed', { - inspectionId, error: e instanceof Error ? e.message : String(e), - }); - } - } - - // Sprint 1 C-6 — map window option to a human-readable label for the - // calendar event + confirmation email. - const windowLabel: Record = { - 'morning': 'Morning (8:00 AM – 12:00 PM)', - 'afternoon': 'Afternoon (12:00 PM – 4:00 PM)', - 'all-day': 'All day (8:00 AM – 5:00 PM)', - 'custom': body.customTime ? `${body.customTime}` : 'Custom time', - }; - - // Async tasks - c.executionCtx.waitUntil((async () => { - const inspector = await db.select().from(users).where(eq(users.id, inspectorId!)).get(); - const open = await loadOpenGoogleConnection( - c.env.DB, - tenantId, - inspectorId!, - c.env.JWT_SECRET, - c.env.JWT_SECRET_PREVIOUS, - ); - if (open && canPushEvents(open.connection.capabilities)) { - const oauthMode = await loadGoogleOAuthMode(c.env.DB, tenantId); - const oauthCreds = await resolveGoogleOAuthCredentials(c.env, tenantId, oauthMode); - if (oauthCreds) { - const startDateTime = `${body.date}T${requestedTime}:00Z`; - await createCalendarEvent( - oauthCreds.clientId, - oauthCreds.clientSecret, - open.credentials.refreshToken, - open.connection.calendarId, - `Inspection: ${body.address}`, - startDateTime, - body.address, - ).catch(e => logger.error('Calendar sync failed', {}, e instanceof Error ? e : undefined)); - } - } - - const emailService = c.var.services.email; - - // Sprint 1 C-10 — build the ICS event so the confirmation email - // carries a calendar invite the customer can import into Apple - // Calendar / Google Calendar. Duration defaults to 3 hours, with - // 4 hours for morning/afternoon windows and 9 hours for all-day. - const startMs = new Date(`${body.date}T${requestedTime}:00Z`).getTime(); - let durationHours: number; - switch (body.timeSlot) { - case 'all-day': durationHours = 9; break; - case 'morning': - case 'afternoon': durationHours = 4; break; - default: durationHours = 3; break; - } - const endMs = startMs + durationHours * 60 * 60 * 1000; - // Booking-confirmation greeting falls back to the brand, never the - // inspector's inbox — keeps the email looking professional even if a - // legacy account is missing a display name. - const inspectorName = inspector?.name || c.env.APP_NAME || 'Your inspector'; - const inspectorEmail = inspector?.email || c.env.SENDER_EMAIL || `noreply@${c.env.APP_NAME?.toLowerCase().replace(/\s/g, '') || 'inspector'}.com`; - - // Spec B — the assigned inspector's active credentials, for the footer. - // Via the shared mapper, so this footer and the email signature can - // never disagree about the badge URL form. - const bookingCreds = inspector - ? await new CredentialService(c.env.DB).listRenderable(tenantId, inspectorId!) - : []; - // Sprint B-4a — append inspector signature so customers can rebook - // with the same inspector via the per-inspector booking link. - const sigInspector = inspector ? { - name: inspector.name ?? null, - email: inspector.email ?? null, - phone: inspector.phone ?? null, - slug: inspector.slug ?? null, - credentials: bookingCreds, - } : undefined; - // Track L (D6, path B) — double-opt-in link injected at the RENDERER - // level (not gated on any automation rule) so disabling a rule never - // removes the only opt-in path. The token self-describes (tenant, - // contact) — see lib/sms/optin-token.ts. Best-effort: a token failure - // simply omits the link. - let smsOptinUrl: string | undefined; - if (bookingClientContactId && c.env.JWT_SECRET) { - try { - const { mintOptinToken } = await import('../lib/sms/optin-token'); - const token = await mintOptinToken(tenantId, bookingClientContactId, c.env.JWT_SECRET); - smsOptinUrl = `${getBaseUrl(c)}/sms-optin/${encodeURIComponent(token)}`; - } catch (e) { - logger.warn('booking.sms-optin.mint.failed', { inspectionId, error: e instanceof Error ? e.message : String(e) }); - } - } - - await emailService.sendBookingConfirmation( - body.clientEmail, - body.clientName, - body.address, - body.date, - windowLabel[body.timeSlot], - { - uid: `inspection-${inspectionId}`, - summary: `Home Inspection at ${body.address}`, - description: `Inspector: ${inspectorName}\nWindow: ${windowLabel[body.timeSlot]}\n\nWe will send your detailed report within 24 hours of completion.`, - location: body.address, - start: new Date(startMs), - end: new Date(endMs), - organizerEmail: inspectorEmail, - organizerName: inspectorName, - }, - sigInspector, - getBookingHost(c), - smsOptinUrl, - ).catch(e => logger.error('Booking confirmation email failed', {}, e instanceof Error ? e : undefined)); - })()); - - if (isWidgetSubmit) { - c.executionCtx.waitUntil( - c.var.services.widget.recordEvent(tenantId, 'success', { origin: originHeader, inspectionId }) - ); - } - - // B3 — the office alert is a rule now (`Office alert — new booking`, - // recipientKind 'staff', channel in_app). `booking.received` is its own - // trigger rather than a reuse of `inspection.created`: a booking is a - // stranger arriving through the public form, while an inspection can - // also be created by the office itself, and alerting someone about - // their own action is noise. - c.executionCtx.waitUntil( - fireAutomation(c.env.DB, tenantId, inspectionId, 'booking.received'), - ); - - return c.json({ - success: true, - data: { - success: true, - inspectionId, - requestId: createdRequestId, - inspectionIds: allInspectionIds, - } - }, 200); - } -} - -/** - * Service to manage internal inspector availability schedules. - */ -export class AvailabilityService { - constructor(private db: D1Database) {} - - private getDrizzle() { - return drizzle(this.db); - } - - /** - * Replaces the entire weekly schedule for an inspector. - */ - async updateWeeklySchedule(tenantId: string, inspectorId: string, slots: { dayOfWeek: number; startTime: string; endTime: string }[]) { - const db = this.getDrizzle(); - - await db.delete(availability).where(and( - eq(availability.tenantId, tenantId), - eq(availability.inspectorId, inspectorId) - )); - - if (slots.length > 0) { - await db.insert(availability).values( - slots.map(s => ({ - id: crypto.randomUUID(), - tenantId, - inspectorId, - dayOfWeek: s.dayOfWeek, - startTime: s.startTime, - endTime: s.endTime, - createdAt: new Date(), - })) - ); - } - } - - /** - * Adds a specific availability override. - */ - async addOverride(tenantId: string, data: { - inspectorId: string; - date: string; - isAvailable: boolean; - startTime?: string | null | undefined; - endTime?: string | null | undefined; - }) { - const db = this.getDrizzle(); - const newOverride = { - id: crypto.randomUUID(), - tenantId, - inspectorId: data.inspectorId, - date: data.date, - isAvailable: data.isAvailable, - startTime: data.startTime || null, - endTime: data.endTime || null, - createdAt: new Date(), - }; - - await db.insert(availabilityOverrides).values(newOverride); - return { - ...newOverride, - createdAt: safeISODate(newOverride.createdAt) - }; - } - - /** - * Deletes an availability override. - */ - async deleteOverride(tenantId: string, id: string) { - const db = this.getDrizzle(); - const existing = await db.select().from(availabilityOverrides).where(and( - eq(availabilityOverrides.id, id), - eq(availabilityOverrides.tenantId, tenantId) - )).get(); - - if (!existing) throw Errors.NotFound('Override not found'); - await db.delete(availabilityOverrides).where(and(eq(availabilityOverrides.id, id), eq(availabilityOverrides.tenantId, tenantId))); + return runFulfillBooking({ d1: this.db, planQuota: this.planQuota }, c, tenantId, body); } } diff --git a/server/services/booking/booking-admission.ts b/server/services/booking/booking-admission.ts new file mode 100644 index 000000000..8a80f38b6 --- /dev/null +++ b/server/services/booking/booking-admission.ts @@ -0,0 +1,132 @@ +import type { Context } from 'hono'; +import { eq, and } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { users } from '../../lib/db/schema'; +import { Errors } from '../../lib/errors'; +import { resolvePublicHolidayEffect } from '../../lib/holidays/load-tenant-holidays'; +import type { HonoConfig } from '../../types/hono'; +import type { PublicBookingSchema } from '../../lib/validations/booking.schema'; +import type { z } from '@hono/zod-openapi'; + +/** What survives admission: a slot claimed for a named, tenant-owned inspector. */ +export interface BookingClaim { + inspectorId: string; + requestedTime: string; + /** Carried through for the widget success/error telemetry the caller emits. */ + isWidgetSubmit: boolean; + originHeader: string | undefined; +} + +/** + * Everything that must hold BEFORE a booking writes a row, in the order it must + * hold, ending with a claimed slot. + * + * The seam is the absence of writes. Every check here can reject an anonymous + * stranger's form post with a 403/404/409 and leave the database exactly as it + * was; the moment one row exists the failure modes change completely (a losing + * booker has to be compensated, not refused — see `arbitrateSlotRace`). Keeping + * the refusals together is what makes it checkable that quota is consumed after + * all of them, which is the property `plan-quota-guarded-services.spec.ts` and + * the comment in `fulfillBooking`'s direct-insert branch both depend on. + * + * The two D1 handles are NOT interchangeable and both are parameters on + * purpose: `db` is drizzle over `c.env.DB` (the request's), `d1` is the + * BookingService instance's own. The original code used each where it is used + * here, and unit tests construct the service with a handle that is not + * `c.env.DB`. + */ +export async function admitBooking( + c: Context, + db: DrizzleD1Database, + d1: D1Database, + tenantId: string, + body: z.infer, +): Promise { + const service = c.var.services.booking; + + // Bot Protection — always enforce when secret is configured + if (c.env.TURNSTILE_SECRET_KEY) { + if (!body.turnstileToken) throw Errors.Forbidden('Security verification token missing.'); + const isValid = await service.verifyBotProtection(body.turnstileToken, c.env.TURNSTILE_SECRET_KEY); + if (!isValid) throw Errors.Forbidden('Security verification failed.'); + } + + // B2: when the booking originates from an embedded widget, enforce + // per-tenant origin allowlist. Non-embed (direct /book visit) submissions + // are unaffected. + const isWidgetSubmit = c.req.query('embed') === '1'; + const originHeader = c.req.header('origin'); + if (isWidgetSubmit) { + const ok = await c.var.services.widget.isOriginAllowed(tenantId, originHeader ?? null); + if (!ok) { + await c.var.services.widget.recordEvent(tenantId, 'error', { origin: originHeader, reason: 'origin_not_allowed' }); + throw Errors.Forbidden('Widget submissions from this origin are not allowed for this workspace.'); + } + } + + // IA-26 — inspectorId is now OPTIONAL. The company-level booking page + // submits without one (pure auto-assign); the legacy per-inspector + // deep link and the allowInspectorChoice dropdown still send it. + const serviceIdsForQual = (body.services ?? []).map(s => s.serviceId); + let inspectorId = body.inspectorId ?? null; + + if (inspectorId) { + // B-16 — a supplied inspector must belong to the resolved tenant; + // a mismatched id (tampered payload or stale form) must not reach + // into another tenant's availability/inspection space. + const inspectorRow = await db.select({ id: users.id }).from(users) + .where(and(eq(users.id, inspectorId), eq(users.tenantId, tenantId))) + .get(); + if (!inspectorRow) throw Errors.NotFound('Inspector not found.'); + } + + // B-16 (company-wide) — distinguish "nobody configured working hours" + // from a genuinely taken slot, with the honest not-open copy. + // qualifiedIds is computed once here and threaded through to avoid + // duplicate getQualifiedInspectorIds lookups in hasAnyHours / getTenantSlots. + const qualifiedIds = await service.getQualifiedInspectorIds(tenantId, serviceIdsForQual); + const bookingOpen = await service.hasAnyHours(tenantId, serviceIdsForQual, qualifiedIds); + if (!bookingOpen) { + throw Errors.Conflict('Online booking is not open yet. Please contact the company directly to schedule.'); + } + + const holiday = await resolvePublicHolidayEffect(d1, tenantId, body.date); + if (holiday.effect === 'block') { + throw Errors.BadRequest( + holiday.name + ? `The office is closed on ${holiday.name}. Please pick another date.` + : 'The office is closed on this date. Please pick another date.', + 'HOLIDAY_BLOCKED', + ); + } + + // Spec 3C / IA-26 — availability enforcement now runs on the tenant + // aggregation: a slot is bookable iff at least one QUALIFIED inspector + // is free (or the requested one, when the client chose). + let requestedTime: string; + switch (body.timeSlot) { + case 'morning': requestedTime = '08:00'; break; + case 'afternoon': requestedTime = '13:00'; break; + case 'all-day': requestedTime = '08:00'; break; + case 'custom': requestedTime = body.customTime ?? '08:00'; break; + } + // KNOWN RACE (advisory check): the slot read and the inspection insert + // below are not atomic and D1 offers no row locks, so two concurrent + // submits for the last slot can both pass and double-book the same + // inspector (deterministic pickInspector converges on one person). + // Accepted for launch traffic; a post-insert recheck/compensation is + // tracked in the backlog. Do NOT "fix" by randomizing the pick — the + // determinism is intentional (idempotent re-submits). + const { slots } = await service.getTenantSlots(tenantId, body.date, serviceIdsForQual, qualifiedIds); + const target = slots.find(s => s.time === requestedTime); + const freeIds = (target?.inspectorIds ?? []).filter(id => !inspectorId || id === inspectorId); + if (freeIds.length === 0) { + throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); + } + if (!inspectorId) { + inspectorId = await service.pickInspector(tenantId, freeIds); + if (!inspectorId) throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); + } + + return { inspectorId, requestedTime, isWidgetSubmit, originHeader }; +} diff --git a/server/services/booking/booking-confirmation.ts b/server/services/booking/booking-confirmation.ts new file mode 100644 index 000000000..7d4f4e3e9 --- /dev/null +++ b/server/services/booking/booking-confirmation.ts @@ -0,0 +1,162 @@ +import type { Context } from 'hono'; +import { eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { users } from '../../lib/db/schema'; +import { logger } from '../../lib/logger'; +import { CredentialService } from '../credential.service'; +import { createCalendarEvent } from '../../api/calendar'; +import { loadOpenGoogleConnection } from '../../lib/calendar/connection'; +import { loadGoogleOAuthMode, resolveGoogleOAuthCredentials } from '../../lib/calendar/resolve-google-oauth'; +import { canPushEvents } from '../../lib/calendar/provider'; +import { getBookingHost, getBaseUrl } from '../../lib/url'; +import type { HonoConfig } from '../../types/hono'; +import type { PublicBookingSchema } from '../../lib/validations/booking.schema'; +import type { z } from '@hono/zod-openapi'; + +type BookingBody = z.infer; + +/** + * Sprint 1 C-6 — map window option to a human-readable label for the + * calendar event + confirmation email. + */ +function windowLabelFor(body: BookingBody): string { + const windowLabel: Record = { + 'morning': 'Morning (8:00 AM – 12:00 PM)', + 'afternoon': 'Afternoon (12:00 PM – 4:00 PM)', + 'all-day': 'All day (8:00 AM – 5:00 PM)', + 'custom': body.customTime ? `${body.customTime}` : 'Custom time', + }; + return windowLabel[body.timeSlot]; +} + +export interface BookingConfirmationInput { + inspectorId: string; + requestedTime: string; + inspectionId: string; + /** Null when the contact upsert was skipped or failed; suppresses the opt-in link. */ + bookingClientContactId: string | null; +} + +/** + * Everything the outside world hears about a booking after it exists: the + * inspector's Google Calendar and the customer's confirmation email. + * + * The seam is "after the answer is sent". The caller hands this to + * `waitUntil`, so NOTHING here can change the response the booker already got, + * and nothing here may throw in a way that matters — every leg either catches + * or is `.catch`-ed. That is also why the read of `users` happens here rather + * than being threaded in: this runs detached, and one more query on the + * detached side costs the booker nothing. + * + * Kept as one function rather than two because the calendar push and the email + * share the resolved inspector row and the same start instant, and because + * "what the booking announces" is the thing a reader comes here looking for. + */ +export async function dispatchBookingConfirmation( + c: Context, + db: DrizzleD1Database, + tenantId: string, + body: BookingBody, + input: BookingConfirmationInput, +): Promise { + const { inspectorId, requestedTime, inspectionId, bookingClientContactId } = input; + const windowLabel = windowLabelFor(body); + + const inspector = await db.select().from(users).where(eq(users.id, inspectorId)).get(); + const open = await loadOpenGoogleConnection( + c.env.DB, + tenantId, + inspectorId, + c.env.JWT_SECRET, + c.env.JWT_SECRET_PREVIOUS, + ); + if (open && canPushEvents(open.connection.capabilities)) { + const oauthMode = await loadGoogleOAuthMode(c.env.DB, tenantId); + const oauthCreds = await resolveGoogleOAuthCredentials(c.env, tenantId, oauthMode); + if (oauthCreds) { + const startDateTime = `${body.date}T${requestedTime}:00Z`; + await createCalendarEvent( + oauthCreds.clientId, + oauthCreds.clientSecret, + open.credentials.refreshToken, + open.connection.calendarId, + `Inspection: ${body.address}`, + startDateTime, + body.address, + ).catch(e => logger.error('Calendar sync failed', {}, e instanceof Error ? e : undefined)); + } + } + + const emailService = c.var.services.email; + + // Sprint 1 C-10 — build the ICS event so the confirmation email + // carries a calendar invite the customer can import into Apple + // Calendar / Google Calendar. Duration defaults to 3 hours, with + // 4 hours for morning/afternoon windows and 9 hours for all-day. + const startMs = new Date(`${body.date}T${requestedTime}:00Z`).getTime(); + let durationHours: number; + switch (body.timeSlot) { + case 'all-day': durationHours = 9; break; + case 'morning': + case 'afternoon': durationHours = 4; break; + default: durationHours = 3; break; + } + const endMs = startMs + durationHours * 60 * 60 * 1000; + // Booking-confirmation greeting falls back to the brand, never the + // inspector's inbox — keeps the email looking professional even if a + // legacy account is missing a display name. + const inspectorName = inspector?.name || c.env.APP_NAME || 'Your inspector'; + const inspectorEmail = inspector?.email || c.env.SENDER_EMAIL || `noreply@${c.env.APP_NAME?.toLowerCase().replace(/\s/g, '') || 'inspector'}.com`; + + // Spec B — the assigned inspector's active credentials, for the footer. + // Via the shared mapper, so this footer and the email signature can + // never disagree about the badge URL form. + const bookingCreds = inspector + ? await new CredentialService(c.env.DB).listRenderable(tenantId, inspectorId) + : []; + // Sprint B-4a — append inspector signature so customers can rebook + // with the same inspector via the per-inspector booking link. + const sigInspector = inspector ? { + name: inspector.name ?? null, + email: inspector.email ?? null, + phone: inspector.phone ?? null, + slug: inspector.slug ?? null, + credentials: bookingCreds, + } : undefined; + // Track L (D6, path B) — double-opt-in link injected at the RENDERER + // level (not gated on any automation rule) so disabling a rule never + // removes the only opt-in path. The token self-describes (tenant, + // contact) — see lib/sms/optin-token.ts. Best-effort: a token failure + // simply omits the link. + let smsOptinUrl: string | undefined; + if (bookingClientContactId && c.env.JWT_SECRET) { + try { + const { mintOptinToken } = await import('../../lib/sms/optin-token'); + const token = await mintOptinToken(tenantId, bookingClientContactId, c.env.JWT_SECRET); + smsOptinUrl = `${getBaseUrl(c)}/sms-optin/${encodeURIComponent(token)}`; + } catch (e) { + logger.warn('booking.sms-optin.mint.failed', { inspectionId, error: e instanceof Error ? e.message : String(e) }); + } + } + + await emailService.sendBookingConfirmation( + body.clientEmail, + body.clientName, + body.address, + body.date, + windowLabel, + { + uid: `inspection-${inspectionId}`, + summary: `Home Inspection at ${body.address}`, + description: `Inspector: ${inspectorName}\nWindow: ${windowLabel}\n\nWe will send your detailed report within 24 hours of completion.`, + location: body.address, + start: new Date(startMs), + end: new Date(endMs), + organizerEmail: inspectorEmail, + organizerName: inspectorName, + }, + sigInspector, + getBookingHost(c), + smsOptinUrl, + ).catch(e => logger.error('Booking confirmation email failed', {}, e instanceof Error ? e : undefined)); +} diff --git a/server/services/booking/booking-people.ts b/server/services/booking/booking-people.ts new file mode 100644 index 000000000..8a876a43d --- /dev/null +++ b/server/services/booking/booking-people.ts @@ -0,0 +1,180 @@ +import type { Context } from 'hono'; +import { eq, and, isNull } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { users, contactRoleProfiles } from '../../lib/db/schema'; +import { contacts } from '../../lib/db/schema/contact'; +import { logger } from '../../lib/logger'; +import { normalizeLocale } from '../../lib/i18n/contact-locale'; +import { PeopleService } from '../people.service'; +import type { HonoConfig } from '../../types/hono'; +import type { PublicBookingSchema } from '../../lib/validations/booking.schema'; +import type { z } from '@hono/zod-openapi'; + +/** + * WHO a public booking is for and who gets credit for it. + * + * Task 13 dropped `inspections.clientContactId` / `referredByAgentId` / + * `sellingAgentId`, so `inspection_people` is now the ONLY persistence of who + * is attached to an inspection — and on the booking path that is written from + * two ends: the referring agent is resolved from the URL before anything + * exists, the client is upserted after the rows commit. Splitting them across + * two files is how one of them gets forgotten, so both live here. + * + * EVERY WRITE IN THIS MODULE IS NON-FATAL BY DESIGN. The inspection rows have + * already committed by the time `attachBookingPeople` runs; an anonymous booker + * must never see a 500 because of contact bookkeeping, and logs carry inspection + * ids and messages only, never the client's email. + */ + +/** + * UC-A-1 — agent referral attribution. Resolve `?ref=` (sent + * through the form as agentRefSlug) to a contacts.id in this tenant. + * Two requirements both need to hold: + * 1. A global agent user with that slug exists. + * 2. They have an `active` agent_tenant_links row for THIS tenant whose + * inspectorContactId points at the agent's contact row. + * Either failure leaves the result null — bookings with bad slugs still + * succeed; we just don't credit the (unknown) agent. + */ +export async function resolveBookingAgentReferral( + db: DrizzleD1Database, + tenantId: string, + agentRefSlug: string | undefined, +): Promise { + if (!agentRefSlug) return null; + try { + const agent = await db.select({ id: users.id }) + .from(users) + .where(and( + eq(users.slug, agentRefSlug), + isNull(users.tenantId), + eq(users.role, 'agent'), + )) + .get(); + if (!agent) return null; + // IA-104 — the agent's contact in THIS tenant is the row + // bound to their account; no link hop. + const link = await db.select({ contactId: contacts.id }) + .from(contacts) + .where(and( + eq(contacts.agentUserId, agent.id), + eq(contacts.tenantId, tenantId), + isNull(contacts.agentRevokedAt), + )) + .get(); + return link?.contactId ?? null; + } catch (err) { + logger.warn('booking.agentRef.resolve.failed', { + slug: agentRefSlug, + tenantId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +export interface BookingPeopleInput { + /** Every inspection this booking created; the client is linked to all of them. */ + allInspectionIds: string[]; + /** Set only by the legacy single-service direct-insert branch. */ + directInsertInspectionId: string | null; + resolvedAgentContactId: string | null; + /** The BookingService instance's own D1 handle — see `admitBooking`'s note. */ + d1: D1Database; +} + +/** + * Capture the booker as a Client contact, mirror client + buyer_agent into + * `inspection_people`, and record an SMS consent event when the box was ticked. + * Returns the client contact id, which the confirmation email needs to mint the + * double-opt-in link. + * + * PLACEMENT MATTERS AND IS THE CALLER'S RESPONSIBILITY: this must run AFTER + * slot arbitration. A losing booker self-revokes and throws, so we never stamp a + * contact onto inspections that were just deleted. (A stray contact row is + * harmless on its own — what we avoid is a contact pointing at vanished + * inspections.) It also runs BEFORE the side-effect block, to keep the + * synchronous DB writes grouped ahead of async waitUntil work. + */ +export async function attachBookingPeople( + c: Context, + db: DrizzleD1Database, + tenantId: string, + body: z.infer, + input: BookingPeopleInput, +): Promise { + const { allInspectionIds, directInsertInspectionId, resolvedAgentContactId, d1 } = input; + + // IA-18 (#111) — capture the booker as a Client contact and link it to + // ALL inspections this booking created so their client appears in + // Contacts and on the inspector portal People card. + let bookingClientContactId: string | null = null; + if (body.clientEmail || body.clientName) { + try { + const { id: clientContactId } = await c.var.services.contact.upsertClientContact(tenantId, { + 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) { + logger.warn('booking.client-contact.upsert.failed', { + inspectionIds: allInspectionIds, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + // Task 7b (people-role-profiles), FIXED — mirror client + buyer_agent + // into inspection_people. Client covers EVERY allInspectionIds entry + // (bookingClientContactId is linked to all of them, incl. multi-service + // sub-inspections, which only get buyer_agent from + // InspectionRequestService.create — the original bug wrongly scoped + // the client write to directInsertInspectionId alone). + if (bookingClientContactId || (directInsertInspectionId && resolvedAgentContactId)) { + try { + const roleRows = await db.select({ id: contactRoleProfiles.id, key: contactRoleProfiles.key }) + .from(contactRoleProfiles) + .where(and(eq(contactRoleProfiles.tenantId, tenantId), eq(contactRoleProfiles.active, true))); + const roleIdByKey = new Map(roleRows.map(r => [r.key, r.id])); + const people = new PeopleService({ DB: d1 }); + const clientRoleId = roleIdByKey.get('client'); + if (bookingClientContactId && clientRoleId) { + for (const inspId of allInspectionIds) { + await people.addPerson(tenantId, inspId, bookingClientContactId, clientRoleId); + } + } + const buyerAgentRoleId = roleIdByKey.get('buyer_agent'); + if (directInsertInspectionId && resolvedAgentContactId && buyerAgentRoleId) { + await people.addPerson(tenantId, directInsertInspectionId, resolvedAgentContactId, buyerAgentRoleId); + } + } catch (err) { + logger.error('inspection-people write from booking create failed', { inspectionIds: allInspectionIds }, err instanceof Error ? err : undefined); + } + } + + // Track L (D6, path A) — self-book SMS opt-in. The checkbox is unchecked by + // default; when ticked we record a `granted` consent event (captured_via= + // booking_form) keyed on the client contact. Non-fatal: a consent write must + // never fail the booking (the inspection rows already committed). + if (body.smsOptin && bookingClientContactId) { + try { + const { SmsConsentService } = await import('../sms-consent.service'); + await new SmsConsentService(c.env.DB).record( + tenantId, bookingClientContactId, 'granted', 'booking_form', + { ip: c.req.header('CF-Connecting-IP'), userAgent: c.req.header('User-Agent') }, + ); + } catch (e) { + logger.warn('booking.sms-optin.record.failed', { + inspectionId: allInspectionIds[0], error: e instanceof Error ? e.message : String(e), + }); + } + } + + return bookingClientContactId; +} diff --git a/server/services/booking/fulfill-booking.ts b/server/services/booking/fulfill-booking.ts new file mode 100644 index 000000000..84fb3cfc5 --- /dev/null +++ b/server/services/booking/fulfill-booking.ts @@ -0,0 +1,253 @@ +import type { Context } from 'hono'; +import { drizzle } from 'drizzle-orm/d1'; +import { eq, and, inArray } from 'drizzle-orm'; +import { inspections, inspectionRequests, tenantConfigs, services as servicesTable } from '../../lib/db/schema'; +import { wallClockToEpochMs, resolveTenantTimeZone } from '../../lib/tz'; +import { Errors } from '../../lib/errors'; +import { logger } from '../../lib/logger'; +import { fireAutomation } from '../inspection/shared'; +import { syncInspectionAssignments } from '../../lib/db/assignment-links'; +import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; +import { admitBooking } from './booking-admission'; +import { resolveBookingAgentReferral, attachBookingPeople } from './booking-people'; +import { dispatchBookingConfirmation } from './booking-confirmation'; +import type { HonoConfig } from '../../types/hono'; +import type { PublicBookingSchema } from '../../lib/validations/booking.schema'; +import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; +import type { z } from '@hono/zod-openapi'; + +/** + * The BookingService instance's own runtime deps. Passed rather than read off + * `c.env`: `d1` is the handle the service was CONSTRUCTED with, which unit + * tests deliberately make different from `c.env.DB`. + */ +export interface FulfillBookingDeps { + d1: D1Database; + planQuota?: PlanQuotaGuard | undefined; +} + +/** + * Turn an accepted public booking into rows, and defend them. + * + * What is left here after the three extractions is exactly the part that + * WRITES and the part that has to clean up after itself: the request + + * inspection rows (two branches, multi-service via InspectionRequestService and + * the legacy single-service direct insert), the post-insert race arbitration + * that may have to revoke what was just written, and the scheduled-instant + * stamp over the whole set. Those three cannot be separated — the arbitration + * only means anything against rows this function created, and the stamp only + * runs on the survivors. + * + * The neighbours are `./booking-admission` (everything before the first write), + * `./booking-people` (who the booking is for), and `./booking-confirmation` + * (what the world hears afterwards). + */ +export async function fulfillBooking( + deps: FulfillBookingDeps, + c: Context, + tenantId: string, + body: z.infer, +) { + const service = c.var.services.booking; + const db = drizzle(c.env.DB); + + const { inspectorId, requestedTime, isWidgetSubmit, originHeader } = + await admitBooking(c, db, deps.d1, tenantId, body); + + const resolvedAgentContactId = await resolveBookingAgentReferral(db, tenantId, body.agentRefSlug); + + // Sprint 2 S2-2 — When the customer selects multiple services, we route + // through InspectionRequestService so the resulting inspections are + // grouped under a parent request. The legacy single-service flow still + // creates a one-inspection request implicitly so dashboards can group + // every booking the same way. + const startIso = `${body.date}T${requestedTime}:00Z`; + const inspectionRequestService = c.var.services.inspectionRequest; + let createdRequestId: string; + let primaryInspectionId: string; + let allInspectionIds: string[] = []; + // Task 7b (people-role-profiles) — set only by the direct-insert + // (legacy single-service) branch below. The multi-service branch + // routes through InspectionRequestService.create, which owns its + // own inspection_people write for the inspections it creates. + let directInsertInspectionId: string | null = null; + // Booked duration from the chosen service(s); NULL when the legacy path + // carries no explicit service (falls back to the time-slot window below). + let bookedServiceDurationMin: number | null = null; + + if (body.services && body.services.length > 0) { + const serviceIds = body.services.map(s => s.serviceId); + const svcRows = await db.select().from(servicesTable) + .where(and(eq(servicesTable.tenantId, tenantId), inArray(servicesTable.id, serviceIds))) + .all(); + if (svcRows.length !== serviceIds.length) { + throw Errors.BadRequest('One or more services were not found.'); + } + // Total booked minutes across the selected services (back-to-back); + // NULL when none carry a duration, so the time-slot window is used. + bookedServiceDurationMin = + svcRows.reduce((sum, s) => sum + (s.durationMinutes ?? 0), 0) || null; + const subs = svcRows.map(s => { + const sub: { templateId: string; price: number } = { + templateId: s.templateId ?? '', + price: s.price ?? 0, + }; + if (!sub.templateId) throw Errors.BadRequest(`Service '${s.name}' has no template configured.`); + return sub; + }); + const created = await inspectionRequestService.create(tenantId, { + clientName: body.clientName, + clientEmail: body.clientEmail, + propertyAddress: body.address, + scheduledAt: startIso, + inspectorId, + referredByAgentId: resolvedAgentContactId, + }, subs); + createdRequestId = created.id; + allInspectionIds = created.inspections.map(i => i.id); + primaryInspectionId = allInspectionIds[0] ?? ''; + } else { + primaryInspectionId = crypto.randomUUID(); + createdRequestId = `req-${primaryInspectionId}`; + const now = new Date(); + // Quota is consumed AFTER every precondition check in admitBooking (bot + // protection, widget origin, inspector ownership, booking-open, + // slot availability) and BEFORE either row below is inserted — + // the request row must never be orphaned (created with no + // inspection behind it) because the tenant hit the cap. + await deps.planQuota?.consumeInspection(tenantId); + // Insert one-inspection request first so the FK is satisfied. + await db.insert(inspectionRequests).values({ + id: createdRequestId, + tenantId, + clientName: body.clientName, + clientEmail: body.clientEmail, + propertyAddress: body.address, + scheduledAt: new Date(startIso), + status: 'pending', + totalAmount: 0, + paymentStatus: 'unpaid', + createdAt: now, + updatedAt: now, + }); + await db.insert(inspections).values({ + id: primaryInspectionId, + tenantId, + inspectorId, + propertyAddress: body.address, + // B-28 adjacent fix — store the full start ISO like the + // multi-service path (inspection-request.service create) does. + // Busy checks read HH:MM at slice(11,16) of this value; the old + // bare `body.date` never marked the slot busy, so even + // sequential double-booking succeeded. + date: startIso, + status: INSPECTION_STATUS.REQUESTED, + paymentStatus: 'unpaid', + price: 0, + requestId: createdRequestId, + createdAt: now + }); + // DB-8: mirror assignment into inspection_inspectors link table. + // Non-fatal — the link table is a denormalized mirror; a sync failure + // must never 500 an anonymous booker whose inspection row already committed. + try { + await syncInspectionAssignments(db, tenantId, primaryInspectionId, { inspectorId }); + } catch (e) { + logger.error('booking.assignment-sync.failed', { inspectionId: primaryInspectionId }, e instanceof Error ? e : undefined); + } + allInspectionIds = [primaryInspectionId]; + directInsertInspectionId = primaryInspectionId; + } + const inspectionId = primaryInspectionId; + + // B-28 — post-insert TOCTOU recheck. Runs after our insert and BEFORE + // any side effect (confirmation email, calendar event, notifications) + // so a losing booker only ever sees the 409, never a confirmation for + // a booking that then vanishes. The arbitration is deterministic + // (earliest (createdAt, id) wins), so of two racers exactly one + // self-compensates here while the other proceeds untouched. + const verdict = await service.arbitrateSlotRace( + tenantId, inspectorId, body.date, requestedTime, createdRequestId, + ); + if (verdict === 'lose') { + await service.revokeBooking(tenantId, createdRequestId); + throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); + } + + // A-polish 9b — stamp the precise scheduled instant on every inspection + // this booking created. The wall-clock slot time is interpreted in the + // TENANT tz (not the naive :00Z of the startIso busy-check key), so + // downstream conflict detection, Google push, and the ICS feed reason + // about real instants. inspections.date is deliberately left untouched — + // it still keys the HH:MM busy-checks via slice(11,16). Non-fatal: the + // inspection rows already committed, so a stamp failure must not 500 the + // booker (conflict detection just falls back to the hour-bucket). + const tzRow = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const tenantTz = resolveTenantTimeZone(tzRow?.defaultTimezone); + const scheduledStartMs = wallClockToEpochMs(body.date, requestedTime, tenantTz); + const slotWindowMin = + body.timeSlot === 'all-day' ? 540 + : body.timeSlot === 'morning' || body.timeSlot === 'afternoon' ? 240 + : 180; + const durationMin = bookedServiceDurationMin ?? slotWindowMin; + const scheduledEndMs = scheduledStartMs + durationMin * 60000; + try { + await db.update(inspections) + .set({ + scheduledStartMs: new Date(scheduledStartMs), + scheduledEndMs: new Date(scheduledEndMs), + durationMin, + }) + .where(and( + inArray(inspections.id, allInspectionIds), + eq(inspections.tenantId, tenantId), + )); + } catch (e) { + logger.warn('booking.scheduled-instant.stamp.failed', { + inspectionIds: allInspectionIds, + error: e instanceof Error ? e.message : String(e), + }); + } + + const bookingClientContactId = await attachBookingPeople(c, db, tenantId, body, { + allInspectionIds, + directInsertInspectionId, + resolvedAgentContactId, + d1: deps.d1, + }); + + // Async tasks + c.executionCtx.waitUntil(dispatchBookingConfirmation(c, db, tenantId, body, { + inspectorId, + requestedTime, + inspectionId, + bookingClientContactId, + })); + + if (isWidgetSubmit) { + c.executionCtx.waitUntil( + c.var.services.widget.recordEvent(tenantId, 'success', { origin: originHeader, inspectionId }) + ); + } + + // B3 — the office alert is a rule now (`Office alert — new booking`, + // recipientKind 'staff', channel in_app). `booking.received` is its own + // trigger rather than a reuse of `inspection.created`: a booking is a + // stranger arriving through the public form, while an inspection can + // also be created by the office itself, and alerting someone about + // their own action is noise. + c.executionCtx.waitUntil( + fireAutomation(c.env.DB, tenantId, inspectionId, 'booking.received'), + ); + + return c.json({ + success: true, + data: { + success: true, + inspectionId, + requestId: createdRequestId, + inspectionIds: allInspectionIds, + } + }, 200); +} diff --git a/server/types/hono.ts b/server/types/hono.ts index bb3fad279..ff123a250 100644 --- a/server/types/hono.ts +++ b/server/types/hono.ts @@ -235,7 +235,8 @@ import type { AdminService } from '../services/admin.service'; import type { AIService } from '../services/ai.service'; import type { AuthService } from '../services/auth.service'; import type { UserSyncOutbox } from '../lib/integration/user-sync'; -import type { BookingService, AvailabilityService } from '../services/booking.service'; +import type { BookingService } from '../services/booking.service'; +import type { AvailabilityService } from '../services/availability.service'; import type { BrandingService } from '../services/branding.service'; import type { LegalVersionService } from '../services/legal-version.service'; import type { EmailService } from '../services/email.service'; diff --git a/tests/unit/bookings/booking-delete-override-scope.spec.ts b/tests/unit/bookings/booking-delete-override-scope.spec.ts index 6c0e7a426..c376c59fc 100644 --- a/tests/unit/bookings/booking-delete-override-scope.spec.ts +++ b/tests/unit/bookings/booking-delete-override-scope.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createTestDb, setupSchema } from '../db'; -import { AvailabilityService } from '../../../server/services/booking.service'; +import { AvailabilityService } from '../../../server/services/availability.service'; import { tenants, users, availabilityOverrides } from '../../../server/lib/db/schema'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; import * as schema from '../../../server/lib/db/schema'; From f45f62307662810affdf28dd3ef75cdcd8181994 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 14:55:45 +0800 Subject: [PATCH 39/77] refactor(requests): reading a request and writing one stop sharing a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inspection-request.service.ts` sat at 500 lines against a 501 cap and is on booking-deposit (#20)'s path. Pure move plus one deduplication, described below; the 138 tests across tests/unit/{inspections,usage,bookings} pass untouched. The seam is read vs write, and the two share nothing but table names. Writing is quota, template-ownership validation, inserts and the `inspection_people` mirror. Reading is one projection — parent plus children plus template names — and its correctness rests on an invariant that has nothing to do with writing: CLIENT NAME COMES FROM `inspection_people`, NEVER FROM `inspections.client_name`. That column survives GDPR erasure as a stale denormalized cache, so reading it leaks an erased subject's name. The Task 9c comment saying so was written twice, above two verbatim copies of a four-table LEFT JOIN — once in `list`, once in `get`. In `inspection-request/request-read.ts` it is `selectSubInspections`, said once, narrowed by the caller's condition. THAT IS THE ONE NON-MOVE IN THIS COMMIT: same joins, same order (role filter first, so the join does not fan out over every role), same projection, same `where` shape with the caller's predicate in the position the caller's predicate was in. `ListFilter`, `SubInspectionRow` and `RequestRow` go with it; only `ListFilter` comes back, as a type import for the delegating signature. `shapeRequest` is module-private, not exported — nothing outside may build the API shape by hand, and the dead-code gate agrees. `list` and `get` stay as methods. `get` in particular is reloaded through by `create`, `addSubInspection`, `update` and `getByInspectionId`, so making it a free function would have rewritten four call sites inside the same file for no gain. Baseline ENTRY removed, not tightened. 345 lines is under the ordinary 400-line rule; re-baselining at 345 would have rebuilt the wall. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- scripts/file-size-baseline.json | 1 - server/services/inspection-request.service.ts | 171 +--------------- .../inspection-request/request-read.ts | 183 ++++++++++++++++++ 3 files changed, 191 insertions(+), 164 deletions(-) create mode 100644 server/services/inspection-request/request-read.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index c3d4f530a..c6c13b1da 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -43,7 +43,6 @@ "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/repair-builder.ts": 504, "app/routes/inspection-edit/action.server.ts": 501, - "server/services/inspection-request.service.ts": 501, "app/routes/settings-workspace.tsx": 499, "server/services/report-export-consumer.ts": 499, "app/components/collab/VersionHistoryPanel.tsx": 497, diff --git a/server/services/inspection-request.service.ts b/server/services/inspection-request.service.ts index aea9d63fa..d204729b0 100644 --- a/server/services/inspection-request.service.ts +++ b/server/services/inspection-request.service.ts @@ -7,23 +7,20 @@ */ import { drizzle } from 'drizzle-orm/d1'; -import { and, eq, gte, lte, inArray, desc } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { inspectionRequests, inspections, templates, contactRoleProfiles, - inspectionPeople, - contacts, } from '../lib/db/schema'; -import { PRIMARY_CLIENT_KEY } from '../lib/people/default-role-profiles'; import { Errors } from '../lib/errors'; -import { safeISODate } from '../lib/date'; import { logger } from '../lib/logger'; import { syncInspectionAssignments } from '../lib/db/assignment-links'; import { INSPECTION_STATUS } from '../lib/status/inspection-status'; import { PeopleService } from './people.service'; import { ContactService } from './contact.service'; +import { listRequests, getRequest, type ListFilter } from './inspection-request/request-read'; import type { PlanQuotaGuard } from '../features/plan-quota/guard'; export interface CreateRequestInput { @@ -64,28 +61,6 @@ export interface UpdateRequestInput { totalAmount?: number; } -interface ListFilter { - status?: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled'; - from?: string; - to?: string; - limit?: number; - offset?: number; -} - -type SubInspectionRow = { - id: string; - templateId: string | null; - propertyAddress: string; - clientName: string | null; - status: string; - date: string; - price: number; - inspectorId: string | null; - requestId: string | null; -}; - -type RequestRow = typeof inspectionRequests.$inferSelect; - export class InspectionRequestService { /** * Free-tier usage-quota guard (optional). Present only in SaaS deploys @@ -101,117 +76,19 @@ export class InspectionRequestService { /** * List parent requests for the tenant, eager-loading child inspections. - * Filters can narrow by status / date window. Pagination is offset-based - * (offset/limit) — cursor pagination not needed at this scale. + * Body in `./inspection-request/request-read`. */ async list(tenantId: string, filter: ListFilter = {}) { - const db = this.getDrizzle(); - const conds = [eq(inspectionRequests.tenantId, tenantId)]; - if (filter.status) conds.push(eq(inspectionRequests.status, filter.status)); - if (filter.from) conds.push(gte(inspectionRequests.scheduledAt, new Date(filter.from))); - if (filter.to) conds.push(lte(inspectionRequests.scheduledAt, new Date(filter.to))); - - const limit = filter.limit ?? 50; - const offset = filter.offset ?? 0; - - const reqs = await db.select().from(inspectionRequests) - .where(and(...conds)) - .orderBy(desc(inspectionRequests.scheduledAt)) - .limit(limit) - .offset(offset) - .all(); - - const reqIds = reqs.map(r => r.id); - // Task 9c — clientName is sourced via the client-role inspection_people - // LEFT JOIN (role filter joined FIRST to avoid fanning out over every - // role on the sub-inspection — same pattern as api/metrics.ts / - // InspectionCoreService.listInspections), NOT the legacy - // inspections.client_name column, which survives GDPR erasure as a - // stale denormalized cache and would leak the erased subject's name. - const subRows: SubInspectionRow[] = reqIds.length === 0 ? [] : await db.select({ - id: inspections.id, - templateId: inspections.templateId, - propertyAddress: inspections.propertyAddress, - clientName: contacts.name, - status: inspections.status, - date: inspections.date, - price: inspections.price, - inspectorId: inspections.inspectorId, - requestId: inspections.requestId, - }).from(inspections) - .leftJoin(contactRoleProfiles, and( - eq(contactRoleProfiles.tenantId, inspections.tenantId), - eq(contactRoleProfiles.key, PRIMARY_CLIENT_KEY), - eq(contactRoleProfiles.active, true), - )) - .leftJoin(inspectionPeople, and( - eq(inspectionPeople.roleProfileId, contactRoleProfiles.id), - eq(inspectionPeople.inspectionId, inspections.id), - eq(inspectionPeople.tenantId, inspections.tenantId), - )) - .leftJoin(contacts, and( - eq(contacts.id, inspectionPeople.contactId), - eq(contacts.tenantId, inspections.tenantId), - )) - .where(and(eq(inspections.tenantId, tenantId), inArray(inspections.requestId, reqIds))) - .all(); - - return reqs.map(r => this.shapeRequest(r, subRows.filter(s => s.requestId === r.id))); + return listRequests(this.getDrizzle(), tenantId, filter); } /** - * Fetch a single parent request with its children (tenant-scoped). - * Returns null when not found. Resolves child template names so callers - * (e.g. the inspection-edit request switcher) can render readable chips - * without an extra round-trip. + * Fetch a single parent request with its children (tenant-scoped), or null. + * Stays a method: `create`, `addSubInspection` and `update` all reload + * through it, as does `getByInspectionId` below. */ async get(tenantId: string, id: string) { - const db = this.getDrizzle(); - const req = await db.select().from(inspectionRequests) - .where(and(eq(inspectionRequests.id, id), eq(inspectionRequests.tenantId, tenantId))) - .get(); - if (!req) return null; - - // Task 9c — same client-role inspection_people join as list() above. - const subs: SubInspectionRow[] = await db.select({ - id: inspections.id, - templateId: inspections.templateId, - propertyAddress: inspections.propertyAddress, - clientName: contacts.name, - status: inspections.status, - date: inspections.date, - price: inspections.price, - inspectorId: inspections.inspectorId, - requestId: inspections.requestId, - }).from(inspections) - .leftJoin(contactRoleProfiles, and( - eq(contactRoleProfiles.tenantId, inspections.tenantId), - eq(contactRoleProfiles.key, PRIMARY_CLIENT_KEY), - eq(contactRoleProfiles.active, true), - )) - .leftJoin(inspectionPeople, and( - eq(inspectionPeople.roleProfileId, contactRoleProfiles.id), - eq(inspectionPeople.inspectionId, inspections.id), - eq(inspectionPeople.tenantId, inspections.tenantId), - )) - .leftJoin(contacts, and( - eq(contacts.id, inspectionPeople.contactId), - eq(contacts.tenantId, inspections.tenantId), - )) - .where(and(eq(inspections.tenantId, tenantId), eq(inspections.requestId, id))) - .all(); - - const tplIds = Array.from(new Set(subs.map(s => s.templateId).filter((x): x is string => !!x))); - const tplNameById = new Map(); - if (tplIds.length > 0) { - const tplRows = await db.select({ id: templates.id, name: templates.name }) - .from(templates) - .where(and(eq(templates.tenantId, tenantId), inArray(templates.id, tplIds))) - .all(); - for (const t of tplRows) tplNameById.set(t.id, t.name); - } - - return this.shapeRequest(req, subs, tplNameById); + return getRequest(this.getDrizzle(), tenantId, id); } /** @@ -465,36 +342,4 @@ export class InspectionRequestService { if (!detail) throw Errors.Internal('Failed to reload updated request'); return detail; } - - private shapeRequest(req: RequestRow, subs: SubInspectionRow[], tplNameById?: Map) { - return { - id: req.id, - tenantId: req.tenantId, - clientName: req.clientName, - clientEmail: req.clientEmail, - clientPhone: req.clientPhone, - propertyAddress: req.propertyAddress, - propertyCity: req.propertyCity, - propertyState: req.propertyState, - propertyZip: req.propertyZip, - scheduledAt: safeISODate(req.scheduledAt), - status: req.status, - notes: req.notes, - totalAmount: req.totalAmount, - paymentStatus: req.paymentStatus, - createdAt: safeISODate(req.createdAt), - updatedAt: safeISODate(req.updatedAt), - inspections: subs.map(s => ({ - id: s.id, - templateId: s.templateId, - templateName: (s.templateId && tplNameById?.get(s.templateId)) || null, - propertyAddress: s.propertyAddress, - clientName: s.clientName, - status: s.status, - date: s.date, - price: s.price, - inspectorId: s.inspectorId, - })), - }; - } } diff --git a/server/services/inspection-request/request-read.ts b/server/services/inspection-request/request-read.ts new file mode 100644 index 000000000..18f2afd41 --- /dev/null +++ b/server/services/inspection-request/request-read.ts @@ -0,0 +1,183 @@ +import { and, eq, gte, lte, inArray, desc, type SQL } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { + inspectionRequests, + inspections, + templates, + contactRoleProfiles, + inspectionPeople, + contacts, +} from '../../lib/db/schema'; +import { PRIMARY_CLIENT_KEY } from '../../lib/people/default-role-profiles'; +import { safeISODate } from '../../lib/date'; + +/** + * READING an inspection request: the joins that assemble a parent plus its + * children, and the single shape every read returns to the API. + * + * Separated from the write side because the two share nothing but the table + * names. Writing is quota, ownership checks, inserts and the people mirror; + * reading is one projection, and its correctness rests on one invariant that + * has nothing to do with writing: + * + * CLIENT NAME COMES FROM `inspection_people`, NEVER FROM + * `inspections.client_name`. That column survives GDPR erasure as a stale + * denormalized cache, so reading it would leak an erased subject's name. The + * join is written once here (`selectSubInspections`) rather than twice — it was + * copied verbatim between `list` and `get`, which is exactly the shape of + * duplication where one copy gets the next fix. + * + * The role filter is joined FIRST so the join does not fan out over every role + * on a sub-inspection — the same pattern as `api/metrics.ts` and + * `InspectionCoreService.listInspections`. + */ + +export interface ListFilter { + status?: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled'; + from?: string; + to?: string; + limit?: number; + offset?: number; +} + +export type SubInspectionRow = { + id: string; + templateId: string | null; + propertyAddress: string; + clientName: string | null; + status: string; + date: string; + price: number; + inspectorId: string | null; + requestId: string | null; +}; + +export type RequestRow = typeof inspectionRequests.$inferSelect; + +/** The client-role projection over `inspections`, narrowed by the caller. */ +function selectSubInspections( + db: DrizzleD1Database, + tenantId: string, + narrow: SQL | undefined, +): Promise { + return db.select({ + id: inspections.id, + templateId: inspections.templateId, + propertyAddress: inspections.propertyAddress, + clientName: contacts.name, + status: inspections.status, + date: inspections.date, + price: inspections.price, + inspectorId: inspections.inspectorId, + requestId: inspections.requestId, + }).from(inspections) + .leftJoin(contactRoleProfiles, and( + eq(contactRoleProfiles.tenantId, inspections.tenantId), + eq(contactRoleProfiles.key, PRIMARY_CLIENT_KEY), + eq(contactRoleProfiles.active, true), + )) + .leftJoin(inspectionPeople, and( + eq(inspectionPeople.roleProfileId, contactRoleProfiles.id), + eq(inspectionPeople.inspectionId, inspections.id), + eq(inspectionPeople.tenantId, inspections.tenantId), + )) + .leftJoin(contacts, and( + eq(contacts.id, inspectionPeople.contactId), + eq(contacts.tenantId, inspections.tenantId), + )) + .where(and(eq(inspections.tenantId, tenantId), narrow)) + .all(); +} + +/** + * List parent requests for the tenant, eager-loading child inspections. + * Filters can narrow by status / date window. Pagination is offset-based + * (offset/limit) — cursor pagination not needed at this scale. + */ +export async function listRequests(db: DrizzleD1Database, tenantId: string, filter: ListFilter = {}) { + const conds = [eq(inspectionRequests.tenantId, tenantId)]; + if (filter.status) conds.push(eq(inspectionRequests.status, filter.status)); + if (filter.from) conds.push(gte(inspectionRequests.scheduledAt, new Date(filter.from))); + if (filter.to) conds.push(lte(inspectionRequests.scheduledAt, new Date(filter.to))); + + const limit = filter.limit ?? 50; + const offset = filter.offset ?? 0; + + const reqs = await db.select().from(inspectionRequests) + .where(and(...conds)) + .orderBy(desc(inspectionRequests.scheduledAt)) + .limit(limit) + .offset(offset) + .all(); + + const reqIds = reqs.map(r => r.id); + const subRows: SubInspectionRow[] = reqIds.length === 0 + ? [] + : await selectSubInspections(db, tenantId, inArray(inspections.requestId, reqIds)); + + return reqs.map(r => shapeRequest(r, subRows.filter(s => s.requestId === r.id))); +} + +/** + * Fetch a single parent request with its children (tenant-scoped). + * Returns null when not found. Resolves child template names so callers + * (e.g. the inspection-edit request switcher) can render readable chips + * without an extra round-trip. + */ +export async function getRequest(db: DrizzleD1Database, tenantId: string, id: string) { + const req = await db.select().from(inspectionRequests) + .where(and(eq(inspectionRequests.id, id), eq(inspectionRequests.tenantId, tenantId))) + .get(); + if (!req) return null; + + const subs = await selectSubInspections(db, tenantId, eq(inspections.requestId, id)); + + const tplIds = Array.from(new Set(subs.map(s => s.templateId).filter((x): x is string => !!x))); + const tplNameById = new Map(); + if (tplIds.length > 0) { + const tplRows = await db.select({ id: templates.id, name: templates.name }) + .from(templates) + .where(and(eq(templates.tenantId, tenantId), inArray(templates.id, tplIds))) + .all(); + for (const t of tplRows) tplNameById.set(t.id, t.name); + } + + return shapeRequest(req, subs, tplNameById); +} + +/** + * The one shape a request read returns. Both entry points go through it, and + * nothing outside this module may build the shape by hand — which is why it is + * module-private rather than exported. + */ +function shapeRequest(req: RequestRow, subs: SubInspectionRow[], tplNameById?: Map) { + return { + id: req.id, + tenantId: req.tenantId, + clientName: req.clientName, + clientEmail: req.clientEmail, + clientPhone: req.clientPhone, + propertyAddress: req.propertyAddress, + propertyCity: req.propertyCity, + propertyState: req.propertyState, + propertyZip: req.propertyZip, + scheduledAt: safeISODate(req.scheduledAt), + status: req.status, + notes: req.notes, + totalAmount: req.totalAmount, + paymentStatus: req.paymentStatus, + createdAt: safeISODate(req.createdAt), + updatedAt: safeISODate(req.updatedAt), + inspections: subs.map(s => ({ + id: s.id, + templateId: s.templateId, + templateName: (s.templateId && tplNameById?.get(s.templateId)) || null, + propertyAddress: s.propertyAddress, + clientName: s.clientName, + status: s.status, + date: s.date, + price: s.price, + inspectorId: s.inspectorId, + })), + }; +} From ec8400cdaaf14679b7cb5b5d0fb2877b5fd91af9 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 15:14:46 +0800 Subject: [PATCH 40/77] refactor(inspections): the primitive keeps the file; its four callers move out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inspection-core.service.ts` sat at 1131 against a 1132 cap, blocking booking-deposit (#20)'s tier-2 fix. Pure move: no query, ordering, message, log key or public signature changed, and 664 tests across tests/unit/{inspections,usage}, the 85 tests in tests/unit/mcp (OpenAPI snapshot byte-identical, not regenerated) and tests/workers/reinspections pass untouched. WHAT REMAINS IS THE PRIMITIVE — create one inspection, read one, and the guards that creating one needs. Everything moved out is a CALLER of it, and each one is composed as a field on the core service rather than promoted onto the `InspectionService` facade. That is a deliberate constraint, not taste: the facade is itself at 740/741, so four new fields and four constructor lines would have broken its cap. Composing here leaves every existing `this.core.x()` delegation on the facade byte-identical. `inspection-query.service.ts` — reading MANY. The seam is cardinality: `listInspections` and `getStats` answer questions about a SET and pay for it with cursor pagination, a LEFT JOIN chain and a batched roster lookup. Reading ONE loads the template and the results payload, which no list can afford per row, so `getInspection` and `computePreflight` stay behind. `inspection-reinspection.service.ts` — #119 rounds. The seam is the baseline: both methods are meaningless without a PUBLISHED prior inspection, both read its latest `report_versions` snapshot, and they must agree about what `.original` means when the baseline is itself a re-inspection. That is why they share a file and why `parseSnapshotData` — which had been sitting at module scope in a file where only these two called it — goes with them. `inspection-create-variants.service.ts` — the other ways an inspection comes into existence. Neither `createFromWizard` nor `cloneInspection` is an alternative to `createInspection`; each is a TRANSLATION into it, which is why both call back into the core service instead of duplicating the insert, and why the constructor takes it. `applyServicePriceOverrides` joins them as the same shape of thing: a post-create hook the handler runs after an id exists. `inspection-recipients.service.ts` — who is attached, read two ways (the Publish modal's deliverable list, the inspector portal's People card). They share the source of truth and must not disagree about it: since Task 13 dropped the legacy contact columns, `inspection_people` is the only persistence of who, and a second reader reaching for `inspections.client_name` would resurrect GDPR-erased names. BASELINE ENTRY LEFT ALONE AT 1132. The file reached 464, not under 400, so its entry is neither removed (it would fail the 400 rule) nor tightened to 464 (that recreates the wall one line out). Getting the last 64 lines would have meant carving the middle out of `createInspection` — a boundary chosen to satisfy a counter, which is worse than the number. The practical result stands: 668 lines of headroom where there was one. `type-check:app` run explicitly, not just `:api`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- .../inspection/inspection-core.service.ts | 746 ++---------------- .../inspection-create-variants.service.ts | 239 ++++++ .../inspection/inspection-query.service.ts | 164 ++++ .../inspection-recipients.service.ts | 170 ++++ .../inspection-reinspection.service.ts | 288 +++++++ 5 files changed, 911 insertions(+), 696 deletions(-) create mode 100644 server/services/inspection/inspection-create-variants.service.ts create mode 100644 server/services/inspection/inspection-query.service.ts create mode 100644 server/services/inspection/inspection-recipients.service.ts create mode 100644 server/services/inspection/inspection-reinspection.service.ts diff --git a/server/services/inspection/inspection-core.service.ts b/server/services/inspection/inspection-core.service.ts index 53b39c9ff..8bce507ee 100644 --- a/server/services/inspection/inspection-core.service.ts +++ b/server/services/inspection/inspection-core.service.ts @@ -1,63 +1,40 @@ -import { eq, and, or, lt, gte, lte, sql, inArray, desc } from 'drizzle-orm'; -import { inspections, inspectionResults, templates, users, inspectionServices, tenantConfigs, agreementRequests, reportVersions, contactRoleProfiles, inspectionPeople } from '../../lib/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { inspections, inspectionResults, templates, tenantConfigs, agreementRequests, contactRoleProfiles } from '../../lib/db/schema'; import { resolveAgentRepairAccess, type AgentRepairAccess } from '../../lib/people/agent-repair-access'; import { contacts } from '../../lib/db/schema/contact'; import { PeopleService } from '../people.service'; -import { PRIMARY_CLIENT_KEY } from '../../lib/people/default-role-profiles'; import { Errors } from '../../lib/errors'; -import { getRatingBucket, type RatingLevel } from '../../lib/report-utils'; -import { mapRatingSystemLevels } from '../../lib/map-rating-levels'; -import { escapeLikePattern } from '../../lib/db/like-escape'; -import { safeISODate, safeTimestamp } from '../../lib/date'; +import { safeISODate } from '../../lib/date'; import { logger } from '../../lib/logger'; import { createPrimaryReport } from '../../lib/inspection/reports'; import { writeInspectionServiceSnapshots, type ServiceSelection } from '../../lib/inspection/service-snapshot'; import { computePreflightFromData } from '../../lib/preflight'; import { syncInspectionAssignments } from '../../lib/db/assignment-links'; -import { getInspectionRoster } from '../../lib/inspection/roster'; -import { findingKey, DEFAULT_UNIT } from '../../lib/finding-key'; -import { parseReinspectionStatuses, isOpenStatus } from '../../lib/reinspection-status'; import { INSPECTION_STATUS, type InspectionStatus } from '../../lib/status/inspection-status'; -import { REPORT_STATUS } from '../../lib/status/report-status'; import { fireAutomation, type Inspection, type InspectionListParams, type CreateInspectionData } from './shared'; import { InspectionSubService } from './base'; import { ServiceService } from '../service.service'; import type { ScopedDB } from '../../lib/db/scoped'; import type { ImagesBinding } from '../../lib/media/strip-exif'; +import { InspectionQueryService } from './inspection-query.service'; +import { InspectionReinspectionService } from './inspection-reinspection.service'; +import { InspectionCreateVariantsService } from './inspection-create-variants.service'; +import { InspectionRecipientsService } from './inspection-recipients.service'; import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; -/** Internal — one Publish-modal recipient row (client or agent). Not exported: - * the public `getRecipientList` signature keeps its inline structural type. */ -interface InspectionRecipient { - contactId: string | null; - name: string; - role: 'client' | 'agent_buyer' | 'agent_listing'; - email: string | null; - phone: string | null; -} - -/** `contact_role_profiles.key` → `InspectionRecipient.role`, for the three - * roles `getRecipientList` covers. Other role keys (co_client, attorney, - * ...) are intentionally absent — Spec 2 widens the recipient set. */ -const RECIPIENT_ROLE_MAP: Record = { - client: 'client', - buyer_agent: 'agent_buyer', - listing_agent: 'agent_listing', -}; - -/** Parse a report_versions.snapshotJson payload (snapshotOnPublish serialises - * `{ inspection, data, units }`); both re-inspection paths read only `.data`, - * keyed by findingKey or legacy item id. */ -function parseSnapshotData(snapshotJson: string): { data?: Record> } { - return JSON.parse(snapshotJson) as { data?: Record> }; -} - /** - * Core inspection CRUD + lifecycle: list / stats / preflight / get / create / - * reinspection / candidates / service-price overrides / wizard create / clone, - * plus the recipient + people aggregation cards. Extracted verbatim from - * InspectionService. Self-contained (cloneInspection calls getInspection - * internally on this service). + * The inspection PRIMITIVE: create one, read one, and the two guards that + * creating one needs (contact ownership, agent repair access) plus the publish + * pre-flight that reads one. + * + * What is left here is what everything else calls INTO. The four sub-services + * it composes are the callers, not peers: `./inspection-query.service` reads + * MANY, `./inspection-reinspection.service` creates one FROM a published + * baseline, `./inspection-create-variants.service` translates the wizard + * payload or an existing row INTO `createInspection`, and + * `./inspection-recipients.service` reads who is attached. They are composed + * here rather than on the `InspectionService` facade so that every existing + * `this.core.x()` delegation on the facade keeps working unchanged. */ export class InspectionCoreService extends InspectionSubService { /** @@ -68,6 +45,10 @@ export class InspectionCoreService extends InspectionSubService { * / cloneInspection. */ private readonly planQuota: PlanQuotaGuard | undefined; + private readonly query: InspectionQueryService; + private readonly reinspection: InspectionReinspectionService; + private readonly variants: InspectionCreateVariantsService; + private readonly recipients: InspectionRecipientsService; constructor( db: D1Database, @@ -79,6 +60,11 @@ export class InspectionCoreService extends InspectionSubService { ) { super(db, r2, sdb, kv, images); this.planQuota = planQuota; + this.query = new InspectionQueryService(db, r2, sdb, kv, images); + this.reinspection = new InspectionReinspectionService(db, r2, sdb, kv, images, planQuota); + // `this` is the primitive both variants translate into — see that module. + this.variants = new InspectionCreateVariantsService(db, r2, sdb, kv, images, planQuota, this); + this.recipients = new InspectionRecipientsService(db, r2, sdb, kv, images); } /** @@ -114,144 +100,16 @@ export class InspectionCoreService extends InspectionSubService { for (const id of want) if (!ok.has(id)) throw Errors.BadRequest('Unknown contact for this workspace'); } - /** - * Lists inspections with pagination and filtering. - */ + /** Lists inspections with pagination and filtering. Body in `./inspection-query.service`. */ async listInspections(tenantId: string, params: InspectionListParams) { - const db = this.getDrizzle(); - const conditions = [eq(inspections.tenantId, tenantId)]; - - if (params.status) conditions.push(eq(inspections.status, params.status)); - if (params.inspectorId) conditions.push(eq(inspections.inspectorId, params.inspectorId)); - if (params.dateFrom) conditions.push(gte(inspections.date, params.dateFrom)); - if (params.dateTo) conditions.push(lte(inspections.date, params.dateTo)); - - if (params.search) { - const term = `%${escapeLikePattern(params.search)}%`; - conditions.push(or( - sql`lower(${inspections.propertyAddress}) like lower(${term})`, - sql`lower(${contacts.name}) like lower(${term})` // primary-client join below, not the frozen legacy inspections.client_name - )!); - } - - const tabParam = (params as { tab?: string }).tab; - if (tabParam && tabParam !== 'all') { - const todayStr = new Date().toISOString().slice(0, 10); - switch (tabParam) { - case 'today': - conditions.push(sql`date(${inspections.date}) = ${todayStr}`); - break; - case 'upcoming': - conditions.push(sql`${inspections.date} > ${todayStr}`); - conditions.push(sql`${inspections.status} not in ('completed','cancelled')`); - break; - case 'past': - conditions.push(or( - sql`${inspections.date} < ${todayStr}`, - inArray(inspections.status, ['completed', 'cancelled']) - )!); - break; - // Same two definitions the workspace filters use — one word, one - // meaning, whichever tier asks. - case 'needs_confirmation': - conditions.push(inArray(inspections.status, [INSPECTION_STATUS.SCHEDULED, INSPECTION_STATUS.REQUESTED])); - break; - case 'awaiting_report': - conditions.push(eq(inspections.status, INSPECTION_STATUS.COMPLETED)); - conditions.push(sql`${inspections.reportStatus} <> ${REPORT_STATUS.PUBLISHED}`); - break; - } - } - - if (params.cursor) { - try { - const c = JSON.parse(atob(params.cursor)); - conditions.push(or( - lt(inspections.createdAt, new Date(c.createdAt)), - and(eq(inspections.createdAt, new Date(c.createdAt)), lt(inspections.id, c.id)) - )!); - } catch { throw Errors.BadRequest('Invalid cursor'); } - } - - // Task 9c (people-role-profiles) — clientName/clientEmail are sourced - // from the inspection_people primary-client join, not the legacy - // inspections.client_name/_email columns (frozen cache, dropped Task - // 13). A single LEFT JOIN keeps this list N+1-free; contact_role_profiles - // is joined BEFORE inspection_people (filtered to the 'client' role) - // so the join stays scoped to the primary client, mirroring the join - // order already used for top-agents in api/metrics.ts. - const rows = await db.select({ - inspection: inspections, - primaryClientName: contacts.name, - primaryClientEmail: contacts.email, - }) - .from(inspections) - .leftJoin(contactRoleProfiles, and( - eq(contactRoleProfiles.tenantId, inspections.tenantId), - eq(contactRoleProfiles.key, PRIMARY_CLIENT_KEY), - eq(contactRoleProfiles.active, true), - )) - .leftJoin(inspectionPeople, and( - eq(inspectionPeople.roleProfileId, contactRoleProfiles.id), - eq(inspectionPeople.inspectionId, inspections.id), - eq(inspectionPeople.tenantId, inspections.tenantId), - )) - .leftJoin(contacts, and( - eq(contacts.id, inspectionPeople.contactId), - eq(contacts.tenantId, inspections.tenantId), - )) - .where(and(...conditions)) - .orderBy(sql`${inspections.createdAt} desc, ${inspections.id} desc`) - .limit(params.limit + 1); - - const hasMore = rows.length > params.limit; - const page = hasMore ? rows.slice(0, params.limit) : rows; - - let nextCursor: string | null = null; - if (hasMore) { - const last = page[page.length - 1].inspection; - nextCursor = btoa(JSON.stringify({ createdAt: safeTimestamp(last.createdAt), id: last.id })); - } - - const inspectionsFormatted: Inspection[] = page.map(({ inspection: row, primaryClientName, primaryClientEmail }) => ({ - ...row, - id: row.id as string, - propertyAddress: row.propertyAddress as string, - clientName: primaryClientName ?? null, - clientEmail: primaryClientEmail ?? null, - status: row.status, - date: row.date as string, - inspectorId: row.inspectorId as string | null, - templateId: row.templateId as string | null, - createdAt: safeISODate(row.createdAt), - })); - - return { inspections: inspectionsFormatted, nextCursor, hasMore }; + return this.query.listInspections(tenantId, params); } - /** - * Fetches counts for the dashboard. - */ + /** Fetches counts for the dashboard. Body in `./inspection-query.service`. */ async getStats(tenantId: string) { - const db = this.getDrizzle(); - const counts = await db.select({ status: inspections.status, count: sql`count(*)` }) - .from(inspections) - .where(eq(inspections.tenantId, tenantId)) - .groupBy(inspections.status); - - const stats = { total: 0, requested: 0, completed: 0, published: 0 }; - for (const row of counts) { - const n = Number(row.count); - stats.total += n; - if (row.status === INSPECTION_STATUS.REQUESTED) stats.requested = n; - else if (row.status === INSPECTION_STATUS.COMPLETED) stats.completed = n; - } - return stats; + return this.query.getStats(tenantId); } - /** - * Fetches a single inspection with its template. - */ /** * Design System 0520 subsystem E P1.2 — Publish pre-flight gates. * @@ -554,558 +412,54 @@ export class InspectionCoreService extends InspectionSubService { } as Inspection; } - /** - * #119 — Re-inspection. Creates a NEW draft inspection linked to a published - * baseline (the original OR a prior re-inspection). Seeds inspection_results.data - * for ONLY the selected items, each `{ original, followupStatus: null }`, where - * `original` carries the root finding forward from the baseline's latest published - * report_versions snapshot (or the propagated `.original` if the baseline is itself - * a re-inspection). - * - * GATE: the baseline must be published — i.e. have ≥1 report_versions row. - */ + /** #119 — creates a follow-up round over a published baseline. Body in `./inspection-reinspection.service`. */ async createReinspection( tenantId: string, baselineId: string, opts: { selectedItemIds: string[]; inspectorId?: string }, ): Promise { - const db = this.getDrizzle(); - - const baseline = await db.select().from(inspections) - .where(and(eq(inspections.id, baselineId), eq(inspections.tenantId, tenantId))).get(); - if (!baseline) throw new Error('Baseline inspection not found'); - - const latestVersion = await db.select().from(reportVersions) - .where(and(eq(reportVersions.tenantId, tenantId), eq(reportVersions.inspectionId, baselineId))) - .orderBy(desc(reportVersions.versionNumber)).limit(1).get(); - if (!latestVersion) throw new Error('Cannot re-inspect an unpublished baseline'); - - // When an explicit inspectorId is supplied, it MUST resolve to a user in - // this tenant. inspector_id has a DB FK to users.id; a foreign-tenant or - // bogus id would either violate the FK at runtime or assign the round to - // another tenant's user. Validate before use; omitted → baseline fallback. - if (opts.inspectorId) { - const owner = await db.select({ id: users.id }).from(users) - .where(and(eq(users.id, opts.inspectorId), eq(users.tenantId, tenantId))).get(); - if (!owner) throw new Error('Inspector not found in this workspace'); - } - - const rootId = baseline.rootInspectionId ?? baseline.id; - const existingRounds = await db.select().from(inspections) - .where(and(eq(inspections.tenantId, tenantId), eq(inspections.rootInspectionId, rootId))).all(); - const round = existingRounds.length + 1; - - // The latest published snapshot is the carry-forward source. snapshotOnPublish - // serialises { inspection, data, units }; we read .data[itemId]. - const baseSnapshot = parseSnapshotData(latestVersion.snapshotJson); - const baselineIsReinspection = baseline.sourceInspectionId != null; - - const seeded: Record = {}; - for (const itemId of opts.selectedItemIds) { - const item = baseSnapshot.data?.[itemId] ?? {}; - // When the baseline is itself a re-inspection AND its snapshot item already - // carries a propagated `.original` root finding, forward THAT (so round N - // always shows the root defect, never the intermediate follow-up state). - const original = baselineIsReinspection && item.original - ? item.original - : { rating: item.rating ?? null, notes: item.notes ?? null, photos: item.photos ?? [] }; - seeded[itemId] = { original, followupStatus: null }; - } - - const id = crypto.randomUUID(); - const createdAt = new Date(); - // Quota is consumed only after every precondition check above (baseline - // existence, published-baseline gate, inspector ownership) has passed - // and immediately before the insert that actually creates the - // re-inspection — a failed validation must never burn a free tenant's - // lifetime slot. - await this.planQuota?.consumeInspection(tenantId); - await db.insert(inspections).values({ - id, - tenantId, - // Reuse the baseline's property + client + template fields. - inspectorId: opts.inspectorId ?? baseline.inspectorId ?? null, - propertyAddress: baseline.propertyAddress, - addressPlaceId: baseline.addressPlaceId, - addressStreet: baseline.addressStreet, - addressCity: baseline.addressCity, - addressState: baseline.addressState, - addressZip: baseline.addressZip, - addressCounty: baseline.addressCounty, - addressLat: baseline.addressLat, - addressLng: baseline.addressLng, - templateId: baseline.templateId, - templateSnapshot: baseline.templateSnapshot, - templateSnapshotVersion: baseline.templateSnapshotVersion, - date: createdAt.toISOString(), - status: INSPECTION_STATUS.REQUESTED, - paymentStatus: 'unpaid', - price: 0, - paymentRequired: false, - agreementRequired: false, - createdAt, - // #119 link columns. - sourceInspectionId: baselineId, - rootInspectionId: rootId, - 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, - }); - - // Task 7c (people-role-profiles fix) — copy the baseline's - // inspection_people rows (client / buyer_agent / listing_agent / ...) - // onto the new re-inspection. Task 13 dropped the legacy - // clientContactId/clientName/clientEmail/clientPhone columns from the - // inspections row, so this copy is now the ONLY carry-forward of WHO. - // Without this, getInspection/listInspections (Task 9c-reads) resolve the client - // via inspection_people ONLY and would show a null client on every - // re-inspection. Non-fatal: a people-write failure must never roll - // back the already-committed re-inspection row. - try { - const people = new PeopleService({ DB: this.db }); - const baselinePeople = await people.listPeople(tenantId, baselineId); - for (const p of baselinePeople) { - await people.addPerson(tenantId, id, p.contactId, p.roleProfileId); - } - } catch (err) { - logger.error('inspection-people copy from reinspection create failed', { inspectionId: id }, err instanceof Error ? err : undefined); - } - - const created = await db.select().from(inspections).where(eq(inspections.id, id)).get(); - return created as unknown as Inspection; + return this.reinspection.createReinspection(tenantId, baselineId, opts); } - /** - * #119 (Task 6) — Candidate items for the "Create re-inspection" modal. - * Returns the baseline's still-open flagged items so the UI can pre-check - * the ones worth carrying forward. Computed off the SAME published snapshot - * `createReinspection` reads, so the returned `itemId`s are exactly the keys - * accepted as `selectedItemIds`. - * - * `open` default-check rule (mirrors the task spec): - * - ORIGINAL baseline (no sourceInspectionId): item is open when its rating - * bucket is `defect` or `monitor`. - * - RE-INSPECTION baseline: item is open when its `followupStatus` is a - * non-closed status (via isOpenStatus + the tenant's status set). - * - * Returns [] when the baseline is unpublished (no snapshot) — the caller - * gates the action on publication anyway, and the modal renders an empty - * state. Labels come from the baseline's templateSnapshot; an unmatched key - * degrades to the raw item id. - */ + /** #119 — what is still open on a baseline and can carry into a round. Body in `./inspection-reinspection.service`. */ async getReinspectCandidates( tenantId: string, baselineId: string, ): Promise> { - const db = this.getDrizzle(); - - const baseline = await db.select().from(inspections) - .where(and(eq(inspections.id, baselineId), eq(inspections.tenantId, tenantId))).get(); - if (!baseline) return []; - - const latestVersion = await db.select().from(reportVersions) - .where(and(eq(reportVersions.tenantId, tenantId), eq(reportVersions.inspectionId, baselineId))) - .orderBy(desc(reportVersions.versionNumber)).limit(1).get(); - if (!latestVersion) return []; // unpublished baseline → no candidates - - const baselineIsReinspection = baseline.sourceInspectionId != null; - - // Snapshot data is keyed by findingKey (unit:section:item) or, for legacy - // inspections, the plain item id — the same keys createReinspection reads. - const snapData = parseSnapshotData(latestVersion.snapshotJson).data ?? {}; - - // Resolve item labels from the baseline's templateSnapshot (authoritative - // shape once an inspection exists). Both {sections:[...]} and flat-array - // formats are supported, matching getReportData's schema resolution. - const labelByItemId = new Map(); - const rawSnap = baseline.templateSnapshot as unknown; - const tplSnap = rawSnap - ? (typeof rawSnap === 'string' ? JSON.parse(rawSnap as string) : rawSnap) - : null; - const sections: Array<{ id?: string; items?: Array> }> = Array.isArray(tplSnap) - ? [{ id: 'general', items: tplSnap as Array> }] - : Array.isArray((tplSnap as { sections?: unknown })?.sections) - ? (tplSnap as { sections: Array<{ id?: string; items?: Array> }> }).sections - : []; - for (const sec of sections) { - for (const it of sec.items ?? []) { - const itemId = String(it.id ?? ''); - if (!itemId) continue; - const label = String(it.label ?? it.title ?? it.name ?? itemId); - labelByItemId.set(itemId, label); - // Also map the composite findingKey so snapshot keys resolve. - labelByItemId.set(findingKey(DEFAULT_UNIT, String(sec.id ?? ''), itemId), label); - } - } - - // Rating levels for bucket resolution (original-baseline rule). Read from - // the templateSnapshot.ratingSystem when present; absence degrades to the - // legacy string-bucket map inside getRatingBucket. - const snapLevels = !Array.isArray(tplSnap) - ? (tplSnap as { ratingSystem?: { levels?: unknown[] } } | null)?.ratingSystem?.levels - : undefined; - const levels: RatingLevel[] = Array.isArray(snapLevels) - ? mapRatingSystemLevels(snapLevels as Array>) - : []; - - // Resolve the tenant's configured follow-up status set (re-inspection rule). - const configRow = await db.select({ reinspectionStatuses: tenantConfigs.reinspectionStatuses }) - .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); - const resolvedStatuses = parseReinspectionStatuses(configRow?.reinspectionStatuses ?? null); - - const out: Array<{ itemId: string; label: string; originalNotes: string | null; open: boolean }> = []; - for (const [itemId, entry] of Object.entries(snapData)) { - const rating = (entry.rating ?? null) as string | null; - const notes = (entry.notes ?? null) as string | null; - // A re-inspection snapshot may already carry the propagated root finding. - const original = (entry.original ?? null) as { notes?: string | null } | null; - const originalNotes = baselineIsReinspection && original ? (original.notes ?? null) : notes; - - let open: boolean; - if (baselineIsReinspection) { - open = isOpenStatus((entry.followupStatus ?? null) as string | null, resolvedStatuses); - } else { - const bucket = getRatingBucket(rating, levels); - open = bucket === 'defect' || bucket === 'monitor'; - } - - out.push({ - itemId, - label: labelByItemId.get(itemId) ?? itemId, - originalNotes, - open, - }); - } - // Open items first, then by label — the pre-checked carry-forward set surfaces on top. - out.sort((a, b) => (a.open === b.open ? a.label.localeCompare(b.label) : a.open ? -1 : 1)); - return out; + return this.reinspection.getReinspectCandidates(tenantId, baselineId); } - /** - * IA-1: Post-create hook — write priceOverride onto inspection_services rows - * that were already inserted by createInspection. Called by the handler AFTER - * createInspection returns so it can use the resolved inspection id. - * Only rows whose serviceId appears in selections AND carry a priceOverrideCents - * value are updated; rows without an override are left with priceOverride=null. - */ + /** IA-1 post-create hook — priceOverride onto existing rows. Body in `./inspection-create-variants.service`. */ async applyServicePriceOverrides( inspectionId: string, tenantId: string, selections: Array<{ serviceId: string; priceOverrideCents?: number }>, ): Promise { - const db = this.getDrizzle(); - for (const sel of selections) { - if (sel.priceOverrideCents !== undefined) { - await db.update(inspectionServices) - .set({ priceOverride: sel.priceOverrideCents }) - .where( - and( - eq(inspectionServices.inspectionId, inspectionId), - eq(inspectionServices.tenantId, tenantId), - eq(inspectionServices.serviceId, sel.serviceId), - ), - ); - } - } + return this.variants.applyServicePriceOverrides(inspectionId, tenantId, selections); } - /** - * Design System 0520 subsystem B phase 5 — NewInspectionWizard creation - * path. Thin wrapper around createInspection that maps the wizard's - * 4-step payload onto the existing column set + the new team_mode / - * lead_inspector_id / helper_inspector_ids columns added in subsystem - * B phase 1. - * - * Returns the freshly-inserted inspection id so the wizard factory can - * redirect to /inspections/:id/edit. - * - * Services array (wizard step 2) is stored informational-only on this - * MVP — wiring to the inspectionServices catalog needs slug→id - * lookup which is a separate follow-up. - */ + /** NewInspectionWizard creation path. Body in `./inspection-create-variants.service`. */ async createFromWizard( tenantId: string, creatorUserId: string, input: import('../../lib/validations/wizard.schema').CreateInspectionFromWizardInput, ): Promise<{ id: string }> { - // Build the base CreateInspectionData shape consumed by createInspection. - // The wizard's schedule.startTime is appended to the ISO date so the - // existing `date` column carries both — the editor's calendar pane - // already round-trips this format. - const dateTime = `${input.schedule.date}T${input.schedule.startTime}:00`; - - const created = await this.createInspection(tenantId, { - inspectorId: creatorUserId, - propertyAddress: input.property.address, - clientName: 'Private Client', // wizard MVP — client picker is step-extension follow-up - clientEmail: null, - clientPhone: null, - templateId: null, - date: dateTime, - yearBuilt: input.property.yearBuilt ?? null, - sqft: input.property.sqft ?? null, - foundationType: null, - bedrooms: null, - bathrooms: null, - } as unknown as CreateInspectionData & { inspectorId?: string }); - - { - const db = this.getDrizzle(); - const patch: Record = {}; - if (input.property.propertyType) patch.propertyType = input.property.propertyType; - if (input.property.propertyType === 'commercial' && input.property.commercialSubtype) { - patch.commercialSubtype = input.property.commercialSubtype; - } - let teamFieldsPatched = false; - let effectiveLead: string | null = null; - let effectiveHelpers: string[] = []; - if (input.teamMode || input.leadInspectorId || (input.helperInspectorIds?.length ?? 0) > 0) { - // teamMode is live (it drives the team UI). Lead + helpers are - // NOT written back to `inspections` — they live in - // inspection_inspectors, written from the intent computed below. - patch.teamMode = input.teamMode; - teamFieldsPatched = true; - effectiveLead = input.teamMode ? (input.leadInspectorId ?? creatorUserId) : null; - effectiveHelpers = input.teamMode ? (input.helperInspectorIds ?? []) : []; - } - if (Object.keys(patch).length > 0) { - await db.update(inspections) - .set(patch) - .where(and(eq(inspections.id, created.id), eq(inspections.tenantId, tenantId))); - } - // Write who is assigned. Always pass creatorUserId as the inspectorId - // fallback so that when teamMode=false but a lead was still present in - // the request (effectiveLead=null, effectiveHelpers=[]), - // syncInspectionAssignments writes a lead row for the creator rather - // than leaving the inspection with nobody on it. - if (teamFieldsPatched) { - // Non-fatal, but no longer cosmetic: this table is the only - // record of who is assigned, so a failure here leaves the - // inspection UNASSIGNED, not merely un-mirrored. Still non-fatal - // because the inspection row is already committed and throwing - // would lose it; assignment can be redone, a lost inspection - // cannot. The error log is the signal. - try { - await syncInspectionAssignments(db, tenantId, created.id, { - inspectorId: creatorUserId, - leadInspectorId: effectiveLead, - helperInspectorIds: effectiveHelpers, - }); - } catch (e) { - logger.error('inspection.wizard-team-sync.failed', { inspectionId: created.id }, e instanceof Error ? e : undefined); - } - } - } - - return { id: created.id }; + return this.variants.createFromWizard(tenantId, creatorUserId, input); } - /** - * Clones an existing inspection. - */ + /** Clones an existing inspection. Body in `./inspection-create-variants.service`. */ async cloneInspection(id: string, tenantId: string): Promise { - // getInspection throws NotFound for a bad id — that precondition check - // must run BEFORE quota is consumed, so cloning a nonexistent - // inspection never burns a free tenant's lifetime slot. - const { inspection: source } = await this.getInspection(id, tenantId); - await this.planQuota?.consumeInspection(tenantId); - - const clone = { - ...source, - id: crypto.randomUUID(), - tenantId, - date: new Date().toISOString(), - status: 'draft' as const, - paymentStatus: 'unpaid' as const, - createdAt: new Date(), - }; - delete (clone as { signedByClient?: boolean }).signedByClient; // Remove ephemeral field - - // Task 13 — clientName/clientEmail/clientPhone on `source` are - // resolved via PeopleService inside getInspection (not raw DB - // columns; clientContactId/referredByAgentId/sellingAgentId are gone - // entirely now that the columns are dropped). Strip them from the - // insert payload — they'd otherwise be dead keys on an object the - // schema no longer recognizes. The inspection_people copy below is - // the only carry-forward of WHO. - const { clientName: _clientName, clientEmail: _clientEmail, clientPhone: _clientPhone, ...cloneDbValues } = - clone as typeof clone & { clientName?: unknown; clientEmail?: unknown; clientPhone?: unknown }; - void _clientName; void _clientEmail; void _clientPhone; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this.getDrizzle().insert(inspections).values(cloneDbValues as any); - - // Task 7c (people-role-profiles fix) — copy the source inspection's - // inspection_people rows (client + any agents) onto the clone. - // Without this, getInspection/listInspections (Task 9c-reads) - // resolve the client via inspection_people ONLY and would show a - // null client on every clone. Non-fatal: a people-write failure must - // never roll back the already-committed clone row. - try { - const people = new PeopleService({ DB: this.db }); - const sourcePeople = await people.listPeople(tenantId, id); - for (const p of sourcePeople) { - await people.addPerson(tenantId, clone.id, p.contactId, p.roleProfileId); - } - } catch (err) { - logger.error('inspection-people copy from clone create failed', { inspectionId: clone.id }, err instanceof Error ? err : undefined); - } - // Give the clone the SOURCE's people, read from the source's roster — - // not from columns copied onto the clone row, which are no longer - // written and would leave any recently-assigned clone empty. Non-fatal - // for the same reason as the create path above. - try { - const sourceRoster = await getInspectionRoster(this.getDrizzle(), tenantId, id); - await syncInspectionAssignments(this.getDrizzle(), tenantId, clone.id, { - inspectorId: (clone as { inspectorId?: string | null }).inspectorId ?? null, - leadInspectorId: sourceRoster.lead?.id ?? null, - helperInspectorIds: sourceRoster.helpers.map(h => h.id), - }); - } catch (e) { - logger.error('inspection.clone-sync.failed', { inspectionId: clone.id }, e instanceof Error ? e : undefined); - } - - return { - ...clone, - createdAt: safeISODate(clone.createdAt) - }; + return this.variants.cloneInspection(id, tenantId); } - /** - * Round-2 F1 — list every party associated with an inspection so the - * Publish modal can render per-recipient Email + Text checkboxes. - * - * Sourced from `PeopleService.listPeople` (the `inspection_people` join), - * restricted to the three roles this Publish-modal contract covers - * (`client` / `buyer_agent` / `listing_agent` — see `RECIPIENT_ROLE_MAP`); - * other role kinds (co_client, attorney, ...) are ignored here (Spec 2 - * widens the recipient set). - * - * Returned shape (`InspectionRecipient[]`): - * - role: 'client' | 'agent_buyer' | 'agent_listing' - * - contactId: the person's contact row id (now populated for every - * role, including the client — the legacy inline-client column had - * no contact row, so this used to be null for `role: 'client'`) - * - name, email, phone - * - * Recipients without any contact info (no email AND no phone) are dropped - * because there is no way to deliver to them. Tenant-scoped via the - * compound `where(eq(id), eq(tenantId))` guard on the inspection lookup - * AND `PeopleService.listPeople`'s own tenant filter. - */ - async getRecipientList(inspectionId: string, tenantId: string): Promise { - const db = this.getDrizzle(); - - const inspection = await db.select().from(inspections) - .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) - .get(); - if (!inspection) throw Errors.NotFound('Inspection not found'); - - const people = await new PeopleService({ DB: this.db }).listPeople(tenantId, inspectionId); - - const recipients: InspectionRecipient[] = []; - for (const p of people) { - const role = RECIPIENT_ROLE_MAP[p.roleKey]; - if (!role) continue; // ignore co_client/attorney/etc — Spec 2 widens the recipient set - if (!p.email && !p.phone) continue; // no delivery channel - recipients.push({ - contactId: p.contactId, - name: p.name, - role, - email: p.email ?? null, - phone: p.phone ?? null, - }); - } - - return recipients; + /** Round-2 F1 — parties an inspection can be delivered to. Body in `./inspection-recipients.service`. */ + async getRecipientList(inspectionId: string, tenantId: string) { + return this.recipients.getRecipientList(inspectionId, tenantId); } - /** - * Round-2 F3 — People card payload (Spectora §E.2 / §4.1). - * - * Groups every party connected to an inspection by role so the inspection - * Settings page can render a contact card with role chips: - * - * - Inspector → users row referenced by inspectorId - * - Client, Buyer's Agent, Listing Agent → `inspection_people` rows - * (via `PeopleService.listPeople`), matched on `roleKey`. Other role - * kinds (co_client, attorney, ...) are ignored here (Spec 2 widens - * the people card). - * - * Each agent's `.id` is the CONTACT id (`p.contactId`), matching the old - * contract — NOT the `inspection_people` join-row id (`p.id`). - * - * Schema currently allows ONE buyer agent + ONE listing agent per - * inspection. The result returns arrays for forward-compat (so the UI - * can render "Buyer's Agent · 2" if multi-agent ever ships) without a - * follow-up service refactor. - */ - async getPeopleCard(inspectionId: string, tenantId: string): Promise<{ - inspector: { id: string; name: string | null; email: string; phone: string | null } | null; - client: { name: string; email: string | null; phone: string | null } | null; - buyerAgents: Array<{ id: string; name: string; email: string | null; phone: string | null; agency: string | null }>; - listingAgents: Array<{ id: string; name: string; email: string | null; phone: string | null; agency: string | null }>; - }> { - const db = this.getDrizzle(); - - const inspection = await db.select().from(inspections) - .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) - .get(); - if (!inspection) throw Errors.NotFound('Inspection not found'); - - // Inspector — users table (tenant-scoped). - let inspector: { id: string; name: string | null; email: string; phone: string | null } | null = null; - if (inspection.inspectorId) { - const u = await db.select().from(users) - .where(and(eq(users.id, inspection.inspectorId as string), eq(users.tenantId, tenantId))) - .get(); - if (u) { - inspector = { - id: u.id as string, - name: (u.name as string | null) ?? null, - email: u.email as string, - phone: (u.phone as string | null) ?? null, - }; - } - } - - // Client + agents — from inspection_people (via PeopleService). - const people = await new PeopleService({ DB: this.db }).listPeople(tenantId, inspectionId); - - const clientP = people.find(p => p.roleKey === 'client') ?? null; - const client = clientP - ? { - name: clientP.name, - email: clientP.email ?? null, - phone: clientP.phone ?? null, - } - : null; - - const toAgent = (p: (typeof people)[number]) => ({ - id: p.contactId, // CONTACT id — matches the old contract, not the join-row id - name: p.name, - email: p.email ?? null, - phone: p.phone ?? null, - agency: p.agency ?? null, - }); - const buyerAgents = people.filter(p => p.roleKey === 'buyer_agent').map(toAgent); - const listingAgents = people.filter(p => p.roleKey === 'listing_agent').map(toAgent); - - return { - inspector, - client, - buyerAgents, - listingAgents, - }; + /** IA-18 — the inspector portal People card. Body in `./inspection-recipients.service`. */ + async getPeopleCard(inspectionId: string, tenantId: string) { + return this.recipients.getPeopleCard(inspectionId, tenantId); } -} + +} \ No newline at end of file diff --git a/server/services/inspection/inspection-create-variants.service.ts b/server/services/inspection/inspection-create-variants.service.ts new file mode 100644 index 000000000..fac2c1b91 --- /dev/null +++ b/server/services/inspection/inspection-create-variants.service.ts @@ -0,0 +1,239 @@ +import { eq, and } from 'drizzle-orm'; +import { inspections, inspectionServices } from '../../lib/db/schema'; +import { PeopleService } from '../people.service'; +import { safeISODate } from '../../lib/date'; +import { logger } from '../../lib/logger'; +import { syncInspectionAssignments } from '../../lib/db/assignment-links'; +import { getInspectionRoster } from '../../lib/inspection/roster'; +import type { Inspection, CreateInspectionData } from './shared'; +import type { ScopedDB } from '../../lib/db/scoped'; +import type { ImagesBinding } from '../../lib/media/strip-exif'; +import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; +import type { InspectionCoreService } from './inspection-core.service'; +import { InspectionSubService } from './base'; + +/** + * THE OTHER WAYS AN INSPECTION COMES INTO EXISTENCE, and the post-create hook + * they share. + * + * `createInspection` on the core service is the primitive. Neither of these is + * a caller-visible alternative to it — each is a TRANSLATION into it: + * `createFromWizard` maps the wizard's four-step payload onto the column set + * and then patches the team fields the primitive does not know about; + * `cloneInspection` reads an existing row and replays it. Both therefore call + * back into the core service rather than duplicating the insert, and both + * consume quota at the same point the primitive does — after the precondition + * checks, before the row exists. + * + * `applyServicePriceOverrides` lives here because it is the same shape of + * thing: a post-create hook the HANDLER runs once `createInspection` has + * returned an id, never part of the insert itself. + */ +export class InspectionCreateVariantsService extends InspectionSubService { + private readonly planQuota: PlanQuotaGuard | undefined; + private readonly core: InspectionCoreService; + + constructor( + db: D1Database, + r2: R2Bucket | undefined, + sdb: ScopedDB | undefined, + kv: KVNamespace | undefined, + images: ImagesBinding | undefined, + planQuota: PlanQuotaGuard | undefined, + core: InspectionCoreService, + ) { + super(db, r2, sdb, kv, images); + this.planQuota = planQuota; + this.core = core; + } + + /** + * IA-1: Post-create hook — write priceOverride onto inspection_services rows + * that were already inserted by createInspection. Called by the handler AFTER + * createInspection returns so it can use the resolved inspection id. + * Only rows whose serviceId appears in selections AND carry a priceOverrideCents + * value are updated; rows without an override are left with priceOverride=null. + */ + async applyServicePriceOverrides( + inspectionId: string, + tenantId: string, + selections: Array<{ serviceId: string; priceOverrideCents?: number }>, + ): Promise { + const db = this.getDrizzle(); + for (const sel of selections) { + if (sel.priceOverrideCents !== undefined) { + await db.update(inspectionServices) + .set({ priceOverride: sel.priceOverrideCents }) + .where( + and( + eq(inspectionServices.inspectionId, inspectionId), + eq(inspectionServices.tenantId, tenantId), + eq(inspectionServices.serviceId, sel.serviceId), + ), + ); + } + } + } + + /** + * Design System 0520 subsystem B phase 5 — NewInspectionWizard creation + * path. Thin wrapper around createInspection that maps the wizard's + * 4-step payload onto the existing column set + the new team_mode / + * lead_inspector_id / helper_inspector_ids columns added in subsystem + * B phase 1. + * + * Returns the freshly-inserted inspection id so the wizard factory can + * redirect to /inspections/:id/edit. + * + * Services array (wizard step 2) is stored informational-only on this + * MVP — wiring to the inspectionServices catalog needs slug→id + * lookup which is a separate follow-up. + */ + async createFromWizard( + tenantId: string, + creatorUserId: string, + input: import('../../lib/validations/wizard.schema').CreateInspectionFromWizardInput, + ): Promise<{ id: string }> { + // Build the base CreateInspectionData shape consumed by createInspection. + // The wizard's schedule.startTime is appended to the ISO date so the + // existing `date` column carries both — the editor's calendar pane + // already round-trips this format. + const dateTime = `${input.schedule.date}T${input.schedule.startTime}:00`; + + const created = await this.core.createInspection(tenantId, { + inspectorId: creatorUserId, + propertyAddress: input.property.address, + clientName: 'Private Client', // wizard MVP — client picker is step-extension follow-up + clientEmail: null, + clientPhone: null, + templateId: null, + date: dateTime, + yearBuilt: input.property.yearBuilt ?? null, + sqft: input.property.sqft ?? null, + foundationType: null, + bedrooms: null, + bathrooms: null, + } as unknown as CreateInspectionData & { inspectorId?: string }); + + { + const db = this.getDrizzle(); + const patch: Record = {}; + if (input.property.propertyType) patch.propertyType = input.property.propertyType; + if (input.property.propertyType === 'commercial' && input.property.commercialSubtype) { + patch.commercialSubtype = input.property.commercialSubtype; + } + let teamFieldsPatched = false; + let effectiveLead: string | null = null; + let effectiveHelpers: string[] = []; + if (input.teamMode || input.leadInspectorId || (input.helperInspectorIds?.length ?? 0) > 0) { + // teamMode is live (it drives the team UI). Lead + helpers are + // NOT written back to `inspections` — they live in + // inspection_inspectors, written from the intent computed below. + patch.teamMode = input.teamMode; + teamFieldsPatched = true; + effectiveLead = input.teamMode ? (input.leadInspectorId ?? creatorUserId) : null; + effectiveHelpers = input.teamMode ? (input.helperInspectorIds ?? []) : []; + } + if (Object.keys(patch).length > 0) { + await db.update(inspections) + .set(patch) + .where(and(eq(inspections.id, created.id), eq(inspections.tenantId, tenantId))); + } + // Write who is assigned. Always pass creatorUserId as the inspectorId + // fallback so that when teamMode=false but a lead was still present in + // the request (effectiveLead=null, effectiveHelpers=[]), + // syncInspectionAssignments writes a lead row for the creator rather + // than leaving the inspection with nobody on it. + if (teamFieldsPatched) { + // Non-fatal, but no longer cosmetic: this table is the only + // record of who is assigned, so a failure here leaves the + // inspection UNASSIGNED, not merely un-mirrored. Still non-fatal + // because the inspection row is already committed and throwing + // would lose it; assignment can be redone, a lost inspection + // cannot. The error log is the signal. + try { + await syncInspectionAssignments(db, tenantId, created.id, { + inspectorId: creatorUserId, + leadInspectorId: effectiveLead, + helperInspectorIds: effectiveHelpers, + }); + } catch (e) { + logger.error('inspection.wizard-team-sync.failed', { inspectionId: created.id }, e instanceof Error ? e : undefined); + } + } + } + + return { id: created.id }; + } + + /** + * Clones an existing inspection. + */ + async cloneInspection(id: string, tenantId: string): Promise { + // getInspection throws NotFound for a bad id — that precondition check + // must run BEFORE quota is consumed, so cloning a nonexistent + // inspection never burns a free tenant's lifetime slot. + const { inspection: source } = await this.core.getInspection(id, tenantId); + await this.planQuota?.consumeInspection(tenantId); + + const clone = { + ...source, + id: crypto.randomUUID(), + tenantId, + date: new Date().toISOString(), + status: 'draft' as const, + paymentStatus: 'unpaid' as const, + createdAt: new Date(), + }; + delete (clone as { signedByClient?: boolean }).signedByClient; // Remove ephemeral field + + // Task 13 — clientName/clientEmail/clientPhone on `source` are + // resolved via PeopleService inside getInspection (not raw DB + // columns; clientContactId/referredByAgentId/sellingAgentId are gone + // entirely now that the columns are dropped). Strip them from the + // insert payload — they'd otherwise be dead keys on an object the + // schema no longer recognizes. The inspection_people copy below is + // the only carry-forward of WHO. + const { clientName: _clientName, clientEmail: _clientEmail, clientPhone: _clientPhone, ...cloneDbValues } = + clone as typeof clone & { clientName?: unknown; clientEmail?: unknown; clientPhone?: unknown }; + void _clientName; void _clientEmail; void _clientPhone; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await this.getDrizzle().insert(inspections).values(cloneDbValues as any); + + // Task 7c (people-role-profiles fix) — copy the source inspection's + // inspection_people rows (client + any agents) onto the clone. + // Without this, getInspection/listInspections (Task 9c-reads) + // resolve the client via inspection_people ONLY and would show a + // null client on every clone. Non-fatal: a people-write failure must + // never roll back the already-committed clone row. + try { + const people = new PeopleService({ DB: this.db }); + const sourcePeople = await people.listPeople(tenantId, id); + for (const p of sourcePeople) { + await people.addPerson(tenantId, clone.id, p.contactId, p.roleProfileId); + } + } catch (err) { + logger.error('inspection-people copy from clone create failed', { inspectionId: clone.id }, err instanceof Error ? err : undefined); + } + // Give the clone the SOURCE's people, read from the source's roster — + // not from columns copied onto the clone row, which are no longer + // written and would leave any recently-assigned clone empty. Non-fatal + // for the same reason as the create path above. + try { + const sourceRoster = await getInspectionRoster(this.getDrizzle(), tenantId, id); + await syncInspectionAssignments(this.getDrizzle(), tenantId, clone.id, { + inspectorId: (clone as { inspectorId?: string | null }).inspectorId ?? null, + leadInspectorId: sourceRoster.lead?.id ?? null, + helperInspectorIds: sourceRoster.helpers.map(h => h.id), + }); + } catch (e) { + logger.error('inspection.clone-sync.failed', { inspectionId: clone.id }, e instanceof Error ? e : undefined); + } + + return { + ...clone, + createdAt: safeISODate(clone.createdAt) + }; + } +} diff --git a/server/services/inspection/inspection-query.service.ts b/server/services/inspection/inspection-query.service.ts new file mode 100644 index 000000000..91546f870 --- /dev/null +++ b/server/services/inspection/inspection-query.service.ts @@ -0,0 +1,164 @@ +import { eq, and, or, lt, gte, lte, sql, inArray } from 'drizzle-orm'; +import { inspections, contactRoleProfiles, inspectionPeople } from '../../lib/db/schema'; +import { contacts } from '../../lib/db/schema/contact'; +import { PRIMARY_CLIENT_KEY } from '../../lib/people/default-role-profiles'; +import { Errors } from '../../lib/errors'; +import { escapeLikePattern } from '../../lib/db/like-escape'; +import { safeISODate, safeTimestamp } from '../../lib/date'; +import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; +import { REPORT_STATUS } from '../../lib/status/report-status'; +import type { Inspection, InspectionListParams } from './shared'; +import { InspectionSubService } from './base'; + +/** + * READING MANY inspections: the dashboard list and its status counts. + * + * The seam is cardinality. Everything here answers a question about a SET — + * which inspections match these filters, how many are in each status — and + * pays for it with cursor pagination, a LEFT JOIN chain for the client name + * and a batched roster lookup. Reading ONE inspection (`getInspection`, + * `computePreflight`) stays on the core service, because it is a different + * problem: it loads the template and the results payload, which no list can + * afford to do per row. + * + * `clientName` comes from the client-role `inspection_people` join, never from + * `inspections.client_name` — that column survives GDPR erasure as a stale + * cache. The role filter joins FIRST so the join does not fan out over every + * role on the inspection. + */ +export class InspectionQueryService extends InspectionSubService { + /** + * Lists inspections with pagination and filtering. + */ + async listInspections(tenantId: string, params: InspectionListParams) { + const db = this.getDrizzle(); + const conditions = [eq(inspections.tenantId, tenantId)]; + + if (params.status) conditions.push(eq(inspections.status, params.status)); + if (params.inspectorId) conditions.push(eq(inspections.inspectorId, params.inspectorId)); + if (params.dateFrom) conditions.push(gte(inspections.date, params.dateFrom)); + if (params.dateTo) conditions.push(lte(inspections.date, params.dateTo)); + + if (params.search) { + const term = `%${escapeLikePattern(params.search)}%`; + conditions.push(or( + sql`lower(${inspections.propertyAddress}) like lower(${term})`, + sql`lower(${contacts.name}) like lower(${term})` // primary-client join below, not the frozen legacy inspections.client_name + )!); + } + + const tabParam = (params as { tab?: string }).tab; + if (tabParam && tabParam !== 'all') { + const todayStr = new Date().toISOString().slice(0, 10); + switch (tabParam) { + case 'today': + conditions.push(sql`date(${inspections.date}) = ${todayStr}`); + break; + case 'upcoming': + conditions.push(sql`${inspections.date} > ${todayStr}`); + conditions.push(sql`${inspections.status} not in ('completed','cancelled')`); + break; + case 'past': + conditions.push(or( + sql`${inspections.date} < ${todayStr}`, + inArray(inspections.status, ['completed', 'cancelled']) + )!); + break; + // Same two definitions the workspace filters use — one word, one + // meaning, whichever tier asks. + case 'needs_confirmation': + conditions.push(inArray(inspections.status, [INSPECTION_STATUS.SCHEDULED, INSPECTION_STATUS.REQUESTED])); + break; + case 'awaiting_report': + conditions.push(eq(inspections.status, INSPECTION_STATUS.COMPLETED)); + conditions.push(sql`${inspections.reportStatus} <> ${REPORT_STATUS.PUBLISHED}`); + break; + } + } + + if (params.cursor) { + try { + const c = JSON.parse(atob(params.cursor)); + conditions.push(or( + lt(inspections.createdAt, new Date(c.createdAt)), + and(eq(inspections.createdAt, new Date(c.createdAt)), lt(inspections.id, c.id)) + )!); + } catch { throw Errors.BadRequest('Invalid cursor'); } + } + + // Task 9c (people-role-profiles) — clientName/clientEmail are sourced + // from the inspection_people primary-client join, not the legacy + // inspections.client_name/_email columns (frozen cache, dropped Task + // 13). A single LEFT JOIN keeps this list N+1-free; contact_role_profiles + // is joined BEFORE inspection_people (filtered to the 'client' role) + // so the join stays scoped to the primary client, mirroring the join + // order already used for top-agents in api/metrics.ts. + const rows = await db.select({ + inspection: inspections, + primaryClientName: contacts.name, + primaryClientEmail: contacts.email, + }) + .from(inspections) + .leftJoin(contactRoleProfiles, and( + eq(contactRoleProfiles.tenantId, inspections.tenantId), + eq(contactRoleProfiles.key, PRIMARY_CLIENT_KEY), + eq(contactRoleProfiles.active, true), + )) + .leftJoin(inspectionPeople, and( + eq(inspectionPeople.roleProfileId, contactRoleProfiles.id), + eq(inspectionPeople.inspectionId, inspections.id), + eq(inspectionPeople.tenantId, inspections.tenantId), + )) + .leftJoin(contacts, and( + eq(contacts.id, inspectionPeople.contactId), + eq(contacts.tenantId, inspections.tenantId), + )) + .where(and(...conditions)) + .orderBy(sql`${inspections.createdAt} desc, ${inspections.id} desc`) + .limit(params.limit + 1); + + const hasMore = rows.length > params.limit; + const page = hasMore ? rows.slice(0, params.limit) : rows; + + let nextCursor: string | null = null; + if (hasMore) { + const last = page[page.length - 1].inspection; + nextCursor = btoa(JSON.stringify({ createdAt: safeTimestamp(last.createdAt), id: last.id })); + } + + const inspectionsFormatted: Inspection[] = page.map(({ inspection: row, primaryClientName, primaryClientEmail }) => ({ + ...row, + id: row.id as string, + propertyAddress: row.propertyAddress as string, + clientName: primaryClientName ?? null, + clientEmail: primaryClientEmail ?? null, + status: row.status, + date: row.date as string, + inspectorId: row.inspectorId as string | null, + templateId: row.templateId as string | null, + createdAt: safeISODate(row.createdAt), + })); + + return { inspections: inspectionsFormatted, nextCursor, hasMore }; + } + + /** + * Fetches counts for the dashboard. + */ + async getStats(tenantId: string) { + const db = this.getDrizzle(); + const counts = await db.select({ status: inspections.status, count: sql`count(*)` }) + .from(inspections) + .where(eq(inspections.tenantId, tenantId)) + .groupBy(inspections.status); + + const stats = { total: 0, requested: 0, completed: 0, published: 0 }; + for (const row of counts) { + const n = Number(row.count); + stats.total += n; + if (row.status === INSPECTION_STATUS.REQUESTED) stats.requested = n; + else if (row.status === INSPECTION_STATUS.COMPLETED) stats.completed = n; + } + return stats; + } +} diff --git a/server/services/inspection/inspection-recipients.service.ts b/server/services/inspection/inspection-recipients.service.ts new file mode 100644 index 000000000..ae6b67e25 --- /dev/null +++ b/server/services/inspection/inspection-recipients.service.ts @@ -0,0 +1,170 @@ +import { eq, and } from 'drizzle-orm'; +import { inspections, users } from '../../lib/db/schema'; +import { PeopleService } from '../people.service'; +import { Errors } from '../../lib/errors'; +import { InspectionSubService } from './base'; + +/** Internal — one Publish-modal recipient row (client or agent). Not exported: + * the public `getRecipientList` signature keeps its inline structural type. */ +interface InspectionRecipient { + contactId: string | null; + name: string; + role: 'client' | 'agent_buyer' | 'agent_listing'; + email: string | null; + phone: string | null; +} + +/** `contact_role_profiles.key` → `InspectionRecipient.role`, for the three + * roles `getRecipientList` covers. Other role keys (co_client, attorney, + * ...) are intentionally absent — Spec 2 widens the recipient set. */ +const RECIPIENT_ROLE_MAP: Record = { + client: 'client', + buyer_agent: 'agent_buyer', + listing_agent: 'agent_listing', +}; + +/** + * WHO is attached to an inspection, read two ways. + * + * `getRecipientList` answers "who can this report be delivered to" for the + * Publish modal — a flat list restricted to the three roles that contract + * covers, with anyone unreachable (no email AND no phone) dropped. + * `getPeopleCard` answers "who is on this job" for the inspector portal — the + * same `inspection_people` join, grouped by role and including the assigned + * inspector. + * + * They share a file because they share the source of truth and must not + * disagree about it: `inspection_people` via `PeopleService.listPeople` is the + * ONLY persistence of who, since Task 13 dropped the legacy contact columns + * from `inspections`. A second reader that reached for those columns would + * quietly resurrect GDPR-erased names. + */ +export class InspectionRecipientsService extends InspectionSubService { + /** + * Round-2 F1 — list every party associated with an inspection so the + * Publish modal can render per-recipient Email + Text checkboxes. + * + * Sourced from `PeopleService.listPeople` (the `inspection_people` join), + * restricted to the three roles this Publish-modal contract covers + * (`client` / `buyer_agent` / `listing_agent` — see `RECIPIENT_ROLE_MAP`); + * other role kinds (co_client, attorney, ...) are ignored here (Spec 2 + * widens the recipient set). + * + * Returned shape (`InspectionRecipient[]`): + * - role: 'client' | 'agent_buyer' | 'agent_listing' + * - contactId: the person's contact row id (now populated for every + * role, including the client — the legacy inline-client column had + * no contact row, so this used to be null for `role: 'client'`) + * - name, email, phone + * + * Recipients without any contact info (no email AND no phone) are dropped + * because there is no way to deliver to them. Tenant-scoped via the + * compound `where(eq(id), eq(tenantId))` guard on the inspection lookup + * AND `PeopleService.listPeople`'s own tenant filter. + */ + async getRecipientList(inspectionId: string, tenantId: string): Promise { + const db = this.getDrizzle(); + + const inspection = await db.select().from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) + .get(); + if (!inspection) throw Errors.NotFound('Inspection not found'); + + const people = await new PeopleService({ DB: this.db }).listPeople(tenantId, inspectionId); + + const recipients: InspectionRecipient[] = []; + for (const p of people) { + const role = RECIPIENT_ROLE_MAP[p.roleKey]; + if (!role) continue; // ignore co_client/attorney/etc — Spec 2 widens the recipient set + if (!p.email && !p.phone) continue; // no delivery channel + recipients.push({ + contactId: p.contactId, + name: p.name, + role, + email: p.email ?? null, + phone: p.phone ?? null, + }); + } + + return recipients; + } + + /** + * Round-2 F3 — People card payload (Spectora §E.2 / §4.1). + * + * Groups every party connected to an inspection by role so the inspection + * Settings page can render a contact card with role chips: + * + * - Inspector → users row referenced by inspectorId + * - Client, Buyer's Agent, Listing Agent → `inspection_people` rows + * (via `PeopleService.listPeople`), matched on `roleKey`. Other role + * kinds (co_client, attorney, ...) are ignored here (Spec 2 widens + * the people card). + * + * Each agent's `.id` is the CONTACT id (`p.contactId`), matching the old + * contract — NOT the `inspection_people` join-row id (`p.id`). + * + * Schema currently allows ONE buyer agent + ONE listing agent per + * inspection. The result returns arrays for forward-compat (so the UI + * can render "Buyer's Agent · 2" if multi-agent ever ships) without a + * follow-up service refactor. + */ + async getPeopleCard(inspectionId: string, tenantId: string): Promise<{ + inspector: { id: string; name: string | null; email: string; phone: string | null } | null; + client: { name: string; email: string | null; phone: string | null } | null; + buyerAgents: Array<{ id: string; name: string; email: string | null; phone: string | null; agency: string | null }>; + listingAgents: Array<{ id: string; name: string; email: string | null; phone: string | null; agency: string | null }>; + }> { + const db = this.getDrizzle(); + + const inspection = await db.select().from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) + .get(); + if (!inspection) throw Errors.NotFound('Inspection not found'); + + // Inspector — users table (tenant-scoped). + let inspector: { id: string; name: string | null; email: string; phone: string | null } | null = null; + if (inspection.inspectorId) { + const u = await db.select().from(users) + .where(and(eq(users.id, inspection.inspectorId as string), eq(users.tenantId, tenantId))) + .get(); + if (u) { + inspector = { + id: u.id as string, + name: (u.name as string | null) ?? null, + email: u.email as string, + phone: (u.phone as string | null) ?? null, + }; + } + } + + // Client + agents — from inspection_people (via PeopleService). + const people = await new PeopleService({ DB: this.db }).listPeople(tenantId, inspectionId); + + const clientP = people.find(p => p.roleKey === 'client') ?? null; + const client = clientP + ? { + name: clientP.name, + email: clientP.email ?? null, + phone: clientP.phone ?? null, + } + : null; + + const toAgent = (p: (typeof people)[number]) => ({ + id: p.contactId, // CONTACT id — matches the old contract, not the join-row id + name: p.name, + email: p.email ?? null, + phone: p.phone ?? null, + agency: p.agency ?? null, + }); + const buyerAgents = people.filter(p => p.roleKey === 'buyer_agent').map(toAgent); + const listingAgents = people.filter(p => p.roleKey === 'listing_agent').map(toAgent); + + return { + inspector, + client, + buyerAgents, + listingAgents, + }; + } +} diff --git a/server/services/inspection/inspection-reinspection.service.ts b/server/services/inspection/inspection-reinspection.service.ts new file mode 100644 index 000000000..5ad22fc28 --- /dev/null +++ b/server/services/inspection/inspection-reinspection.service.ts @@ -0,0 +1,288 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { inspections, inspectionResults, users, tenantConfigs, reportVersions } from '../../lib/db/schema'; +import { PeopleService } from '../people.service'; +import { getRatingBucket, type RatingLevel } from '../../lib/report-utils'; +import { mapRatingSystemLevels } from '../../lib/map-rating-levels'; +import { logger } from '../../lib/logger'; +import { createPrimaryReport } from '../../lib/inspection/reports'; +import { findingKey, DEFAULT_UNIT } from '../../lib/finding-key'; +import { parseReinspectionStatuses, isOpenStatus } from '../../lib/reinspection-status'; +import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; +import type { Inspection } from './shared'; +import type { ScopedDB } from '../../lib/db/scoped'; +import type { ImagesBinding } from '../../lib/media/strip-exif'; +import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; +import { InspectionSubService } from './base'; + +/** Parse a report_versions.snapshotJson payload (snapshotOnPublish serialises + * `{ inspection, data, units }`); both re-inspection paths read only `.data`, + * keyed by findingKey or legacy item id. */ +function parseSnapshotData(snapshotJson: string): { data?: Record> } { + return JSON.parse(snapshotJson) as { data?: Record> }; +} + +/** + * #119 — RE-INSPECTION ROUNDS: creating a follow-up round over a published + * baseline, and listing what is eligible to carry into one. + * + * The seam is the baseline. Both methods here are meaningless without a + * PUBLISHED prior inspection: `getReinspectCandidates` reads its latest + * `report_versions` snapshot to decide what is still open, and + * `createReinspection` seeds the new round's `inspection_results.data` from the + * same snapshot. Nothing else in the inspection lifecycle reads + * `report_versions` to write an inspection, and these two must agree about what + * `.original` means when a baseline is itself a re-inspection — which is why + * they share a file and a snapshot parser. + */ +export class InspectionReinspectionService extends InspectionSubService { + private readonly planQuota: PlanQuotaGuard | undefined; + + constructor( + db: D1Database, + r2?: R2Bucket, + sdb?: ScopedDB, + kv?: KVNamespace, + images?: ImagesBinding, + planQuota?: PlanQuotaGuard, + ) { + super(db, r2, sdb, kv, images); + this.planQuota = planQuota; + } + + /** + * #119 — Re-inspection. Creates a NEW draft inspection linked to a published + * baseline (the original OR a prior re-inspection). Seeds inspection_results.data + * for ONLY the selected items, each `{ original, followupStatus: null }`, where + * `original` carries the root finding forward from the baseline's latest published + * report_versions snapshot (or the propagated `.original` if the baseline is itself + * a re-inspection). + * + * GATE: the baseline must be published — i.e. have ≥1 report_versions row. + */ + async createReinspection( + tenantId: string, + baselineId: string, + opts: { selectedItemIds: string[]; inspectorId?: string }, + ): Promise { + const db = this.getDrizzle(); + + const baseline = await db.select().from(inspections) + .where(and(eq(inspections.id, baselineId), eq(inspections.tenantId, tenantId))).get(); + if (!baseline) throw new Error('Baseline inspection not found'); + + const latestVersion = await db.select().from(reportVersions) + .where(and(eq(reportVersions.tenantId, tenantId), eq(reportVersions.inspectionId, baselineId))) + .orderBy(desc(reportVersions.versionNumber)).limit(1).get(); + if (!latestVersion) throw new Error('Cannot re-inspect an unpublished baseline'); + + // When an explicit inspectorId is supplied, it MUST resolve to a user in + // this tenant. inspector_id has a DB FK to users.id; a foreign-tenant or + // bogus id would either violate the FK at runtime or assign the round to + // another tenant's user. Validate before use; omitted → baseline fallback. + if (opts.inspectorId) { + const owner = await db.select({ id: users.id }).from(users) + .where(and(eq(users.id, opts.inspectorId), eq(users.tenantId, tenantId))).get(); + if (!owner) throw new Error('Inspector not found in this workspace'); + } + + const rootId = baseline.rootInspectionId ?? baseline.id; + const existingRounds = await db.select().from(inspections) + .where(and(eq(inspections.tenantId, tenantId), eq(inspections.rootInspectionId, rootId))).all(); + const round = existingRounds.length + 1; + + // The latest published snapshot is the carry-forward source. snapshotOnPublish + // serialises { inspection, data, units }; we read .data[itemId]. + const baseSnapshot = parseSnapshotData(latestVersion.snapshotJson); + const baselineIsReinspection = baseline.sourceInspectionId != null; + + const seeded: Record = {}; + for (const itemId of opts.selectedItemIds) { + const item = baseSnapshot.data?.[itemId] ?? {}; + // When the baseline is itself a re-inspection AND its snapshot item already + // carries a propagated `.original` root finding, forward THAT (so round N + // always shows the root defect, never the intermediate follow-up state). + const original = baselineIsReinspection && item.original + ? item.original + : { rating: item.rating ?? null, notes: item.notes ?? null, photos: item.photos ?? [] }; + seeded[itemId] = { original, followupStatus: null }; + } + + const id = crypto.randomUUID(); + const createdAt = new Date(); + // Quota is consumed only after every precondition check above (baseline + // existence, published-baseline gate, inspector ownership) has passed + // and immediately before the insert that actually creates the + // re-inspection — a failed validation must never burn a free tenant's + // lifetime slot. + await this.planQuota?.consumeInspection(tenantId); + await db.insert(inspections).values({ + id, + tenantId, + // Reuse the baseline's property + client + template fields. + inspectorId: opts.inspectorId ?? baseline.inspectorId ?? null, + propertyAddress: baseline.propertyAddress, + addressPlaceId: baseline.addressPlaceId, + addressStreet: baseline.addressStreet, + addressCity: baseline.addressCity, + addressState: baseline.addressState, + addressZip: baseline.addressZip, + addressCounty: baseline.addressCounty, + addressLat: baseline.addressLat, + addressLng: baseline.addressLng, + templateId: baseline.templateId, + templateSnapshot: baseline.templateSnapshot, + templateSnapshotVersion: baseline.templateSnapshotVersion, + date: createdAt.toISOString(), + status: INSPECTION_STATUS.REQUESTED, + paymentStatus: 'unpaid', + price: 0, + paymentRequired: false, + agreementRequired: false, + createdAt, + // #119 link columns. + sourceInspectionId: baselineId, + rootInspectionId: rootId, + 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, + }); + + // Task 7c (people-role-profiles fix) — copy the baseline's + // inspection_people rows (client / buyer_agent / listing_agent / ...) + // onto the new re-inspection. Task 13 dropped the legacy + // clientContactId/clientName/clientEmail/clientPhone columns from the + // inspections row, so this copy is now the ONLY carry-forward of WHO. + // Without this, getInspection/listInspections (Task 9c-reads) resolve the client + // via inspection_people ONLY and would show a null client on every + // re-inspection. Non-fatal: a people-write failure must never roll + // back the already-committed re-inspection row. + try { + const people = new PeopleService({ DB: this.db }); + const baselinePeople = await people.listPeople(tenantId, baselineId); + for (const p of baselinePeople) { + await people.addPerson(tenantId, id, p.contactId, p.roleProfileId); + } + } catch (err) { + logger.error('inspection-people copy from reinspection create failed', { inspectionId: id }, err instanceof Error ? err : undefined); + } + + const created = await db.select().from(inspections).where(eq(inspections.id, id)).get(); + return created as unknown as Inspection; + } + + /** + * #119 (Task 6) — Candidate items for the "Create re-inspection" modal. + * Returns the baseline's still-open flagged items so the UI can pre-check + * the ones worth carrying forward. Computed off the SAME published snapshot + * `createReinspection` reads, so the returned `itemId`s are exactly the keys + * accepted as `selectedItemIds`. + * + * `open` default-check rule (mirrors the task spec): + * - ORIGINAL baseline (no sourceInspectionId): item is open when its rating + * bucket is `defect` or `monitor`. + * - RE-INSPECTION baseline: item is open when its `followupStatus` is a + * non-closed status (via isOpenStatus + the tenant's status set). + * + * Returns [] when the baseline is unpublished (no snapshot) — the caller + * gates the action on publication anyway, and the modal renders an empty + * state. Labels come from the baseline's templateSnapshot; an unmatched key + * degrades to the raw item id. + */ + async getReinspectCandidates( + tenantId: string, + baselineId: string, + ): Promise> { + const db = this.getDrizzle(); + + const baseline = await db.select().from(inspections) + .where(and(eq(inspections.id, baselineId), eq(inspections.tenantId, tenantId))).get(); + if (!baseline) return []; + + const latestVersion = await db.select().from(reportVersions) + .where(and(eq(reportVersions.tenantId, tenantId), eq(reportVersions.inspectionId, baselineId))) + .orderBy(desc(reportVersions.versionNumber)).limit(1).get(); + if (!latestVersion) return []; // unpublished baseline → no candidates + + const baselineIsReinspection = baseline.sourceInspectionId != null; + + // Snapshot data is keyed by findingKey (unit:section:item) or, for legacy + // inspections, the plain item id — the same keys createReinspection reads. + const snapData = parseSnapshotData(latestVersion.snapshotJson).data ?? {}; + + // Resolve item labels from the baseline's templateSnapshot (authoritative + // shape once an inspection exists). Both {sections:[...]} and flat-array + // formats are supported, matching getReportData's schema resolution. + const labelByItemId = new Map(); + const rawSnap = baseline.templateSnapshot as unknown; + const tplSnap = rawSnap + ? (typeof rawSnap === 'string' ? JSON.parse(rawSnap as string) : rawSnap) + : null; + const sections: Array<{ id?: string; items?: Array> }> = Array.isArray(tplSnap) + ? [{ id: 'general', items: tplSnap as Array> }] + : Array.isArray((tplSnap as { sections?: unknown })?.sections) + ? (tplSnap as { sections: Array<{ id?: string; items?: Array> }> }).sections + : []; + for (const sec of sections) { + for (const it of sec.items ?? []) { + const itemId = String(it.id ?? ''); + if (!itemId) continue; + const label = String(it.label ?? it.title ?? it.name ?? itemId); + labelByItemId.set(itemId, label); + // Also map the composite findingKey so snapshot keys resolve. + labelByItemId.set(findingKey(DEFAULT_UNIT, String(sec.id ?? ''), itemId), label); + } + } + + // Rating levels for bucket resolution (original-baseline rule). Read from + // the templateSnapshot.ratingSystem when present; absence degrades to the + // legacy string-bucket map inside getRatingBucket. + const snapLevels = !Array.isArray(tplSnap) + ? (tplSnap as { ratingSystem?: { levels?: unknown[] } } | null)?.ratingSystem?.levels + : undefined; + const levels: RatingLevel[] = Array.isArray(snapLevels) + ? mapRatingSystemLevels(snapLevels as Array>) + : []; + + // Resolve the tenant's configured follow-up status set (re-inspection rule). + const configRow = await db.select({ reinspectionStatuses: tenantConfigs.reinspectionStatuses }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const resolvedStatuses = parseReinspectionStatuses(configRow?.reinspectionStatuses ?? null); + + const out: Array<{ itemId: string; label: string; originalNotes: string | null; open: boolean }> = []; + for (const [itemId, entry] of Object.entries(snapData)) { + const rating = (entry.rating ?? null) as string | null; + const notes = (entry.notes ?? null) as string | null; + // A re-inspection snapshot may already carry the propagated root finding. + const original = (entry.original ?? null) as { notes?: string | null } | null; + const originalNotes = baselineIsReinspection && original ? (original.notes ?? null) : notes; + + let open: boolean; + if (baselineIsReinspection) { + open = isOpenStatus((entry.followupStatus ?? null) as string | null, resolvedStatuses); + } else { + const bucket = getRatingBucket(rating, levels); + open = bucket === 'defect' || bucket === 'monitor'; + } + + out.push({ + itemId, + label: labelByItemId.get(itemId) ?? itemId, + originalNotes, + open, + }); + } + // Open items first, then by label — the pre-checked carry-forward set surfaces on top. + out.sort((a, b) => (a.open === b.open ? a.label.localeCompare(b.label) : a.open ? -1 : 1)); + return out; + } +} From d203b2a72cba3dfafd3fe3491ca14de4f26b045a Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 15:31:24 +0800 Subject: [PATCH 41/77] refactor(settings): the workspace route keeps the form; two report panels move out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `settings-workspace.tsx` sat at 498 against a 499 cap, so the booking-deposit work (#20) and #21-#26 could not add a line to it. 498 -> 373, and its baseline entry is REMOVED rather than tightened. Pure move: every field name, default, class string and heading is byte-identical, and the route's `loader` / `action` / default export contract is untouched. `app/lib/forms/branding-body.ts` — `brandingUpdateBody()`, the validated form values -> `POST /api/admin/branding` body mapping. This is the only part of the route that is genuinely hard to read, because three different rules about whether a key is SENT AT ALL sit next to each other and look alike: text fields send an empty string to CLEAR, conform-native checkboxes must send an explicit `false` because an unchecked box submits nothing, and the preference keys (timezone / locale / currency / date + time format) must be OMITTED when empty or they overwrite a stored choice with `""` (#270). Extracting it makes those rules testable without a router, which matters here: the route has no unit test at all — only three E2E specs, and `test:e2e` is not in the normal suite run. `branding-body.test.ts` now pins all three rules. `ReportFeaturesPanel` / `ReportPdfPanel` — the two sections that answer "what does a published report offer, and what does it print in its margins". They are separate panels, not one, because they are separate nav targets and their toggles default the opposite way: report features default OFF (a company that never opted in should not grow buttons on its reports), report-PDF furniture defaults ON (an unset value there means "never configured", not "turned off"). `SettingToggle` — the checkbox-with-a-bold-title-and-a-description shape that all five toggles already repeated verbatim. Extracted rather than copied into both panels so the split does not push new clones at the duplicate-code ceiling, and so the conform-native "no hidden false sibling" rule is stated once where the input is. What stays behind is the form itself: the Conform wiring, the timezone adopt/prefill effect, and the sections that are one or two lines of JSX over a shared control. The timezone block in particular was left alone deliberately — it owns a ref, two pieces of state and a mount effect, and it is one of the behaviours with no unit coverage. `type-check:app` run explicitly, not just `:api`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- .../settings/ReportFeaturesPanel.tsx | 39 +++++ app/components/settings/ReportPdfPanel.tsx | 76 +++++++++ app/components/settings/SettingToggle.tsx | 37 +++++ app/lib/forms/branding-body.test.ts | 63 +++++++ app/lib/forms/branding-body.ts | 62 +++++++ app/routes/settings-workspace.tsx | 157 ++---------------- scripts/file-size-baseline.json | 1 - 7 files changed, 293 insertions(+), 142 deletions(-) create mode 100644 app/components/settings/ReportFeaturesPanel.tsx create mode 100644 app/components/settings/ReportPdfPanel.tsx create mode 100644 app/components/settings/SettingToggle.tsx create mode 100644 app/lib/forms/branding-body.test.ts create mode 100644 app/lib/forms/branding-body.ts diff --git a/app/components/settings/ReportFeaturesPanel.tsx b/app/components/settings/ReportFeaturesPanel.tsx new file mode 100644 index 000000000..748b7ac1e --- /dev/null +++ b/app/components/settings/ReportFeaturesPanel.tsx @@ -0,0 +1,39 @@ +import { SettingToggle } from "./SettingToggle"; +import { m } from "~/paraglide/messages"; + +/** + * Settings → Company: "Report features" section. Two opt-in capabilities that + * change what a published report OFFERS the client — the repair list itself, + * and the client's ability to export it. Both default OFF: a company that has + * not chosen to work that way should not have their reports grow buttons. + * + * Presentational — the route owns the Conform form and the save action; these + * are uncontrolled checkboxes read straight off the submitted FormData. + */ +export function ReportFeaturesPanel({ + enableRepairList, + enableCustomerRepairExport, +}: { + enableRepairList: boolean | null | undefined; + enableCustomerRepairExport: boolean | null | undefined; +}) { + return ( +
+

{m.settings_workspace_report_features_heading()}

+ + + + +
+ ); +} diff --git a/app/components/settings/ReportPdfPanel.tsx b/app/components/settings/ReportPdfPanel.tsx new file mode 100644 index 000000000..d162e4795 --- /dev/null +++ b/app/components/settings/ReportPdfPanel.tsx @@ -0,0 +1,76 @@ +import { SettingToggle } from "./SettingToggle"; +import { m } from "~/paraglide/messages"; + +/** The subset of Conform field metadata this panel reads for the address input. */ +type AddressField = { + id: string; + name: string; + errors?: string[] | undefined; +}; + +/** + * Settings → Company: "Report PDF" section — what the printed/exported report + * puts in its margins. Grouped as one panel because these four settings only + * ever matter together: they are the page furniture (company address, footer, + * page numbers, license line), not anything about inspection content. + * + * The three toggles default ON here (`?? true`), unlike the report-feature + * flags: an existing report already prints its footer, so an unset value means + * "never configured", not "turned off". + * + * Presentational — the route owns the Conform form and the save action. + */ +export function ReportPdfPanel({ + addressField, + companyAddress, + pdfShowFooter, + pdfShowPageNumbers, + pdfShowLicense, +}: { + addressField: AddressField; + companyAddress: string | null | undefined; + pdfShowFooter: boolean | null | undefined; + pdfShowPageNumbers: boolean | null | undefined; + pdfShowLicense: boolean | null | undefined; +}) { + return ( +
+

{m.settings_workspace_report_pdf_heading()}

+

{m.settings_workspace_report_pdf_subtitle()}

+ +
+ + +

{m.settings_workspace_company_address_hint()}

+ {addressField.errors && ( +

{addressField.errors[0]}

+ )} +
+ + + + + + +
+ ); +} diff --git a/app/components/settings/SettingToggle.tsx b/app/components/settings/SettingToggle.tsx new file mode 100644 index 000000000..dc8dc9d2a --- /dev/null +++ b/app/components/settings/SettingToggle.tsx @@ -0,0 +1,37 @@ +/** + * A settings checkbox with a bold title and an explanatory line beneath it. + * + * Conform-native: a checked box submits the single value `"on"` and an + * unchecked one submits nothing, which is what lets `submission.value` coerce + * to a boolean. Do NOT add a hidden "false" sibling — two values for one name + * breaks `z.boolean()` parsing (see `makeWorkspaceSchema`). The action is + * responsible for turning the resulting `undefined` back into an explicit + * `false` so that unchecking persists. + */ +export function SettingToggle({ + name, + defaultChecked, + title, + description, +}: { + name: string; + defaultChecked: boolean; + title: string; + description: string; +}) { + return ( + + ); +} diff --git a/app/lib/forms/branding-body.test.ts b/app/lib/forms/branding-body.test.ts new file mode 100644 index 000000000..39f9fcabd --- /dev/null +++ b/app/lib/forms/branding-body.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { brandingUpdateBody } from "~/lib/forms/branding-body"; + +/** The action always hands over a successfully-parsed submission value. */ +const values = (v: Record) => v as Parameters[0]; + +describe("brandingUpdateBody", () => { + it("sends every checkbox explicitly, even when the box was never rendered", () => { + const body = brandingUpdateBody(values({ companyName: "Acme" })); + expect(body).toMatchObject({ + enableRepairList: false, + enableCustomerRepairExport: false, + pdfShowFooter: false, + pdfShowPageNumbers: false, + pdfShowLicense: false, + }); + }); + + it("keeps a checked box true", () => { + const body = brandingUpdateBody(values({ companyName: "Acme", enableRepairList: true, pdfShowLicense: true })); + expect(body.enableRepairList).toBe(true); + expect(body.pdfShowLicense).toBe(true); + }); + + it("omits preference keys that arrived empty so a stored choice survives", () => { + const body = brandingUpdateBody( + values({ companyName: "Acme", defaultTimezone: "", defaultLocale: "", currency: "", dateFormat: "", timeFormat: "" }), + ); + for (const key of ["defaultTimezone", "defaultLocale", "currency", "dateFormat", "timeFormat"]) { + expect(Object.hasOwn(body, key)).toBe(false); + } + }); + + it("sends preference keys that carry a value", () => { + const body = brandingUpdateBody( + values({ companyName: "Acme", defaultTimezone: "America/Denver", defaultLocale: "es-MX", currency: "MXN", dateFormat: "iso", timeFormat: "24h" }), + ); + expect(body).toMatchObject({ + defaultTimezone: "America/Denver", + defaultLocale: "es-MX", + currency: "MXN", + dateFormat: "iso", + timeFormat: "24h", + }); + }); + + it("trims companyAddress and lets an empty string clear it", () => { + expect(brandingUpdateBody(values({ companyName: "Acme", companyAddress: " 1 Main St " })).companyAddress).toBe("1 Main St"); + const cleared = brandingUpdateBody(values({ companyName: "Acme", companyAddress: "" })); + expect(Object.hasOwn(cleared, "companyAddress")).toBe(true); + expect(cleared.companyAddress).toBe(""); + }); + + it("splits custom referral sources one per line, dropping blanks", () => { + const body = brandingUpdateBody(values({ companyName: "Acme", customReferralSources: "Zillow\n\n Redfin \n" })); + expect(body.customReferralSources).toEqual(["Zillow", "Redfin"]); + }); + + it("omits customReferralSources entirely when the field was absent", () => { + expect(Object.hasOwn(brandingUpdateBody(values({ companyName: "Acme" })), "customReferralSources")).toBe(false); + }); +}); diff --git a/app/lib/forms/branding-body.ts b/app/lib/forms/branding-body.ts new file mode 100644 index 000000000..d6d4c9b7f --- /dev/null +++ b/app/lib/forms/branding-body.ts @@ -0,0 +1,62 @@ +import type { z } from "zod"; +import type { makeWorkspaceSchema } from "~/lib/forms/settings.schema"; + +type WorkspaceFormValues = z.output>; + +/** + * Company settings form values → the `POST /api/admin/branding` request body. + * + * Pure, so the rules that decide whether a key is SENT AT ALL are readable in + * one place and testable without a router. Three different rules live here and + * they are easy to confuse: + * + * - Text fields are sent when the form carried them; an empty string is a + * real value that CLEARS the stored one (`companyAddress`). + * - Checkboxes are conform-native (a checked box submits `"on"`, an unchecked + * one submits nothing), so `undefined` means "off" and must be sent as an + * explicit `false` — otherwise unchecking never persists. + * - Preference fields (timezone / locale / currency / date + time format) are + * sent ONLY when non-empty: an absent key must leave the stored preference + * alone, which is why the API schema carries no `.default()` for them + * (see #270). Sending `""` there would silently overwrite a real choice. + */ +export function brandingUpdateBody(v: WorkspaceFormValues): Record { + const body: Record = {}; + if (v.companyName !== undefined) body.companyName = v.companyName; + if (v.primaryColor !== undefined) body.primaryColor = v.primaryColor; + if (v.defaultProfileId !== undefined) body.defaultProfileId = v.defaultProfileId; + + // Custom referral sources: one label per line + if (typeof v.customReferralSources === "string") { + body.customReferralSources = v.customReferralSources + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + } + + // Boolean feature flags — conform-native checkboxes coerce to boolean in + // submission.value (checked → true, absent → undefined). Always send an explicit + // boolean so unchecking persists false. + body.enableRepairList = v.enableRepairList ?? false; + body.enableCustomerRepairExport = v.enableCustomerRepairExport ?? false; + + // Report PDF settings. companyAddress is free text (trim; empty string clears). + // The three toggles are conform-native checkboxes — absent (unchecked) must + // persist false, so coerce with `?? false` (the same pattern as the flags above). + if (typeof v.companyAddress === "string") body.companyAddress = v.companyAddress.trim(); + body.pdfShowFooter = v.pdfShowFooter ?? false; + body.pdfShowPageNumbers = v.pdfShowPageNumbers ?? false; + body.pdfShowLicense = v.pdfShowLicense ?? false; + + // Tenant display timezone (IANA). Only sent when a value is present. + if (typeof v.defaultTimezone === "string" && v.defaultTimezone) body.defaultTimezone = v.defaultTimezone; + // 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; + + return body; +} diff --git a/app/routes/settings-workspace.tsx b/app/routes/settings-workspace.tsx index 7952e16a7..20849451c 100644 --- a/app/routes/settings-workspace.tsx +++ b/app/routes/settings-workspace.tsx @@ -13,6 +13,9 @@ import { SectionNav } from "~/components/settings/SectionNav"; import { ProfilePicker } from "~/components/settings/ProfilePicker"; import { ReportStylePreview } from "~/components/settings/ReportStylePreview"; import { makeWorkspaceSchema } from "~/lib/forms/settings.schema"; +import { brandingUpdateBody } from "~/lib/forms/branding-body"; +import { ReportFeaturesPanel } from "~/components/settings/ReportFeaturesPanel"; +import { ReportPdfPanel } from "~/components/settings/ReportPdfPanel"; import { requireAdminLoader } from "~/lib/access.server"; import { AccessDenied } from "~/components/AccessDenied"; import { Select } from "@core/shared-ui"; @@ -85,44 +88,7 @@ export async function action({ request, context }: Route.ActionArgs) { if (submission.status !== "success") { return submission.reply(); } - const v = submission.value; - - const body: Record = {}; - if (v.companyName !== undefined) body.companyName = v.companyName; - if (v.primaryColor !== undefined) body.primaryColor = v.primaryColor; - if (v.defaultProfileId !== undefined) body.defaultProfileId = v.defaultProfileId; - - // Custom referral sources: one label per line - if (typeof v.customReferralSources === "string") { - body.customReferralSources = v.customReferralSources - .split("\n") - .map((s) => s.trim()) - .filter(Boolean); - } - - // Boolean feature flags — conform-native checkboxes coerce to boolean in - // submission.value (checked → true, absent → undefined). Always send an explicit - // boolean so unchecking persists false. - body.enableRepairList = v.enableRepairList ?? false; - body.enableCustomerRepairExport = v.enableCustomerRepairExport ?? false; - - // Report PDF settings. companyAddress is free text (trim; empty string clears). - // The three toggles are conform-native checkboxes — absent (unchecked) must - // persist false, so coerce with `?? false` (the same pattern as the flags above). - if (typeof v.companyAddress === "string") body.companyAddress = v.companyAddress.trim(); - body.pdfShowFooter = v.pdfShowFooter ?? false; - body.pdfShowPageNumbers = v.pdfShowPageNumbers ?? false; - body.pdfShowLicense = v.pdfShowLicense ?? false; - - // Tenant display timezone (IANA). Only sent when a value is present. - if (typeof v.defaultTimezone === "string" && v.defaultTimezone) body.defaultTimezone = v.defaultTimezone; - // 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 body = brandingUpdateBody(submission.value); const api = createApi(context, { token }); // Body is runtime-assembled from Zod-validated form values matching UpdateBrandingSchema; @@ -380,109 +346,18 @@ export default function SettingsWorkspacePage() {
- {/* Report features */} -
-

{m.settings_workspace_report_features_heading()}

- - - - -
- - {/* Report PDF */} -
-

{m.settings_workspace_report_pdf_heading()}

-

{m.settings_workspace_report_pdf_subtitle()}

- -
- - -

{m.settings_workspace_company_address_hint()}

- {fields.companyAddress.errors && ( -

{fields.companyAddress.errors[0]}

- )} -
- - - - - - -
+ + + {form.errors && (
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index c6c13b1da..8c68cb3fc 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -43,7 +43,6 @@ "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/repair-builder.ts": 504, "app/routes/inspection-edit/action.server.ts": 501, - "app/routes/settings-workspace.tsx": 499, "server/services/report-export-consumer.ts": 499, "app/components/collab/VersionHistoryPanel.tsx": 497, "server/api/bookings.ts": 477, From ff8792a07820c35ea9fdef9860fd4151d88812db Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 15:37:56 +0800 Subject: [PATCH 42/77] refactor(report): the report body becomes one file per section and one per item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReportView.tsx` sat at 812 against an 813 cap. 812 -> 643 with this cut; the rest follows. Pure move: markup, class strings, keys and prop values are byte-identical, and nothing about ReportView's exported surface changes. `ReportSectionBlock` — one section: its numbered heading, the item cards, the collapsed count card that REPLACES them in "summary" mode, and the disclaimer. Those four travel together because the active filter chooses among them: "defects" drops an empty section entirely, "summary" swaps the item list for a single card AND suppresses the disclaimer. Read any one of them alone and you get the wrong answer about what the client sees, which is exactly why they were 177 lines of nesting inside a `.map`. `ReportItemCard` — one item: label, rating pill, non-rich value, notes, defects, recommendation + estimate, attached repair items, photo grid, and the "add to repair request" checkbox. This is the unit a reader actually goes looking for ("why does the estimate not show?"), and it was four levels of indentation deep. `mediaVisible` and `renderMediaTile` are threaded through as props rather than recreated: both close over the report-wide failed-photo Set and the lightbox, which belong to the report and not to any one section or item. Likewise the repair selection stays in ReportView — the repair panel reads across sections. `REPORT_HEADING_STYLE` moves to `report/types.ts` alongside the other shared print/render constants, so the report title and the section headings cannot drift apart under a style preset. `report-view.image-robustness.test.ts` is a raw-source test that already concatenates the report fileset (the previous split did the same); it gains `ReportItemCard`, which is where the item-photo alt text and the mediaVisible filter now live. The assertions themselves are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- app/components/portal/sections/ReportView.tsx | 203 ++---------------- .../report-view.image-robustness.test.ts | 1 + .../portal/sections/report/ReportItemCard.tsx | 161 ++++++++++++++ .../sections/report/ReportSectionBlock.tsx | 119 ++++++++++ .../portal/sections/report/types.ts | 5 + 5 files changed, 303 insertions(+), 186 deletions(-) create mode 100644 app/components/portal/sections/report/ReportItemCard.tsx create mode 100644 app/components/portal/sections/report/ReportSectionBlock.tsx diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx index a1737a28b..2cb02c2b4 100644 --- a/app/components/portal/sections/ReportView.tsx +++ b/app/components/portal/sections/ReportView.tsx @@ -24,12 +24,12 @@ 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"; -import { getSectionIcon, itemDrivesSummary } from "~/lib/report-helpers"; +import { itemDrivesSummary } from "~/lib/report-helpers"; import { ReportMediaTile } from "./report/ReportMediaTile"; import { CredentialBadges } from "./report/CredentialBadges"; import { badgeUrl } from "../../../../server/lib/media/badge-variant"; import { primaryBadgeOf } from "../../../../server/lib/credentials/primary"; -import { ReportDefectCard } from "./report/ReportDefectCard"; +import { ReportSectionBlock } from "./report/ReportSectionBlock"; import { PhotoAppendix } from "./report/PhotoAppendix"; import { ReportSignatureBlock } from "./report/ReportSignatureBlock"; import { ReportVerificationBlock } from "./report/ReportVerificationBlock"; @@ -43,8 +43,7 @@ import { WordExportButton } from "./report/WordExportButton"; import { CostExportButtons } from "~/components/CostExportButtons"; import { PRINT_CARD_CLASS, - PRINT_SECTION_HEADING_CLASS, - ITEM_PHOTO_GRID_CLASS, + REPORT_HEADING_STYLE, type ReportPhoto, type FilterKey, type ReportLoaderResult, @@ -183,10 +182,6 @@ export function reportViewProps( * report was published). We render a calm panel rather than hiding the * cover section, so the report never looks half-broken to the client. */ -// Report heading typography — driven by the resolved profile's --report-* vars -// (Report Style Presets). Shared by the report title and every section heading. -const REPORT_HEADING_STYLE = { fontFamily: "var(--report-heading-font)", fontWeight: "var(--report-heading-weight)" as unknown as number, letterSpacing: "var(--report-heading-spacing)", textTransform: "var(--report-heading-transform)" as unknown as "none" }; - function CoverPhotoPlaceholder() { return (
@@ -575,184 +570,20 @@ export function ReportView(props: ReportViewProps) { {/* Commercial PCA Phase U — per-unit matrix + exception detail (gated on per_unit mode; renders nothing otherwise → report byte-identical). */} - {filteredSections.map((section, sectionIdx) => { - if (filter === "defects" && section.items.length === 0) return null; - return ( -
-
- {getSectionIcon(section.title)} -

- - {sectionIdx + 1} - - - {section.title} -

-
- - {m.report_view_section_items({ count: section.items.length })} - -
- - {/* Items (hidden in summary mode) */} - {filter !== "summary" && ( -
- {section.items.map((item) => ( -
-
-
-

- {item.label} -

- {item.ratingLabel && ( - - {item.ratingLabel} - - )} -
- - {/* Non-rich item value */} - {item.type && - item.type !== "rich" && - item.value !== undefined && - item.value !== null && - item.value !== "" && ( -

- - {item.type} - - {Array.isArray(item.value) - ? (item.value as unknown[]).join(" · ") - : item.type === "boolean" - ? (item.value as boolean) - ? "Yes" - : "No" - : String(item.value)} - {item.unit && ( - - {item.unit} - - )} -

- )} - - {item.notes && ( -

- {item.notes} -

- )} - - {/* FE-3/B-20 — findings: included canned + custom defects with their - own photos. Previously the viewer rendered neither (field-authored - defects never appeared in the published report at all). */} - - - {item.recommendation && ( -
- - {m.report_view_recommend({ value: item.recommendation })} - - {data.showEstimates && - (item.estimateMin != null || item.estimateMax != null) && ( - - {m.report_view_estimated_cost_label()} $ - {item.estimateMin?.toLocaleString() ?? "?"} - $ - {item.estimateMax?.toLocaleString() ?? "?"} - - )} -
- )} - - {(item.repairItems?.length ?? 0) > 0 && ( -
- {item.repairItems!.map((ri, i) => ( -
- {ri.summary} - {ri.contractorType && ( - {ri.contractorType} - )} - {data.showEstimates && (ri.estimateMin != null || ri.estimateMax != null) && ( - - ${ri.estimateMin?.toLocaleString() ?? "?"} – ${ri.estimateMax?.toLocaleString() ?? "?"} - - )} -
- ))} -
- )} - - {data.photoMode !== "appendix" && item.photos.filter(mediaVisible).length > 0 && ( -
- {item.photos - .filter(mediaVisible) - .map((photo, idx) => renderMediaTile(photo, `${item.label} — photo ${idx + 1}`, idx))} -
- )} - - {itemDrivesSummary(item) && ( - - )} -
-
- ))} -
- )} - - {/* Summary card */} - {filter === "summary" && ( -
-
- - {m.report_view_items_inspected({ count: section.items.length })} - - 0 ? "#f43f5e" : "#22c55e", - }} - > - {section.defectCount > 0 - ? m.report_view_defect_count({ count: section.defectCount, plural: section.defectCount > 1 ? "s" : "" }) - : m.report_view_all_clear()} - -
-
- )} - - {/* Disclaimer */} - {section.disclaimerText && filter !== "summary" && ( -
-
- {m.report_view_disclaimer()} -
-

{section.disclaimerText}

-
- )} -
- ); - })} + {filteredSections.map((section, sectionIdx) => ( + + ))} {/* Commercial PCA Phase C — TABLE 1 (Opinion of Cost) + opt-in TABLE 2 (Reserve Schedule), following the body per the real-PCA layout. diff --git a/app/components/portal/sections/report-view.image-robustness.test.ts b/app/components/portal/sections/report-view.image-robustness.test.ts index 9e5dbe7dd..6d001736d 100644 --- a/app/components/portal/sections/report-view.image-robustness.test.ts +++ b/app/components/portal/sections/report-view.image-robustness.test.ts @@ -27,6 +27,7 @@ async function source(): Promise { import('~/components/portal/sections/ReportView?raw'), import('~/components/portal/sections/report/ReportMediaTile?raw'), import('~/components/portal/sections/report/ReportDefectCard?raw'), + import('~/components/portal/sections/report/ReportItemCard?raw'), ]); return mods.map((m) => (m as unknown as { default: string }).default).join('\n'); } diff --git a/app/components/portal/sections/report/ReportItemCard.tsx b/app/components/portal/sections/report/ReportItemCard.tsx new file mode 100644 index 000000000..fb9ec7b5e --- /dev/null +++ b/app/components/portal/sections/report/ReportItemCard.tsx @@ -0,0 +1,161 @@ +/** + * — one inspection item as the client reads it: label, rating + * pill, the non-rich value, notes, defects, the recommendation + estimate, the + * attached repair items, the photo grid, and the "add to repair request" + * checkbox. + * + * Extracted verbatim from 's section loop. It stays presentational: + * the media predicate (`mediaVisible`) and the tile renderer (`renderMediaTile`) + * are threaded in, because both close over the report-wide failed-photo Set and + * the lightbox, which belong to the report and not to any one item. + * + * lint:ds — only `ih-*` design tokens; raw Tailwind colors are forbidden. + */ +import type { ReactNode } from "react"; +import { m } from "~/paraglide/messages"; +import { itemDrivesSummary } from "~/lib/report-helpers"; +import { ReportDefectCard } from "./ReportDefectCard"; +import { ITEM_PHOTO_GRID_CLASS, PRINT_CARD_CLASS, type ReportItem, type ReportPhoto } from "./types"; + +export interface ReportItemCardProps { + item: ReportItem; + showEstimates: boolean; + /** Commercial PCA Phase P — false in 'appendix' photoMode: the body stays + * text-only and every photo moves to the end-of-report Appendix B. */ + showPhotos: boolean; + mediaVisible: (p: ReportPhoto) => boolean; + renderMediaTile: (photo: ReportPhoto, alt: string, idx: number) => ReactNode; + selectedForRepair: boolean; + onToggleRepairItem: (itemId: string) => void; +} + +export function ReportItemCard({ + item, + showEstimates, + showPhotos, + mediaVisible, + renderMediaTile, + selectedForRepair, + onToggleRepairItem, +}: ReportItemCardProps) { + return ( +
+
+
+

+ {item.label} +

+ {item.ratingLabel && ( + + {item.ratingLabel} + + )} +
+ + {/* Non-rich item value */} + {item.type && + item.type !== "rich" && + item.value !== undefined && + item.value !== null && + item.value !== "" && ( +

+ + {item.type} + + {Array.isArray(item.value) + ? (item.value as unknown[]).join(" · ") + : item.type === "boolean" + ? (item.value as boolean) + ? "Yes" + : "No" + : String(item.value)} + {item.unit && ( + + {item.unit} + + )} +

+ )} + + {item.notes && ( +

+ {item.notes} +

+ )} + + {/* FE-3/B-20 — findings: included canned + custom defects with their + own photos. Previously the viewer rendered neither (field-authored + defects never appeared in the published report at all). */} + + + {item.recommendation && ( +
+ + {m.report_view_recommend({ value: item.recommendation })} + + {showEstimates && + (item.estimateMin != null || item.estimateMax != null) && ( + + {m.report_view_estimated_cost_label()} $ + {item.estimateMin?.toLocaleString() ?? "?"} - $ + {item.estimateMax?.toLocaleString() ?? "?"} + + )} +
+ )} + + {(item.repairItems?.length ?? 0) > 0 && ( +
+ {item.repairItems!.map((ri, i) => ( +
+ {ri.summary} + {ri.contractorType && ( + {ri.contractorType} + )} + {showEstimates && (ri.estimateMin != null || ri.estimateMax != null) && ( + + ${ri.estimateMin?.toLocaleString() ?? "?"} – ${ri.estimateMax?.toLocaleString() ?? "?"} + + )} +
+ ))} +
+ )} + + {showPhotos && item.photos.filter(mediaVisible).length > 0 && ( +
+ {item.photos + .filter(mediaVisible) + .map((photo, idx) => renderMediaTile(photo, `${item.label} — photo ${idx + 1}`, idx))} +
+ )} + + {itemDrivesSummary(item) && ( + + )} +
+
+ ); +} diff --git a/app/components/portal/sections/report/ReportSectionBlock.tsx b/app/components/portal/sections/report/ReportSectionBlock.tsx new file mode 100644 index 000000000..920559978 --- /dev/null +++ b/app/components/portal/sections/report/ReportSectionBlock.tsx @@ -0,0 +1,119 @@ +/** + * — one section of the report body: its numbered heading, + * the item cards under it, the collapsed summary card that replaces them in + * "summary" filter mode, and the section disclaimer. + * + * These four belong together because the active `filter` decides among them: + * "defects" drops empty sections entirely, "summary" swaps the item list for a + * single count card AND suppresses the disclaimer. Reading any one of them + * without the others tells you the wrong thing about what the client sees. + * + * Extracted verbatim from 's `filteredSections.map`. Presentational + * — the report owns the filter, the failed-photo Set and the repair selection. + * + * lint:ds — only `ih-*` design tokens; raw Tailwind colors are forbidden. + */ +import type { ReactNode } from "react"; +import { m } from "~/paraglide/messages"; +import { getSectionIcon } from "~/lib/report-helpers"; +import { ReportItemCard } from "./ReportItemCard"; +import { + PRINT_SECTION_HEADING_CLASS, + REPORT_HEADING_STYLE, + type FilterKey, + type ReportPhoto, + type ReportSection, +} from "./types"; + +export interface ReportSectionBlockProps { + section: ReportSection; + /** Zero-based position in the FILTERED list — the heading numbers off this. */ + sectionIdx: number; + filter: FilterKey; + showEstimates: boolean; + showPhotos: boolean; + mediaVisible: (p: ReportPhoto) => boolean; + renderMediaTile: (photo: ReportPhoto, alt: string, idx: number) => ReactNode; + repairItems: Record; + onToggleRepairItem: (itemId: string) => void; +} + +export function ReportSectionBlock({ + section, + sectionIdx, + filter, + showEstimates, + showPhotos, + mediaVisible, + renderMediaTile, + repairItems, + onToggleRepairItem, +}: ReportSectionBlockProps) { + if (filter === "defects" && section.items.length === 0) return null; + return ( +
+
+ {getSectionIcon(section.title)} +

+ + {sectionIdx + 1} - + + {section.title} +

+
+ + {m.report_view_section_items({ count: section.items.length })} + +
+ + {/* Items (hidden in summary mode) */} + {filter !== "summary" && ( +
+ {section.items.map((item) => ( + + ))} +
+ )} + + {/* Summary card */} + {filter === "summary" && ( +
+
+ + {m.report_view_items_inspected({ count: section.items.length })} + + 0 ? "#f43f5e" : "#22c55e", + }} + > + {section.defectCount > 0 + ? m.report_view_defect_count({ count: section.defectCount, plural: section.defectCount > 1 ? "s" : "" }) + : m.report_view_all_clear()} + +
+
+ )} + + {/* Disclaimer */} + {section.disclaimerText && filter !== "summary" && ( +
+
+ {m.report_view_disclaimer()} +
+

{section.disclaimerText}

+
+ )} +
+ ); +} diff --git a/app/components/portal/sections/report/types.ts b/app/components/portal/sections/report/types.ts index 99b80017e..83dc2d1e8 100644 --- a/app/components/portal/sections/report/types.ts +++ b/app/components/portal/sections/report/types.ts @@ -136,6 +136,11 @@ export const ITEM_PHOTO_GRID_CLASS = /** CF Images thumbnail width: smaller in print to keep the PDF lean. */ export const printThumbWidth = (isPrint: boolean): number => (isPrint ? 480 : 800); +/** Report heading typography — driven by the resolved profile's `--report-*` + * vars (Report Style Presets). Shared by the report title and every section + * heading, so a preset can never restyle one of them and miss the other. */ +export const REPORT_HEADING_STYLE = { fontFamily: "var(--report-heading-font)", fontWeight: "var(--report-heading-weight)" as unknown as number, letterSpacing: "var(--report-heading-spacing)", textTransform: "var(--report-heading-transform)" as unknown as "none" }; + export interface ReportSignature { signatureBase64: string | null; signedAt: number | null; // epoch ms From f2cfd048279a021d2ec95dbc3c1df0614e0fcb41 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 15:47:15 +0800 Subject: [PATCH 43/77] refactor(report): masthead, cover, summary row and export bar leave ReportView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 643 -> 480. Four blocks of front matter come out, each one a thing a reader already asks about by name. Pure move: markup, class strings and gate expressions are byte-identical. `ReportHeader` — who produced this report, for what property, when, and what the reader can do with it. It also carries the two gates that are easy to get backwards: `standalone` decides whether the big ADDRESS title renders (the Hub already shows the address above it, so inline mode would duplicate it, while the inspector/date line stays in both modes because it is content, not chrome), and `hideClientActions` drops an agent's transaction affordances while keeping Print. `ReportExportBar` — the fixed bottom-right cluster: cost export, Word export, Download PDF. One unit because they share one gate (owner-preview AND a commercial tier, plus cost rows for the first) and one failure story (the shared Browser-Rendering rate-limit hint). The reason WordExportButton is mounted-when-gated rather than always-mounted-and-hidden — it calls useFetcher and ReportView renders router-less in many unit tests — travels with it. `ReportCoverPhoto` — the cover image and its "photo was deleted after publish" placeholder. `coverFailed` moves with them: nothing else on the report reads it, and the point of the pair is that the failure is handled as React state rather than by hiding the section. `ReportSummaryStats` — the at-a-glance row. The per-rating tally goes with its render, because the ordering rule (severity bucket, then first appearance) is only meaningful as the reading order of these cards. The `pca-summary` anchor travels with the block it names. Raw-source specs: `report-card-stack.buttons` and `report-card-stack.summary-dynamic` now read the same concatenated fileset that `report-view.image-robustness` already did, and image-robustness gains ReportCoverPhoto. Verified load-bearing rather than assumed: eight of the nine asserted markers are no longer in ReportView.tsx at all, so those specs would have gone red without the update. One assertion changed shape — the FAB's onClick is now two ends (`onDownload={downloadPdf}` in ReportView, `onClick={onDownload}` in the bar), asserted the same way image-robustness already verifies onPhotoFailed across a component boundary. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- app/components/portal/sections/ReportView.tsx | 235 +++--------------- .../report-card-stack.buttons.test.ts | 58 +++-- .../report-card-stack.summary-dynamic.test.ts | 15 +- .../report-view.image-robustness.test.ts | 1 + .../sections/report/ReportCoverPhoto.tsx | 63 +++++ .../sections/report/ReportExportBar.tsx | 77 ++++++ .../portal/sections/report/ReportHeader.tsx | 128 ++++++++++ .../sections/report/ReportSummaryStats.tsx | 54 ++++ 8 files changed, 406 insertions(+), 225 deletions(-) create mode 100644 app/components/portal/sections/report/ReportCoverPhoto.tsx create mode 100644 app/components/portal/sections/report/ReportExportBar.tsx create mode 100644 app/components/portal/sections/report/ReportHeader.tsx create mode 100644 app/components/portal/sections/report/ReportSummaryStats.tsx diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx index 2cb02c2b4..b9596ea3c 100644 --- a/app/components/portal/sections/ReportView.tsx +++ b/app/components/portal/sections/ReportView.tsx @@ -19,16 +19,18 @@ */ import { useState } from "react"; import { m } from "~/paraglide/messages"; -import { usePdfExport, pdfActionLabel, pdfBusyHint } from "~/hooks/usePdfExport"; -import { brandFormat, brandTokens } from "~/lib/brand"; +import { usePdfExport } from "~/hooks/usePdfExport"; +import { brandTokens } from "~/lib/brand"; import { presetTokens } from "~/lib/report-style/preset-tokens"; -import { formatInspectionDateTime } from "~/lib/format-date"; import { ErrorState } from "~/components/ErrorState"; import { itemDrivesSummary } from "~/lib/report-helpers"; import { ReportMediaTile } from "./report/ReportMediaTile"; -import { CredentialBadges } from "./report/CredentialBadges"; import { badgeUrl } from "../../../../server/lib/media/badge-variant"; import { primaryBadgeOf } from "../../../../server/lib/credentials/primary"; +import { ReportExportBar } from "./report/ReportExportBar"; +import { ReportHeader } from "./report/ReportHeader"; +import { ReportCoverPhoto } from "./report/ReportCoverPhoto"; +import { ReportSummaryStats } from "./report/ReportSummaryStats"; import { ReportSectionBlock } from "./report/ReportSectionBlock"; import { PhotoAppendix } from "./report/PhotoAppendix"; import { ReportSignatureBlock } from "./report/ReportSignatureBlock"; @@ -39,14 +41,10 @@ import { PcaSkeleton } from "./report/PcaSkeleton"; import { ReportToc } from "./report/ReportToc"; import { PerUnitReportBlock } from "./report/PerUnitReportBlock"; import { CostTables } from "./report/CostTables"; -import { WordExportButton } from "./report/WordExportButton"; -import { CostExportButtons } from "~/components/CostExportButtons"; -import { - PRINT_CARD_CLASS, - REPORT_HEADING_STYLE, - type ReportPhoto, - type FilterKey, - type ReportLoaderResult, +import type { + ReportPhoto, + FilterKey, + ReportLoaderResult, } from "./report/types"; /* ------------------------------------------------------------------ */ @@ -172,27 +170,6 @@ export function reportViewProps( }; } -/* ------------------------------------------------------------------ */ -/* Image fallbacks (Plan 1 / N1) */ -/* ------------------------------------------------------------------ */ - -/** - * Restrained fallback shown in place of the report cover photo when the - * underlying image fails to load (e.g. the photo was removed after the - * report was published). We render a calm panel rather than hiding the - * cover section, so the report never looks half-broken to the client. - */ -function CoverPhotoPlaceholder() { - return ( -
- - {m.report_view_cover_unavailable()} -
- ); -} - /** * React key for a media tile. Videos key on their stream/media id (stable across * reorders); photos key on their storage key. Pulled out of the JSX because the @@ -234,7 +211,6 @@ export function ReportView(props: ReportViewProps) { const [repairItems, setRepairItems] = useState>({}); // Browser Rendering rate-limit UX (shared across every BR-backed PDF surface). const pdf = usePdfExport(); - const [coverFailed, setCoverFailed] = useState(false); // Photo keys whose thumbnail failed to load. A failed thumbnail is collapsed // (rendered as null) rather than showing the browser's broken-image glyph, @@ -267,26 +243,6 @@ export function ReportView(props: ReportViewProps) { /** A media entry is "visible" when it is a video OR a photo whose thumb hasn't failed. */ const mediaVisible = (p: ReportPhoto) => p.media?.kind === "video-player" || p.media?.kind === "video-poster" || p.media?.kind === "r2-video-player" || p.media?.kind === "r2-video-poster" || !failedPhotos.has(p.key); - // Dynamic rating summary — derived from THIS inspection's own rating system - // (Spectora-style) instead of fixed Satisfactory/Monitor/Defects buckets. - // Tally items by their rating level and render one card per level present, - // using the level's own label + color, ordered good→bad by severity bucket. - const BUCKET_RANK: Record = { satisfactory: 0, monitor: 1, defect: 2, other: 3 }; - const ratingTally = new Map(); - let seenOrder = 0; - for (const it of data.sections.flatMap((s) => s.items)) { - if (!it.rating) continue; - const ex = ratingTally.get(it.rating); - if (ex) ex.count++; - else ratingTally.set(it.rating, { label: it.ratingLabel ?? it.rating, color: it.ratingColor, bucket: it.severityBucket, count: 1, seen: seenOrder++ }); - } - const summaryCards: Array<{ label: string; value: number; color: string | null }> = [ - { label: m.report_view_stat_total(), value: data.stats.total, color: null }, - ...[...ratingTally.values()] - .sort((a, b) => (BUCKET_RANK[a.bucket] ?? 9) - (BUCKET_RANK[b.bucket] ?? 9) || a.seen - b.seen) - .map((l) => ({ label: l.label, value: l.count, color: l.color })), - ]; - const downloadPdf = () => { const url = urlToken ? `/api/public/report/${tenant}/${id}/pdf?type=full&token=${encodeURIComponent(urlToken)}` @@ -365,154 +321,35 @@ export function ReportView(props: ReportViewProps) { return (
- {/* Download PDF FAB + Export to Word (Commercial PCA Phase W Task 6 — - owner-only, commercial reports only; the public token viewer never - has ownerPreview true, and `` is rendered standalone in - plenty of router-less unit tests, so — which - calls useFetcher() and therefore requires a data-router context — - is only mounted into the tree at all when the gate is satisfied, - rather than always-mounted-but-internally-hidden. */} -
- {/* Cost export (Commercial PCA) — owner-preview only, commercial reports - with at least one cost table row. Public token viewers never have - ownerPreview, residential reports have no reportTier, and reports - with zero cost items have no costTables — so all three are hidden. */} - {Boolean(data.ownerPreview) && Boolean(data.reportTier) && data.costTables ? ( - - ) : null} - {Boolean(data.ownerPreview) && Boolean(data.reportTier) ? ( - - ) : null} -
- {pdf.error || pdf.generating ? ( -
- {pdf.error ?? pdfBusyHint()} -
- ) : null} - -
-
+ - {/* Header */} -
-
-
- {data.brand.logoUrl ? ( - {data.brand.companyName - ) : ( -
- - - -
- )} - - {data.brand.companyName ? m.report_view_cert_with_company({ company: data.brand.companyName }) : m.report_view_cert()} - -
-
- {/* IA-68 — the "View Repair List" button pointed at - /inspections/:id/repair-list, a page route that does not exist - (only the API route does), so it 404'd. The "Build repair - request" button below already reaches the real repair capability; - the dead affordance is removed rather than pointed somewhere new. */} - {!data.hideClientActions && data.enableCustomerRepairExport && ( - - {m.report_view_build_repair()} - - )} - - {!data.hideClientActions && ( - - )} -
-
- {/* Big property-ADDRESS title — standalone only. Inline in the Hub the - page header already shows the address + date, so rendering it again - here would duplicate the address. The inspector/date cert line below - stays in both modes (functional, not chrome). */} - {standalone && ( -

- {data.address} -

- )} -

- {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 && ( - - )} -
+ setRepairPanel(!repairPanel)} + /> - {/* Cover photo (DB-16) — the inspector-chosen report cover image. On load - failure (e.g. the photo was removed after publish) we swap in a restrained - placeholder rather than hiding the section, so the report never looks - broken to the client (Plan 1 / N1). */} - {data.coverPhotoUrl && ( -
- {coverFailed ? ( - - ) : ( - {`Cover setCoverFailed(true)} - /> - )} -
- )} + - {/* Stats — Commercial PCA Phase O: this at-a-glance block is the report's - "PCA Summary" front-matter page (registry id `pca-summary`), so it - carries that anchor for the TOC / PDF bookmarks. It renders - unconditionally (data.stats always present), so the anchor is never - dangling regardless of tier. */} -
-
- {summaryCards.map((s) => ( -
-
{s.value}
-
- {s.label} -
-
- ))} -
-
+ {/* Building Profile — Commercial PCA Phase F */}
diff --git a/app/components/portal/sections/report-card-stack.buttons.test.ts b/app/components/portal/sections/report-card-stack.buttons.test.ts index ce95b365a..fd9073872 100644 --- a/app/components/portal/sections/report-card-stack.buttons.test.ts +++ b/app/components/portal/sections/report-card-stack.buttons.test.ts @@ -21,16 +21,28 @@ import { describe, it, expect } from 'vitest'; +// The top bar and the FAB moved into the co-located and +// ; ReportView keeps the URL construction and the handler. +// Concatenate the three so the markers are found wherever they now live — +// ReportView FIRST, so the `const downloadPdf` window slice below stays inside +// it. (Same fileset approach as report-view.image-robustness.spec.ts.) +async function source(): Promise { + const mods = await Promise.all([ + import('~/components/portal/sections/ReportView?raw'), + import('~/components/portal/sections/report/ReportHeader?raw'), + import('~/components/portal/sections/report/ReportExportBar?raw'), + ]); + return mods.map((m) => (m as unknown as { default: string }).default).join('\n'); +} + describe('report-card-stack buttons (Task 9)', () => { it('loads the module source', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); expect(text.length).toBeGreaterThan(0); }); it('top-bar toolbar button reads "Print" (not "PDF")', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); // The top-bar toolbar sits inside the `flex items-center gap-2 print:hidden` // container. Confirm the "Print" label is rendered via the i18n message @@ -39,8 +51,7 @@ describe('report-card-stack buttons (Task 9)', () => { }); it('top-bar toolbar button no longer reads ">PDF<"', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); // The label "PDF" as a JSX text node (between tags) must be gone from the // top-bar button. Note: "Export PDF" (repair panel) and "Download PDF" @@ -50,8 +61,7 @@ describe('report-card-stack buttons (Task 9)', () => { }); it('FAB button still reads "Download PDF" as the default label', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); expect(text).toContain('Download PDF'); }); @@ -61,7 +71,7 @@ describe('report-card-stack buttons (Task 9)', () => { // identically). ReportView now delegates to it; the impl assertions follow the // logic into the hook, while ReportView keeps the URL construction + wiring. it('FAB label is produced by the shared hook ("Generating…" / "Retry in Ns")', async () => { - const view = ((await import('~/components/portal/sections/ReportView?raw')) as unknown as { default: string }).default; + const view = await source(); const hook = ((await import('~/hooks/usePdfExport?raw')) as unknown as { default: string }).default; expect(view).toContain('pdfActionLabel(pdf, m.report_view_download_pdf())'); @@ -72,7 +82,7 @@ describe('report-card-stack buttons (Task 9)', () => { }); it('generating state lives in the shared usePdfExport hook', async () => { - const view = ((await import('~/components/portal/sections/ReportView?raw')) as unknown as { default: string }).default; + const view = await source(); const hook = ((await import('~/hooks/usePdfExport?raw')) as unknown as { default: string }).default; expect(view).toContain('usePdfExport()'); @@ -81,7 +91,7 @@ describe('report-card-stack buttons (Task 9)', () => { }); it('downloadPdf delegates to the hook, which uses fetch (not window.print)', async () => { - const view = ((await import('~/components/portal/sections/ReportView?raw')) as unknown as { default: string }).default; + const view = await source(); const hook = ((await import('~/hooks/usePdfExport?raw')) as unknown as { default: string }).default; expect(view).toContain('downloadPdf'); @@ -98,33 +108,32 @@ describe('report-card-stack buttons (Task 9)', () => { }); it('owner URL path: /api/inspections/:id/pdf', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); expect(text).toContain('/api/inspections/'); expect(text).toContain('/pdf?type=full'); }); it('client URL path: /api/public/report/:tenant/:id/pdf with token', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); expect(text).toContain('/api/public/report/'); expect(text).toContain('token'); }); it('FAB onClick is downloadPdf (not window.print)', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; - - // The fixed bottom-6 right-6 FAB must reference downloadPdf in its onClick. - // We check that "onClick={downloadPdf}" appears in the source. - expect(text).toContain('onClick={downloadPdf}'); + const text = await source(); + + // Post-split the wiring has two ends: ReportView hands its fetch→blob + // handler to the bar, and the bar's button calls exactly that prop. Both + // are asserted, the same way the image-robustness spec verifies + // onPhotoFailed across the ReportView/ReportMediaTile boundary. + expect(text).toContain('onDownload={downloadPdf}'); + expect(text).toContain('onClick={onDownload}'); }); it('no native alert/confirm/prompt in downloadPdf handler', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); // Extract the downloadPdf function body to check for native dialogs. const fnStart = text.indexOf('const downloadPdf'); @@ -137,8 +146,7 @@ describe('report-card-stack buttons (Task 9)', () => { }); it('top-bar print button still calls window.print()', async () => { - const src = await import('~/components/portal/sections/ReportView?raw'); - const text = (src as unknown as { default: string }).default; + const text = await source(); // window.print() must still appear (for the top-bar Print button and // the repair-panel "Export PDF" button). diff --git a/app/components/portal/sections/report-card-stack.summary-dynamic.test.ts b/app/components/portal/sections/report-card-stack.summary-dynamic.test.ts index 05ca19bfc..f3e65c24c 100644 --- a/app/components/portal/sections/report-card-stack.summary-dynamic.test.ts +++ b/app/components/portal/sections/report-card-stack.summary-dynamic.test.ts @@ -2,11 +2,24 @@ // inspection's own rating system (Spectora-style), not the previous hardcoded // "Satisfactory / Monitor / Defects" buckets. Raw-source assertions mirror the // existing report-card-stack web tests. +// +// The tally + card row now live in the co-located (the +// derivation moved with its render). Both modules are concatenated so the +// positive markers are found and — more importantly — the negative assertions +// cover the whole surface rather than only the file the block used to be in. import { describe, it, expect } from 'vitest'; +async function source(): Promise { + const mods = await Promise.all([ + import('~/components/portal/sections/ReportView?raw'), + import('~/components/portal/sections/report/ReportSummaryStats?raw'), + ]); + return mods.map((m) => (m as unknown as { default: string }).default).join('\n'); +} + describe('report-card-stack dynamic rating summary', () => { it('tallies items by their rating level and renders per-level cards', async () => { - const src = ((await import('~/components/portal/sections/ReportView?raw')) as { default: string }).default; + const src = await source(); // Dynamic per-level tally using each item's own rating label/color. expect(src).toContain('ratingTally'); expect(src).toContain('summaryCards'); diff --git a/app/components/portal/sections/report-view.image-robustness.test.ts b/app/components/portal/sections/report-view.image-robustness.test.ts index 6d001736d..468e60bd2 100644 --- a/app/components/portal/sections/report-view.image-robustness.test.ts +++ b/app/components/portal/sections/report-view.image-robustness.test.ts @@ -28,6 +28,7 @@ async function source(): Promise { import('~/components/portal/sections/report/ReportMediaTile?raw'), import('~/components/portal/sections/report/ReportDefectCard?raw'), import('~/components/portal/sections/report/ReportItemCard?raw'), + import('~/components/portal/sections/report/ReportCoverPhoto?raw'), ]); return mods.map((m) => (m as unknown as { default: string }).default).join('\n'); } diff --git a/app/components/portal/sections/report/ReportCoverPhoto.tsx b/app/components/portal/sections/report/ReportCoverPhoto.tsx new file mode 100644 index 000000000..45f950a1e --- /dev/null +++ b/app/components/portal/sections/report/ReportCoverPhoto.tsx @@ -0,0 +1,63 @@ +/** + * — the inspector-chosen cover image (DB-16) and its + * failure fallback (Plan 1 / N1). + * + * The two belong in one component because the failure is the whole point: when + * the image cannot load — typically because the photo was deleted after the + * report was published — we swap in a restrained placeholder rather than + * hiding the section, so the report never looks half-broken to the client. The + * `coverFailed` flag is React state, never a DOM mutation, and it is local to + * this block: nothing else on the report reads it. + * + * lint:ds — only `ih-*` design tokens; raw Tailwind colors are forbidden. + */ +import { useState } from "react"; +import { m } from "~/paraglide/messages"; + +/** + * Restrained fallback shown in place of the report cover photo when the + * underlying image fails to load (e.g. the photo was removed after the + * report was published). We render a calm panel rather than hiding the + * cover section, so the report never looks half-broken to the client. + */ +function CoverPhotoPlaceholder() { + return ( +
+ + {m.report_view_cover_unavailable()} +
+ ); +} + +export function ReportCoverPhoto({ + coverPhotoUrl, + address, + printMode, +}: { + coverPhotoUrl: string | null; + address: string; + printMode: boolean; +}) { + const [coverFailed, setCoverFailed] = useState(false); + if (!coverPhotoUrl) return null; + return ( +
+ {coverFailed ? ( + + ) : ( + {`Cover setCoverFailed(true)} + /> + )} +
+ ); +} diff --git a/app/components/portal/sections/report/ReportExportBar.tsx b/app/components/portal/sections/report/ReportExportBar.tsx new file mode 100644 index 000000000..27042e023 --- /dev/null +++ b/app/components/portal/sections/report/ReportExportBar.tsx @@ -0,0 +1,77 @@ +/** + * — the fixed bottom-right cluster of "get this report out of + * the browser" actions: the Commercial-PCA cost exports, Export to Word, and + * the Download PDF button with the shared Browser-Rendering rate-limit hint. + * + * Grouped because they share one gate and one failure story. Cost export and + * Word export are owner-preview + commercial only — a public token viewer never + * has `ownerPreview`, a residential report has no `reportTier`, and a report + * with no cost rows has no `costTables`, so all three conditions hide them. + * calls useFetcher() and therefore needs a data-router + * context, and is rendered standalone in plenty of router-less + * unit tests, so it is only MOUNTED when the gate is satisfied rather than + * always-mounted-but-internally-hidden. + * + * The PDF button is driven by the shared usePdfExport state, which the report + * owns (the same hook instance also backs any other trigger on the page). + * + * lint:ds — only `ih-*` design tokens; raw Tailwind colors are forbidden. + */ +import { m } from "~/paraglide/messages"; +import { pdfActionLabel, pdfBusyHint, type PdfExportState } from "~/hooks/usePdfExport"; +import { CostExportButtons } from "~/components/CostExportButtons"; +import { WordExportButton } from "./WordExportButton"; + +export interface ReportExportBarProps { + inspectionId: string; + ownerPreview: boolean; + /** Resolved report tier; null on residential reports. */ + reportTier: string | null; + /** Whether the report carries at least one cost-table row. */ + hasCostTables: boolean; + pdf: PdfExportState; + /** The report's own fetch→blob download handler. */ + onDownload: () => void; +} + +export function ReportExportBar({ + inspectionId, + ownerPreview, + reportTier, + hasCostTables, + pdf, + onDownload, +}: ReportExportBarProps) { + const commercialOwner = Boolean(ownerPreview) && Boolean(reportTier); + return ( +
+ {commercialOwner && hasCostTables ? ( + + ) : null} + {commercialOwner ? ( + + ) : null} +
+ {pdf.error || pdf.generating ? ( +
+ {pdf.error ?? pdfBusyHint()} +
+ ) : null} + +
+
+ ); +} diff --git a/app/components/portal/sections/report/ReportHeader.tsx b/app/components/portal/sections/report/ReportHeader.tsx new file mode 100644 index 000000000..aa33ebaff --- /dev/null +++ b/app/components/portal/sections/report/ReportHeader.tsx @@ -0,0 +1,128 @@ +/** + * — the report masthead: the company mark and certification + * line, the top-bar reader actions (build a repair request, Print, open the + * in-report Repair Request panel), the property-address title, the + * inspector/date line and the inspector's credential badges. + * + * One unit because it is what the client sees before scrolling: who produced + * this report, for what property, when, and what they can do with it. + * + * Two gates run through it and are easy to get backwards: + * - `standalone` — the big ADDRESS title renders ONLY on the standalone + * `/report-view/...` page. Inline in the Hub the page header already shows + * the address, so repeating it here duplicates it. The inspector/date cert + * line stays in BOTH modes: it is content, not chrome. + * - `hideClientActions` — an AGENT viewing the report loses the client's + * transaction affordances (build-repair link, Repair Request toggle) but + * keeps the report-viewing ones (Print). + * + * lint:ds — only `ih-*` design tokens; raw Tailwind colors are forbidden. + */ +import { m } from "~/paraglide/messages"; +import { brandFormat, type TenantBrand } from "~/lib/brand"; +import { formatInspectionDateTime } from "~/lib/format-date"; +import { CredentialBadges, type CredentialItem } from "./CredentialBadges"; +import { REPORT_HEADING_STYLE } from "./types"; + +export interface ReportHeaderProps { + brand: TenantBrand; + tenant: string; + reportId: string; + /** Public access token (?token=), for token-scoped action links. */ + token?: string; + /** Standalone page → render the big property-address title. */ + standalone: boolean; + address: string; + date: string; + /** Tenant timezone (IANA) the displayed date is anchored to. */ + reportTimeZone: string; + inspectorName: string | null; + inspectorCredentials?: CredentialItem[]; + /** Resolved style-profile badge layout; defaults to the strip. */ + badgeLayout?: "strip" | "inline"; + /** Agent view: drop the client's transaction affordances. */ + hideClientActions?: boolean; + enableCustomerRepairExport: boolean; + onToggleRepairPanel: () => void; +} + +export function ReportHeader({ + brand, + tenant, + reportId, + token, + standalone, + address, + date, + reportTimeZone, + inspectorName, + inspectorCredentials, + badgeLayout, + hideClientActions, + enableCustomerRepairExport, + onToggleRepairPanel, +}: ReportHeaderProps) { + return ( +
+
+
+ {brand.logoUrl ? ( + {brand.companyName + ) : ( +
+ + + +
+ )} + + {brand.companyName ? m.report_view_cert_with_company({ company: brand.companyName }) : m.report_view_cert()} + +
+
+
+ {standalone && ( +

+ {address} +

+ )} +

+ {date ? `${formatInspectionDateTime(date, undefined, reportTimeZone, brandFormat(brand))} · ` : ""} + {m.report_view_inspector({ name: inspectorName || m.report_view_na() })} +

+ {inspectorCredentials && inspectorCredentials.length > 0 && ( + + )} +
+ ); +} diff --git a/app/components/portal/sections/report/ReportSummaryStats.tsx b/app/components/portal/sections/report/ReportSummaryStats.tsx new file mode 100644 index 000000000..5a71aa5ed --- /dev/null +++ b/app/components/portal/sections/report/ReportSummaryStats.tsx @@ -0,0 +1,54 @@ +/** + * — the at-a-glance card row at the top of the report. + * + * The cards are derived from THIS inspection's own rating system + * (Spectora-style), not from fixed Satisfactory/Monitor/Defects buckets: items + * are tallied by their rating level and one card is rendered per level that + * actually occurs, using that level's own label and colour, ordered good→bad by + * severity bucket and then by first appearance. The derivation lives with the + * render because the ordering rule is only meaningful as the reading order of + * these cards. + * + * Commercial PCA Phase O — this block is the report's "PCA Summary" + * front-matter page (registry id `pca-summary`), so it carries that anchor for + * the TOC / PDF bookmarks. It renders unconditionally, so the anchor is never + * dangling regardless of tier. + * + * lint:ds — only `ih-*` design tokens; raw Tailwind colors are forbidden. + */ +import { m } from "~/paraglide/messages"; +import { PRINT_CARD_CLASS, type ReportSection } from "./types"; + +const BUCKET_RANK: Record = { satisfactory: 0, monitor: 1, defect: 2, other: 3 }; + +export function ReportSummaryStats({ sections, total }: { sections: ReportSection[]; total: number }) { + const ratingTally = new Map(); + let seenOrder = 0; + for (const it of sections.flatMap((s) => s.items)) { + if (!it.rating) continue; + const ex = ratingTally.get(it.rating); + if (ex) ex.count++; + else ratingTally.set(it.rating, { label: it.ratingLabel ?? it.rating, color: it.ratingColor, bucket: it.severityBucket, count: 1, seen: seenOrder++ }); + } + const summaryCards: Array<{ label: string; value: number; color: string | null }> = [ + { label: m.report_view_stat_total(), value: total, color: null }, + ...[...ratingTally.values()] + .sort((a, b) => (BUCKET_RANK[a.bucket] ?? 9) - (BUCKET_RANK[b.bucket] ?? 9) || a.seen - b.seen) + .map((l) => ({ label: l.label, value: l.count, color: l.color })), + ]; + + return ( +
+
+ {summaryCards.map((s) => ( +
+
{s.value}
+
+ {s.label} +
+
+ ))} +
+
+ ); +} From 2099f49fcd211fbd938f8070fedddc76328dc82f Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 15:53:59 +0800 Subject: [PATCH 44/77] refactor(report): the props contract and the dead-link page are not the render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 480 -> 352, and ReportView's baseline entry is REMOVED rather than tightened. 813 lines of wall becomes 48 lines of headroom under the 400 limit, which is what #20 and #21-#26 needed. `report/report-view-props.ts` — the ReportViewProps contract and the pure `reportViewProps()` adapter. This is the report's INPUT boundary, not part of its render: three callers (the standalone route, the agent view of that same route, and the inline Hub slot) each hold a loader payload plus a few route params and all three go through this one function. Having it importable without React is what already lets `report-view.test.ts` exercise it directly; that spec keeps importing it from ReportView, which re-exports both symbols. `report/ReportUnavailable.tsx` — the three outcomes that render INSTEAD of a report: not published, dead link, generic failure. They belong together because choosing between them is the entire logic, and the IA-36 reasoning for why 410 and 404 deliberately render the same page travels with the branch it explains rather than sitting in the middle of a component that otherwise draws a report. ReportView's own header comment now says what it keeps and why: the report-wide state (filter, lightbox, repair selection, failed-photo Set), the media-tile renderer those close over, and the composition order. Everything a reader looks up by name is a file next door. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QFkgupms2h36Mmf11ojFan --- app/components/portal/sections/ReportView.tsx | 168 +++--------------- .../sections/report/ReportUnavailable.tsx | 76 ++++++++ .../sections/report/report-view-props.ts | 102 +++++++++++ scripts/file-size-baseline.json | 1 - 4 files changed, 198 insertions(+), 149 deletions(-) create mode 100644 app/components/portal/sections/report/ReportUnavailable.tsx create mode 100644 app/components/portal/sections/report/report-view-props.ts diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx index b9596ea3c..1393cf17f 100644 --- a/app/components/portal/sections/ReportView.tsx +++ b/app/components/portal/sections/ReportView.tsx @@ -10,10 +10,17 @@ * The agent report (?view=agent) reuses the SAME standalone route, so it is * covered automatically by the wrapper — there is no separate agent component. * - * The presentational sub-blocks (media tile / defect card / signature / - * verification / repair panel) live colocated in ./report/*; the pure helpers - * live in ~/lib/report-helpers. This file composes them and owns the report's - * interactive state (filter, lightbox, repair selection, failed-photo Set). + * The presentational blocks (masthead, cover, summary row, export bar, one + * block per section and one card per item, signature / verification / repair + * panel) live colocated in ./report/*, as do the prop contract + * (./report/report-view-props) and the shared types. The pure helpers live in + * ~/lib/report-helpers. + * + * WHAT STAYS HERE is what no single block can own: the report-wide interactive + * state (filter, lightbox, repair selection, failed-photo Set), the media-tile + * renderer those pieces close over, and the composition order of the page. + * Anything a reader would look up by name — "the item card", "the cover photo", + * "the dead-link page" — is a file next door. * * lint:ds — only `ih-*` design tokens; raw Tailwind colors are forbidden. */ @@ -22,11 +29,11 @@ import { m } from "~/paraglide/messages"; import { usePdfExport } from "~/hooks/usePdfExport"; import { brandTokens } from "~/lib/brand"; import { presetTokens } from "~/lib/report-style/preset-tokens"; -import { ErrorState } from "~/components/ErrorState"; import { itemDrivesSummary } from "~/lib/report-helpers"; import { ReportMediaTile } from "./report/ReportMediaTile"; import { badgeUrl } from "../../../../server/lib/media/badge-variant"; import { primaryBadgeOf } from "../../../../server/lib/credentials/primary"; +import { ReportUnavailable } from "./report/ReportUnavailable"; import { ReportExportBar } from "./report/ReportExportBar"; import { ReportHeader } from "./report/ReportHeader"; import { ReportCoverPhoto } from "./report/ReportCoverPhoto"; @@ -41,17 +48,15 @@ import { PcaSkeleton } from "./report/PcaSkeleton"; import { ReportToc } from "./report/ReportToc"; import { PerUnitReportBlock } from "./report/PerUnitReportBlock"; import { CostTables } from "./report/CostTables"; -import type { - ReportPhoto, - FilterKey, - ReportLoaderResult, -} from "./report/types"; +import type { ReportPhoto, FilterKey } from "./report/types"; +import type { ReportViewProps } from "./report/report-view-props"; /* ------------------------------------------------------------------ */ /* Re-exports — keep ReportView's public type/constant/helper surface */ /* identical after the structural split (route + tests import these). */ /* ------------------------------------------------------------------ */ +export { reportViewProps, type ReportViewProps } from "./report/report-view-props"; export type { ReportPhoto, ResolvedDefect, @@ -77,99 +82,6 @@ export { type VerificationBlockResult, } from "~/lib/report-helpers"; -/* ------------------------------------------------------------------ */ -/* Component props + pure adapter */ -/* ------------------------------------------------------------------ */ - -export interface ReportViewProps extends ReportLoaderResult { - /** Route params, supplied by the wrapper (not from loader payload). */ - tenant: string; - /** The inspection id (params); falls back to loader inspectionId. */ - reportId: string; - /** Public access token (?token=) used for token-scoped action links. */ - token?: string; - /** - * When true (the STANDALONE `/report-view/...` page) the component renders its - * own full-page chrome: a `min-h-screen` page background and the big property- - * ADDRESS title block. When false (default — rendered INLINE inside the Hub) - * that chrome is dropped: the Hub already supplies the page container, header - * and address, so the bare report content is rendered to avoid a double - * background and a duplicated address. The functional bits (filters, toolbar, - * Download-PDF FAB, signature/verification, lightbox) render in BOTH modes. - * Mirrors `PaymentSection`'s `showStandaloneChrome` convention. - */ - showStandaloneChrome?: boolean; - /** Spec 3: hide client-transaction affordances (repair-list / build-repair - * links + the in-report Repair Request toggle) when an AGENT is viewing the - * report via their link. Report-viewing actions (Print, Download PDF) stay. */ - hideClientActions?: boolean; -} - -/** - * Pure adapter: loader payload (+ route params) → component props. Unit-testable - * (no React / router). Defensive defaults keep it safe against partial payloads. - */ -export function reportViewProps( - data: ReportLoaderResult & { - tenant?: string; - inspectionId?: string; - token?: string; - showStandaloneChrome?: boolean; - }, -): ReportViewProps { - const reportId = data.inspectionId ?? ""; - return { - inspectionId: data.inspectionId ?? "", - address: data.address ?? "", - date: data.date ?? "", - inspectorName: data.inspectorName ?? null, - coverPhotoUrl: data.coverPhotoUrl ?? null, - stats: data.stats ?? { total: 0, satisfactory: 0, monitor: 0, defect: 0 }, - sections: data.sections ?? [], - outline: data.outline ?? [], - showEstimates: data.showEstimates ?? false, - costTables: data.costTables ?? null, - enableRepairList: data.enableRepairList ?? false, - enableCustomerRepairExport: data.enableCustomerRepairExport ?? false, - reportTimeZone: data.reportTimeZone ?? "UTC", - isDelivered: data.isDelivered ?? false, - brand: data.brand, - error: data.error ?? null, - notPublished: data.notPublished ?? false, - linkInactive: data.linkInactive ?? false, - styleProfile: data.styleProfile, - inspectorCredentials: data.inspectorCredentials, - initialFilter: data.initialFilter ?? "all", - printMode: data.printMode ?? false, - tocPages: data.tocPages, - isPublished: data.isPublished ?? false, - signature: data.signature ?? null, - verification: data.verification ?? null, - astmConformance: data.astmConformance ?? null, - reportSignoffs: data.reportSignoffs ?? [], - psq: data.psq ?? null, - documentReview: data.documentReview ?? [], - relianceText: data.relianceText ?? { userReliance: "", pointInTime: "", siteSpecific: "" }, - ownerPreview: data.ownerPreview ?? false, - baseUrl: data.baseUrl ?? "", - photoMode: data.photoMode ?? "inline", - photoAppendix: data.photoAppendix ?? [], - propertyType: data.propertyType ?? null, - commercialSubtype: data.commercialSubtype ?? null, - reportTier: data.reportTier ?? null, - buildingProfile: data.buildingProfile ?? [], - pcaReport: data.pcaReport ?? null, - unitInspectionMode: data.unitInspectionMode ?? "tagged", - units: data.units ?? [], - unitConditionMatrix: data.unitConditionMatrix ?? [], - defectCountsByUnit: data.defectCountsByUnit ?? {}, - tenant: data.tenant ?? "", - reportId, - token: data.token, - showStandaloneChrome: data.showStandaloneChrome ?? false, - }; -} - /** * React key for a media tile. Videos key on their stream/media id (stable across * reorders); photos key on their storage key. Pulled out of the JSX because the @@ -251,52 +163,12 @@ export function ReportView(props: ReportViewProps) { }; if (data.error) { - if (data.notPublished) { - return ( - - ); - } - // IA-36 ⑨ — "we took this link offline" (410) and "this link names nothing" - // (404) render the SAME page on purpose. - // - // The reader cannot act on the difference and we cannot always tell them - // the truth anyway: rotating a link overwrites its row in place, so a - // recipient holding the superseded URL is indistinguishable from someone - // who mistyped one — both arrive as 404. Splitting the copy would mean - // confidently telling a legitimate client "no such report" when we in fact - // replaced their link ten minutes ago. - // - // So the page states both possibilities and names the recovery path. The - // wire keeps 410 and 404 distinct — support and the audit trail need to - // know which happened even when the reader doesn't. - const notFound = data.error === "Report not found"; - if (notFound || data.linkInactive) { - // Name the company and give a channel. "Ask your inspector" is not - // actionable to someone who received one email months ago and no longer - // remembers who sent it. - const company = data.brand?.companyName; - return ( - - ); - } return ( - ); } diff --git a/app/components/portal/sections/report/ReportUnavailable.tsx b/app/components/portal/sections/report/ReportUnavailable.tsx new file mode 100644 index 000000000..8bf5a1c0a --- /dev/null +++ b/app/components/portal/sections/report/ReportUnavailable.tsx @@ -0,0 +1,76 @@ +/** + * — what the reader sees instead of a report. + * + * Three outcomes, one place, because choosing between them is the whole logic: + * + * 1. Not published yet — the report exists but the inspector has not released + * it. Nothing is wrong; say so plainly. + * + * 2. IA-36 ⑨ — "we took this link offline" (410) and "this link names + * nothing" (404) render the SAME page on purpose. + * + * The reader cannot act on the difference and we cannot always tell them + * the truth anyway: rotating a link overwrites its row in place, so a + * recipient holding the superseded URL is indistinguishable from someone + * who mistyped one — both arrive as 404. Splitting the copy would mean + * confidently telling a legitimate client "no such report" when we in fact + * replaced their link ten minutes ago. + * + * So the page states both possibilities and names the recovery path. The + * wire keeps 410 and 404 distinct — support and the audit trail need to + * know which happened even when the reader doesn't. + * + * 3. Anything else — a generic load failure. + * + * The caller renders this INSTEAD of the report, never alongside it. + */ +import { m } from "~/paraglide/messages"; +import { ErrorState } from "~/components/ErrorState"; +import type { TenantBrand } from "~/lib/brand"; + +export interface ReportUnavailableProps { + /** The loader's error string (non-null, or the report would have rendered). */ + error: string; + notPublished: boolean; + /** The link was real but has expired or been revoked (API 410). */ + linkInactive?: boolean; + brand: TenantBrand; +} + +export function ReportUnavailable({ error, notPublished, linkInactive, brand }: ReportUnavailableProps) { + if (notPublished) { + return ( + + ); + } + const notFound = error === "Report not found"; + if (notFound || linkInactive) { + // Name the company and give a channel. "Ask your inspector" is not + // actionable to someone who received one email months ago and no longer + // remembers who sent it. + const company = brand?.companyName; + return ( + + ); + } + return ( + + ); +} diff --git a/app/components/portal/sections/report/report-view-props.ts b/app/components/portal/sections/report/report-view-props.ts new file mode 100644 index 000000000..7899883c3 --- /dev/null +++ b/app/components/portal/sections/report/report-view-props.ts @@ -0,0 +1,102 @@ +/** + * 's prop contract and the pure adapter that produces it. + * + * Kept out of the component module because this is the report's INPUT + * boundary, not part of its render: three different callers (the standalone + * `/report-view/...` route, the agent view of that same route, and the inline + * Hub slot) each hold a loader payload plus a few route params, and every one + * of them goes through `reportViewProps()`. Having it importable without + * pulling in React is what lets it be unit-tested directly. + * + * ReportView re-exports both symbols, so its public surface is unchanged. + */ +import type { ReportLoaderResult } from "./types"; + +export interface ReportViewProps extends ReportLoaderResult { + /** Route params, supplied by the wrapper (not from loader payload). */ + tenant: string; + /** The inspection id (params); falls back to loader inspectionId. */ + reportId: string; + /** Public access token (?token=) used for token-scoped action links. */ + token?: string; + /** + * When true (the STANDALONE `/report-view/...` page) the component renders its + * own full-page chrome: a `min-h-screen` page background and the big property- + * ADDRESS title block. When false (default — rendered INLINE inside the Hub) + * that chrome is dropped: the Hub already supplies the page container, header + * and address, so the bare report content is rendered to avoid a double + * background and a duplicated address. The functional bits (filters, toolbar, + * Download-PDF FAB, signature/verification, lightbox) render in BOTH modes. + * Mirrors `PaymentSection`'s `showStandaloneChrome` convention. + */ + showStandaloneChrome?: boolean; + /** Spec 3: hide client-transaction affordances (repair-list / build-repair + * links + the in-report Repair Request toggle) when an AGENT is viewing the + * report via their link. Report-viewing actions (Print, Download PDF) stay. */ + hideClientActions?: boolean; +} + +/** + * Pure adapter: loader payload (+ route params) → component props. Unit-testable + * (no React / router). Defensive defaults keep it safe against partial payloads. + */ +export function reportViewProps( + data: ReportLoaderResult & { + tenant?: string; + inspectionId?: string; + token?: string; + showStandaloneChrome?: boolean; + }, +): ReportViewProps { + const reportId = data.inspectionId ?? ""; + return { + inspectionId: data.inspectionId ?? "", + address: data.address ?? "", + date: data.date ?? "", + inspectorName: data.inspectorName ?? null, + coverPhotoUrl: data.coverPhotoUrl ?? null, + stats: data.stats ?? { total: 0, satisfactory: 0, monitor: 0, defect: 0 }, + sections: data.sections ?? [], + outline: data.outline ?? [], + showEstimates: data.showEstimates ?? false, + costTables: data.costTables ?? null, + enableRepairList: data.enableRepairList ?? false, + enableCustomerRepairExport: data.enableCustomerRepairExport ?? false, + reportTimeZone: data.reportTimeZone ?? "UTC", + isDelivered: data.isDelivered ?? false, + brand: data.brand, + error: data.error ?? null, + notPublished: data.notPublished ?? false, + linkInactive: data.linkInactive ?? false, + styleProfile: data.styleProfile, + inspectorCredentials: data.inspectorCredentials, + initialFilter: data.initialFilter ?? "all", + printMode: data.printMode ?? false, + tocPages: data.tocPages, + isPublished: data.isPublished ?? false, + signature: data.signature ?? null, + verification: data.verification ?? null, + astmConformance: data.astmConformance ?? null, + reportSignoffs: data.reportSignoffs ?? [], + psq: data.psq ?? null, + documentReview: data.documentReview ?? [], + relianceText: data.relianceText ?? { userReliance: "", pointInTime: "", siteSpecific: "" }, + ownerPreview: data.ownerPreview ?? false, + baseUrl: data.baseUrl ?? "", + photoMode: data.photoMode ?? "inline", + photoAppendix: data.photoAppendix ?? [], + propertyType: data.propertyType ?? null, + commercialSubtype: data.commercialSubtype ?? null, + reportTier: data.reportTier ?? null, + buildingProfile: data.buildingProfile ?? [], + pcaReport: data.pcaReport ?? null, + unitInspectionMode: data.unitInspectionMode ?? "tagged", + units: data.units ?? [], + unitConditionMatrix: data.unitConditionMatrix ?? [], + defectCountsByUnit: data.defectCountsByUnit ?? {}, + tenant: data.tenant ?? "", + reportId, + token: data.token, + showStandaloneChrome: data.showStandaloneChrome ?? false, + }; +} diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 8c68cb3fc..f7efd73be 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -7,7 +7,6 @@ "app/routes/inspections.tsx": 879, "server/lib/collab/results-doc.ts": 874, "server/api/sms.ts": 844, - "app/components/portal/sections/ReportView.tsx": 813, "app/routes/settings-communication.tsx": 777, "server/api/admin/admin-settings.ts": 754, "server/services/inspection.service.ts": 741, From b6700c396f16b8a19e71e9e922fabc8f0e1f9fa6 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 17:22:49 +0800 Subject: [PATCH 45/77] =?UTF-8?q?build:=20raise=20the=20app=20type-check?= =?UTF-8?q?=20heap=20=E2=80=94=208G=20no=20longer=20fits=20a=20cold=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured, not guessed. `tsc -p tsconfig.json --noEmit --extendedDiagnostics` on a fresh checkout reports 11.6M types, 36.0M instantiations and 8.17 GB against an 8192 MB cap, and dies with "Ineffective mark-compacts near heap limit" after four minutes. The same measurement at the last commit CI verified is 8.09 GB — so the tree has been sitting on the cliff edge for a while and the pay-rule routes added the 0.95% that went over. Nothing in the recent work is unusually expensive; the app-wide hono/client RPC type is simply at its limit, and the next route to be added would have done the same thing. Why nobody saw it: pre-commit and every local loop run INCREMENTALLY against a warm .tsbuildinfo, which needs far less heap and passes. Only a cold run OOMs — and cold is exactly what CI does on every fresh checkout, so the next push would have failed the typecheck-app job. 12288 matches what `lint` already asks for, so the ceiling for a heavy node process in this repo is unchanged. Cold run at the new cap: exit 0. The structural fix is not this: createApi() splits the client per module and then merges 76 of them back into one Api object that 111 files import, and the app program re-infers the whole server from source on every cold run instead of reading built declarations. Both are what Hono's own RPC guidance warns about. Filed separately. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fbfbe0b9d..0519ea203 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "gen-version": "node scripts/gen-version.js", "i18n:compile": "paraglide-js compile --project ./project.inlang --outdir ./app/paraglide --strategy cookie baseLocale --output-structure locale-modules --emit-ts-declarations", "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:app": "node --max-old-space-size=12288 ./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:idempotency && 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", From bd17615f0f76ee1f3362cd8fe82b74d8ee2af3c5 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 17:26:21 +0800 Subject: [PATCH 46/77] test(settings): the catalog spec never passed the prop #278 made required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine tests threw during render because ServicesCatalogPanel indexes payRuleMap unguarded and this spec omitted it. It merged green because co-located app/** tests are excluded from the app tsc program, so no type check sees this call site — only running test:web does. Passing {} would have silenced the crash while asserting nothing, so the summary a reader sees is pinned instead: removing the wiring turns "Pay: 1 pay rule" back into "Pay: Not set" and the test goes red. The duration assertion is scoped to its cell now that the row says "Not set" twice and means two different things. Also fixes a FAB assertion that had only ever matched a comment: the label is an i18n call, so the literal "Download PDF" is not in the source at all. Deleting the FAB would have left it green. --- .../report-card-stack.buttons.test.ts | 12 ++++- .../services/ServicesCatalogPanel.test.tsx | 53 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/components/portal/sections/report-card-stack.buttons.test.ts b/app/components/portal/sections/report-card-stack.buttons.test.ts index fd9073872..2fc3772eb 100644 --- a/app/components/portal/sections/report-card-stack.buttons.test.ts +++ b/app/components/portal/sections/report-card-stack.buttons.test.ts @@ -63,7 +63,17 @@ describe('report-card-stack buttons (Task 9)', () => { it('FAB button still reads "Download PDF" as the default label', async () => { const text = await source(); - expect(text).toContain('Download PDF'); + // This asserted `toContain('Download PDF')` for a long time, and the only + // thing it ever matched was a COMMENT — the label is an i18n call, so the + // literal string does not appear in the source at all. Deleting the FAB + // would have left it green as long as a comment survived. + // + // Two halves make the claim in the test name: the render calls the message, + // and the message says that. Neither alone is the label a reader sees. + expect(text).toContain('m.report_view_download_pdf()'); + + const en = await import('../../../../messages/en/reports.json'); + expect(en.report_view_download_pdf).toBe('Download PDF'); }); // The fetch→blob download + generating/cooldown state moved into the shared diff --git a/app/components/settings/services/ServicesCatalogPanel.test.tsx b/app/components/settings/services/ServicesCatalogPanel.test.tsx index 1cb0d8f74..5dbb74e75 100644 --- a/app/components/settings/services/ServicesCatalogPanel.test.tsx +++ b/app/components/settings/services/ServicesCatalogPanel.test.tsx @@ -13,7 +13,12 @@ import { ServicesCatalogPanel } from "./ServicesCatalogPanel"; */ function renderPanel( services: Parameters[0]["services"], - opts: { onEdit?: (id: string | null) => void; editingId?: string | null; members?: Parameters[0]["members"] } = {}, + opts: { + onEdit?: (id: string | null) => void; + editingId?: string | null; + members?: Parameters[0]["members"]; + payRuleMap?: Parameters[0]["payRuleMap"]; + } = {}, ) { // The panel renders
for the activate/deactivate action, so it needs a // router context. @@ -28,6 +33,7 @@ function renderPanel( templateNames={{ "tpl-1": "Residential Standard" }} editingId={opts.editingId ?? null} onEdit={opts.onEdit} + payRuleMap={opts.payRuleMap ?? {}} /> ), }, @@ -64,7 +70,9 @@ describe("ServicesCatalogPanel", () => { it("says a duration is not set rather than showing a bare dash", () => { renderPanel([{ ...base, templateId: "tpl-1" }]); - expect(screen.getByText("Not set")).toBeTruthy(); + // Scoped to the DURATION cell: the row says "Not set" twice since #278 + // added the pay-rule summary, and a bare getByText now matches both. + expect(screen.getByRole("cell", { name: "Not set" })).toBeTruthy(); }); it("names the template a service builds from", () => { @@ -113,3 +121,44 @@ describe("ServicesCatalogPanel — a row's actions", () => { expect(screen.getByRole("button", { name: /deactivate/i })).toBeTruthy(); }); }); + +/** + * `payRuleMap` (#278) arrived as a REQUIRED prop with nothing in this file + * passing it, and the panel indexes it unguarded — so every test here threw + * during render. It went in green because co-located `app/**` tests are + * excluded from the app tsc program (see the comment on `exclude` in + * tsconfig.json), so nothing type-checks this call site: only actually running + * `test:web` can see it. + * + * Passing `{}` to make the crash stop would assert nothing about the prop, so + * this pins the difference a reader sees instead — which is also what makes the + * fix above load-bearing rather than padding. + */ +describe("ServicesCatalogPanel — the pay-rule summary on a row", () => { + const MEMBERS = [{ id: "u1", email: "dana@example.com", role: "inspector", createdAt: "" }]; + + /** + * Read through the "Pay:" label rather than by text: the DURATION cell of + * the same row also says "Not set", and `getByText` matches an element's + * direct text nodes — so the summary span (`Pay: Not set`) + * and the duration cell are both hits for the bare string. + */ + const paySummary = () => screen.getByText("Pay:").parentElement?.textContent; + + it("says a service has no pay rule when the map has no entry for it", () => { + renderPanel([{ ...base, templateId: "tpl-1" }], { members: MEMBERS }); + expect(paySummary()).toBe("Pay: Not set"); + }); + + it("counts the rules the map does carry for that service", () => { + renderPanel([{ ...base, templateId: "tpl-1" }], { + members: MEMBERS, + payRuleMap: { + "svc-1": [ + { id: "pr-1", userId: "u1", type: "percent", percentBps: 6000, amountCents: null, deductionCents: null }, + ], + }, + }); + expect(paySummary()).toBe("Pay: 1 pay rule"); + }); +}); From a270f498f3ed0663e7d276c123b1561795451658 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 17:41:15 +0800 Subject: [PATCH 47/77] chore(gate): re-key one tenant-scope baseline entry after the move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-insert read-back in createReinspection moved from inspection-core.service.ts to inspection-reinspection.service.ts, and the baseline is keyed by file::symbol::text, so the gate saw it as new. Same single occurrence, byte-identical, and safe for the reason the gate itself lists: it reads back a row this function just inserted under an id it generated and a tenantId it wrote. The old entry named createInspection, which was already a mis-attribution — the query has only ever been inside createReinspection. --- scripts/tenant-scoping-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tenant-scoping-baseline.json b/scripts/tenant-scoping-baseline.json index 2e55efc77..5510956eb 100644 --- a/scripts/tenant-scoping-baseline.json +++ b/scripts/tenant-scoping-baseline.json @@ -41,8 +41,8 @@ "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));", - "server/services/inspection/inspection-core.service.ts::createInspection::const created = await db.select().from(inspections).where(eq(inspections.id, id)).get();", "server/services/inspection/inspection-photo.service.ts::InspectionPhotoService::.where(eq(inspectionResults.id, existingId));", + "server/services/inspection/inspection-reinspection.service.ts::InspectionReinspectionService::const created = await db.select().from(inspections).where(eq(inspections.id, id)).get();", "server/services/inspection/inspection-results.service.ts::updateResults::await db.update(inspectionResults).set({ data: mergedData, lastSyncedAt: new Date() }).where(eq(inspectionResults.id, ex", "server/services/inspection/inspection-results.service.ts::updateTemplateSnapshot::}).where(eq(inspectionResults.id, existing.id));", "server/services/marketplace.service.ts::importTemplate::.where(eq(marketplaceTemplates.id, marketplaceId))", From 7716fecffe64ecdb3f883662130ad09e5cc65f1f Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 18:08:37 +0800 Subject: [PATCH 48/77] schema(cancellation): the policy column and an attestation that can expire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four columns at the end of `tenant_configs`: the ladder itself, and the agreement id + version + timestamp of the tenant confirming their own agreement covers it. The attestation is stored as id + VERSION rather than a bare timestamp because `agreements` is multi-row per tenant with a per-row version. A bare timestamp would let an edit to the commercial template void the residential attestation, and — the way that actually costs money — let an attestation outlive the clause it attested to while the platform kept charging. Storing what was attested makes invalidation an equality check. `cancellation_policy` is JSON with no default: no policy configured is how every workspace ships, and the platform authors neither a default ladder nor a model clause. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- migrations/0041_medical_prima.sql | 4 + migrations/meta/0041_snapshot.json | 10825 ++++++++++++++++++++ migrations/meta/_journal.json | 7 + server/lib/billing/cancellation-policy.ts | 45 + server/lib/db/schema/tenant/core.ts | 30 + tests/helpers/inline-ddl.ts | 2 +- 6 files changed, 10912 insertions(+), 1 deletion(-) create mode 100644 migrations/0041_medical_prima.sql create mode 100644 migrations/meta/0041_snapshot.json create mode 100644 server/lib/billing/cancellation-policy.ts diff --git a/migrations/0041_medical_prima.sql b/migrations/0041_medical_prima.sql new file mode 100644 index 000000000..1b5dfa919 --- /dev/null +++ b/migrations/0041_medical_prima.sql @@ -0,0 +1,4 @@ +ALTER TABLE `tenant_configs` ADD `cancellation_policy` text;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `cancellation_clause_agreement_id` text;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `cancellation_clause_version` integer;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `cancellation_clause_attested_at` integer; \ No newline at end of file diff --git a/migrations/meta/0041_snapshot.json b/migrations/meta/0041_snapshot.json new file mode 100644 index 000000000..c8fe48565 --- /dev/null +++ b/migrations/meta/0041_snapshot.json @@ -0,0 +1,10825 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c6ec1626-1db2-4dd4-bf6d-bf5e6b550be7", + "prevId": "98f66aa2-1619-4acc-8d2f-b4916cfec2a5", + "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_service_pay_splits": { + "name": "inspection_service_pay_splits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_service_id": { + "name": "inspection_service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrects_split_id": { + "name": "corrects_split_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "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_pay_split_line_user": { + "name": "uq_pay_split_line_user", + "columns": [ + "tenant_id", + "inspection_service_id", + "user_id" + ], + "isUnique": true, + "where": "corrects_split_id IS NULL" + }, + "idx_pay_split_user": { + "name": "idx_pay_split_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_pay_split_line": { + "name": "idx_pay_split_line", + "columns": [ + "tenant_id", + "inspection_service_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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": {} + }, + "service_pay_rules": { + "name": "service_pay_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_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 + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deduction_cents": { + "name": "deduction_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_service_pay_rules_user": { + "name": "uq_service_pay_rules_user", + "columns": [ + "tenant_id", + "service_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "uq_service_pay_rules_default": { + "name": "uq_service_pay_rules_default", + "columns": [ + "tenant_id", + "service_id" + ], + "isUnique": true, + "where": "user_id IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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'" + }, + "cancellation_policy": { + "name": "cancellation_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_agreement_id": { + "name": "cancellation_clause_agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_version": { + "name": "cancellation_clause_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_attested_at": { + "name": "cancellation_clause_attested_at", + "type": "integer", + "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 + }, + "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 ff360e4b9..9a74318c4 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1785978827419, "tag": "0040_lovely_rockslide", "breakpoints": true + }, + { + "idx": 41, + "version": "6", + "when": 1786010842389, + "tag": "0041_medical_prima", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/lib/billing/cancellation-policy.ts b/server/lib/billing/cancellation-policy.ts new file mode 100644 index 000000000..70484ef30 --- /dev/null +++ b/server/lib/billing/cancellation-policy.ts @@ -0,0 +1,45 @@ +/** + * The shape of a tenant's cancellation ladder. + * + * Type only — the arithmetic lives in `./cancellation-outcome.ts` and the wire + * validation in `server/lib/validations/admin/settings.ts`. It is separate from + * both because `tenant_configs.cancellationPolicy` needs the type at the schema + * definition, and a schema file must not pull in Zod or a resolver. + * + * A fee is a DISCRIMINATED UNION rather than `{ type, value }`: a bare `value` + * on a real-money field has no unit, so "50" reads as either half the price or + * fifty cents depending on the sibling field, and nothing in the type system + * objects. Splitting it also makes "percent above 100" a range on a field that + * only exists in the percent arm, instead of a rule that has to remember which + * variant it is looking at. + */ + +export type CancellationFee = + | { type: 'percent'; percent: number } + | { type: 'fixed'; amountCents: number }; + +export interface CancellationPolicy { + /** + * Notice threshold in HOURS. Hours only in v1: "2 business days" — the other + * phrasing every published policy uses — needs the tenant timezone, a + * weekend rule and a holiday list, and only the first two exist here. An + * hours threshold between two instants is exact and needs none of them. + */ + noticeHours: number; + /** Charged when the client cancels INSIDE the notice window. */ + lateFee: CancellationFee; + /** Charged when the client does not show. Commonly 100%. */ + noShowFee: CancellationFee; + /** `'credit'` (toward a future inspection) is deferred — see spec §4. */ + remedy: 'refund'; +} + +/** A zero fee on both rungs is a policy that never charges — no attestation needed. */ +export function policyChargesFees(policy: CancellationPolicy | null | undefined): boolean { + if (!policy) return false; + return feeIsChargeable(policy.lateFee) || feeIsChargeable(policy.noShowFee); +} + +function feeIsChargeable(fee: CancellationFee): boolean { + return fee.type === 'percent' ? fee.percent > 0 : fee.amountCents > 0; +} diff --git a/server/lib/db/schema/tenant/core.ts b/server/lib/db/schema/tenant/core.ts index d34b4089b..f3a126d06 100644 --- a/server/lib/db/schema/tenant/core.ts +++ b/server/lib/db/schema/tenant/core.ts @@ -1,6 +1,7 @@ import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; import type { ReportLinkTtl } from '../../../report-link-ttl'; +import type { CancellationPolicy } from '../../../billing/cancellation-policy'; export const tenants = sqliteTable('tenants', { id: text('id').primaryKey(), @@ -264,6 +265,35 @@ export const tenantConfigs = sqliteTable('tenant_configs', { bookingConflictPolicy: text('booking_conflict_policy', { enum: ['advisory', 'block'], }).notNull().default('advisory'), + // The tenant's cancellation ladder. NULL = no policy configured, which is + // how every workspace ships: the platform charges nothing and cancellations + // are free until the tenant says otherwise. There is no default policy and + // no model clause — both are the tenant's, because the agreement is the + // tenant's content and it is the agreement that governs. + // + // ⚠️ WRITE PATH IS LOAD-BEARING. The only legitimate writer of this column + // is `BrandingService.updateBranding`, which refuses a fee-bearing policy + // unless the attestation below is present AND still matches the agreement + // it was made against. That gate compares DB state, so no Zod schema can + // express it and no constraint enforces it — it lives in the writer. + // `tenant_configs` has ~19 write sites across 11 files; a second writer of + // THIS column would bypass the gate in silence and charge a fee the + // contract may not support. Route new writes through that method, or move + // the gate somewhere both writers can see it. + // Appended at END of the table per the D1 add-column-at-end rule + // (tenant_configs is FK-referenced). + cancellationPolicy: text('cancellation_policy', { mode: 'json' }).$type(), + // The tenant's confirmation that their OWN agreement contains a + // cancellation clause covering those fees. Recorded as the agreement + // template id plus the VERSION attested, never a bare timestamp: + // `agreements` is multi-row per tenant with a per-row version, so a bare + // timestamp lets a commercial template's edit void a residential + // attestation — and, worse, lets an attestation outlive the very clause it + // attested to. Storing id + version makes invalidation an equality check + // instead of an event somebody has to remember to fire. + cancellationClauseAgreementId: text('cancellation_clause_agreement_id'), + cancellationClauseVersion: integer('cancellation_clause_version'), + cancellationClauseAttestedAt: integer('cancellation_clause_attested_at', { mode: 'timestamp_ms' }), }); /** diff --git a/tests/helpers/inline-ddl.ts b/tests/helpers/inline-ddl.ts index d026e7228..d16c97a4a 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\', booking_conflict_policy TEXT NOT NULL DEFAULT \'advisory\', 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\', cancellation_policy TEXT, cancellation_clause_agreement_id TEXT, cancellation_clause_version INTEGER, cancellation_clause_attested_at INTEGER, 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 2583a6394a8f365339637037c786bfb1135db63c Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 18:20:42 +0800 Subject: [PATCH 49/77] feat(cancellation): refuse fees the agreement has not been said to cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate compares the submitted policy against DB state, so no Zod rule can express it. It lives in `BrandingService.updateBranding` — the funnel behind `POST /api/admin/branding` — and refuses any policy with a non-zero rung unless a valid attestation is on file. Valid means the attested agreement still exists AND its version still equals the version attested. That equality is the whole invalidation mechanism: the alternative, clearing the attestation from the agreement-edit path, needs every future writer of `agreements` to remember, and this one is evaluated at the moment the answer is used so it cannot be forgotten. `attestCancellationClause` is transient on the wire, like `confirmCurrencyChange`, and is applied BEFORE the policy write so ticking the box and enabling fees is one save. The fee is a discriminated union — `{type:'percent',percent}` | `{type:'fixed',amountCents}` — because a bare `value` on a money field has no unit, and split, "a percent above 100" is a range on a field that only exists in one arm. Note the coupling this creates: `tenant_configs` has ~19 write sites across 11 files and the gate lives in one of them. `cancellation_policy` is new, so the other ten cannot bypass a gate they do not know about — but a second writer later would, silently. Written down at the column definition. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- server/api/admin/branding.ts | 12 +- server/lib/validations/admin/settings.ts | 49 +++++ server/services/branding.service.ts | 90 ++++++++- .../unit/branding/cancellation-policy.spec.ts | 179 ++++++++++++++++++ 4 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 tests/unit/branding/cancellation-policy.spec.ts diff --git a/server/api/admin/branding.ts b/server/api/admin/branding.ts index 0ec6909e0..9172f1f43 100644 --- a/server/api/admin/branding.ts +++ b/server/api/admin/branding.ts @@ -148,7 +148,17 @@ const adminBrandingRoutes = createApiRouter() // Phase B — `confirmCurrencyChange` is a transient acknowledgement, never a // persisted column; strip it before it reaches the branding service. - const { confirmCurrencyChange, ...brandingData } = body; + // `attestCancellationClause` is transient too: it names an agreement + // template, and the service turns it into the id + version + timestamp + // triple that the fee gate reads. + const { confirmCurrencyChange, attestCancellationClause, ...brandingData } = body; + + // Applied BEFORE the policy write, so "tick the box and turn fees on" + // is one save rather than two. Withdrawing the attestation in the same + // request that enables fees correctly fails the gate below. + if (attestCancellationClause !== undefined) { + await brandingService.attestCancellationClause(tenantId, attestCancellationClause); + } // Guard: block a tenant currency change once invoices exist unless the // caller explicitly confirms (the per-invoice snapshot protects history, diff --git a/server/lib/validations/admin/settings.ts b/server/lib/validations/admin/settings.ts index 9f2284881..031887e49 100644 --- a/server/lib/validations/admin/settings.ts +++ b/server/lib/validations/admin/settings.ts @@ -4,6 +4,38 @@ import { isValidTimeZone } from '../../tz'; import { isValidLocale } from '../../locale'; import { DATE_FORMATS, TIME_FORMATS } from '../../session/display-prefs'; +/** + * One rung of the cancellation ladder. + * + * A discriminated union, not `{ type, value }`: a bare `value` on a money field + * carries no unit, so the same 50 is half the price in one arm and fifty cents + * in the other and nothing objects. Split, "a percent above 100" is a range on + * a field that only exists in the percent arm — the schema rejects it, and a + * caller cannot even construct the nonsense in a typed client. + */ +const CancellationFeeSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('percent').describe('Fee expressed as a share of the inspection price.'), + percent: z.number().min(0).max(100).describe('0-100. Of the PRICE; the resolver caps the charge at what was collected.'), + }), + z.object({ + type: z.literal('fixed').describe('Fee expressed as a fixed amount.'), + amountCents: z.number().int().min(0).describe('Integer cents.'), + }), +]).openapi('CancellationFee'); + +/** + * The ladder itself. Hours only in v1 — see the column comment for why + * "2 business days" is deferred rather than approximated. + */ +export const CancellationPolicySchema = z.object({ + noticeHours: z.number().int().min(0).max(720).openapi({ example: 24 }) + .describe('Notice threshold in hours. Cancelling with at least this much notice is free.'), + lateFee: CancellationFeeSchema.describe('Charged when the client cancels inside the notice window.'), + noShowFee: CancellationFeeSchema.describe('Charged when the client does not show. Commonly 100%.'), + remedy: z.literal('refund').describe("Cash refund. 'credit' toward a future inspection is deferred."), +}).openapi('CancellationPolicy'); + /** * Validation schema for the branding configuration update. */ @@ -71,6 +103,23 @@ export const UpdateBrandingSchema = z.object({ // save is blocked (409 CURRENCY_CHANGE_NEEDS_CONFIRM); existing invoices keep // their snapshot currency, new ones use the new tenant currency. confirmCurrencyChange: z.boolean().optional().openapi({ example: true }).describe('Acknowledge changing tenant currency with invoices present.'), + // The cancellation ladder. `null` clears it back to "no policy configured", + // which is where every workspace starts and where nothing is ever charged. + // `.optional()` with NO `.default()`: a default here would make a save that + // never mentions the policy silently overwrite a configured one. + // + // A fee-bearing policy is REFUSED unless the attestation below is on file + // and still matches. That check reads DB state, so it is not expressible + // here — it lives in `BrandingService.updateBranding`. + cancellationPolicy: CancellationPolicySchema.nullable().optional() + .describe('Cancellation ladder; null clears it. Fees require an attested agreement clause.'), + // Transient (NOT a column): the id of the agreement template the tenant + // confirms contains their cancellation clause. Sending it stamps the + // attestation at that template's CURRENT version; sending null withdraws it. + // Applied BEFORE the policy in the same request, so enabling fees and + // attesting can be one save. + attestCancellationClause: z.string().min(1).nullable().optional() + .describe("Agreement template id the tenant attests contains their cancellation clause; null withdraws it."), }).openapi('UpdateBranding'); /** diff --git a/server/services/branding.service.ts b/server/services/branding.service.ts index 4f16bd2e2..920cba3ad 100644 --- a/server/services/branding.service.ts +++ b/server/services/branding.service.ts @@ -1,7 +1,8 @@ import { drizzle } from 'drizzle-orm/d1'; -import { eq } from 'drizzle-orm'; -import { tenantConfigs } from '../lib/db/schema'; +import { and, eq } from 'drizzle-orm'; +import { agreements, tenantConfigs } from '../lib/db/schema'; import { Errors } from '../lib/errors'; +import { policyChargesFees } from '../lib/billing/cancellation-policy'; import type { EmailIdentityConfig } from '../lib/email/sender-identity'; import { r2Keys } from '../lib/r2-keys'; import { resolveTenantLegalUrls, type LegalMode } from '../lib/legal-links'; @@ -168,10 +169,95 @@ export class BrandingService { return this.getBrand(tenantId); } + // ─── Cancellation clause attestation ───────────────────────────────────── + + /** + * Record — or withdraw — the tenant's confirmation that their OWN agreement + * contains a cancellation clause covering the fees they are configuring. + * + * We cannot parse free-form agreement HTML for a notice window, so this is + * the honest maximum: the tenant says so, and we record WHAT they said it + * about. Passing the template id stamps the attestation at that template's + * current version; passing null withdraws it. + */ + async attestCancellationClause(tenantId: string, agreementId: string | null): Promise { + if (agreementId === null) { + await this.writeConfig(tenantId, { + cancellationClauseAgreementId: null, + cancellationClauseVersion: null, + cancellationClauseAttestedAt: null, + }); + return; + } + const db = this.getDrizzle(); + const agreement = await db.select({ id: agreements.id, version: agreements.version }) + .from(agreements) + .where(and(eq(agreements.id, agreementId), eq(agreements.tenantId, tenantId))) + .get(); + if (!agreement) throw Errors.NotFound('Agreement template not found'); + await this.writeConfig(tenantId, { + cancellationClauseAgreementId: agreement.id, + cancellationClauseVersion: agreement.version, + cancellationClauseAttestedAt: new Date(), + }); + } + + /** + * The attestation on file, or null when there is none — or when the + * agreement it was made against has since been edited or deleted. + * + * Invalidation is this equality check and nothing else. The alternative, + * clearing the attestation from the agreement-edit path, needs every future + * writer of `agreements` to remember; this one cannot be forgotten because + * it is evaluated at the moment the answer is used. + */ + async getCancellationAttestation( + tenantId: string, + ): Promise<{ agreementId: string; version: number; attestedAt: Date } | null> { + const db = this.getDrizzle(); + const row = await db.select({ + agreementId: tenantConfigs.cancellationClauseAgreementId, + version: tenantConfigs.cancellationClauseVersion, + attestedAt: tenantConfigs.cancellationClauseAttestedAt, + }) + .from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, tenantId)) + .get(); + if (!row?.agreementId || row.version == null || !row.attestedAt) return null; + + const current = await db.select({ version: agreements.version }) + .from(agreements) + .where(and(eq(agreements.id, row.agreementId), eq(agreements.tenantId, tenantId))) + .get(); + // Gone, or edited since: the words that were attested to are no longer + // the words the client agreed to. + if (!current || current.version !== row.version) return null; + + return { agreementId: row.agreementId, version: row.version, attestedAt: row.attestedAt }; + } + /** * Updates the branding configuration for a tenant. + * + * ⚠️ This is the gate for `cancellation_policy`. The refusal cannot be a Zod + * rule — it compares the submitted policy against DB state — so it lives + * here, in the writer, and a second writer of that column would bypass it + * without failing anything. See the column comment in the tenant schema. */ async updateBranding(tenantId: string, data: Partial) { + if (data.cancellationPolicy !== undefined && policyChargesFees(data.cancellationPolicy)) { + if (!(await this.getCancellationAttestation(tenantId))) { + throw Errors.UnprocessableEntity( + 'Confirm that your agreement contains a cancellation clause before enabling cancellation fees. ' + + 'The agreement is what the client agreed to; this policy only enforces it.', + ); + } + } + return this.writeConfig(tenantId, data); + } + + /** Upsert of the tenant config row. NOT a gate — see `updateBranding`. */ + private async writeConfig(tenantId: string, data: Partial) { const db = this.getDrizzle(); const existing = await db.select().from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); diff --git a/tests/unit/branding/cancellation-policy.spec.ts b/tests/unit/branding/cancellation-policy.spec.ts new file mode 100644 index 000000000..d0c416432 --- /dev/null +++ b/tests/unit/branding/cancellation-policy.spec.ts @@ -0,0 +1,179 @@ +/** + * The cancellation-fee attestation gate. + * + * The platform enforces NUMBERS while the client agreed to WORDS, and the words + * are what govern. Since free-form agreement HTML cannot be parsed for a notice + * window, the only honest gate is the tenant confirming the clause exists — and + * the only way that confirmation stays meaningful is by recording WHICH + * agreement, at WHICH version, so an edit to the clause cannot leave a live + * attestation behind it. + * + * The gate compares DB state, so it is not expressible in the Zod schema and + * lives in `BrandingService.updateBranding`. These specs exercise that method, + * which is what `POST /api/admin/branding` funnels into. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { BrandingService } from '../../../server/services/branding.service'; +import { AgreementService } from '../../../server/services/agreement.service'; +import { UpdateBrandingSchema } from '../../../server/lib/validations/admin.schema'; +import * as schema from '../../../server/lib/db/schema'; +import type { CancellationPolicy } from '../../../server/lib/billing/cancellation-policy'; +import { createTestDb, setupSchema } from '../db'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); + +const TENANT = '00000000-0000-0000-0000-000000000001'; + +const FEE_POLICY: CancellationPolicy = { + noticeHours: 24, + lateFee: { type: 'percent', percent: 50 }, + noShowFee: { type: 'percent', percent: 100 }, + remedy: 'refund', +}; + +const FREE_POLICY: CancellationPolicy = { + noticeHours: 24, + lateFee: { type: 'percent', percent: 0 }, + noShowFee: { type: 'fixed', amountCents: 0 }, + remedy: 'refund', +}; + +describe('BrandingService — cancellation policy attestation gate', () => { + let testDb: BetterSQLite3Database; + let branding: BrandingService; + let agreementSvc: AgreementService; + + beforeEach(async () => { + const fix = createTestDb(); + testDb = fix.db; + await setupSchema(fix.sqlite); + const { drizzle } = await import('drizzle-orm/d1'); + (drizzle as unknown as ReturnType).mockReturnValue(testDb); + branding = new BrandingService({} as D1Database); + agreementSvc = new AgreementService({} as D1Database); + await testDb.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + }); + + async function seedAgreement(id = 'agr-1') { + return agreementSvc.createAgreement(TENANT, 'Residential', '

Cancel 24h ahead or pay 50%.

') + .then(async (created) => { + // createAgreement mints its own uuid; rename it so the specs can + // name it without depending on crypto.randomUUID. + await testDb.update(schema.agreements).set({ id }) + .where(eq(schema.agreements.id, created.id)); + return id; + }); + } + + async function storedPolicy(): Promise { + const row = await testDb.select({ p: schema.tenantConfigs.cancellationPolicy }) + .from(schema.tenantConfigs).where(eq(schema.tenantConfigs.tenantId, TENANT)).get(); + return row?.p ?? null; + } + + it('refuses to save a policy with fees when the clause is not attested', async () => { + await expect(branding.updateBranding(TENANT, { cancellationPolicy: FEE_POLICY })) + .rejects.toThrow(/agreement contains a cancellation clause/i); + expect(await storedPolicy()).toBeNull(); + }); + + it('accepts the policy once attested', async () => { + const agreementId = await seedAgreement(); + await branding.attestCancellationClause(TENANT, agreementId); + await branding.updateBranding(TENANT, { cancellationPolicy: FEE_POLICY }); + expect(await storedPolicy()).toEqual(FEE_POLICY); + }); + + it('stops honouring the attestation once the agreement is edited', async () => { + // The attestation is about a specific VERSION of the text. Editing the + // agreement invalidates it, or a tenant can attest once and then delete + // the clause while the platform keeps charging. + const agreementId = await seedAgreement(); + await branding.attestCancellationClause(TENANT, agreementId); + expect(await branding.getCancellationAttestation(TENANT)).not.toBeNull(); + + await agreementSvc.updateAgreement(agreementId, TENANT, undefined, '

No cancellation terms.

'); + + expect(await branding.getCancellationAttestation(TENANT)).toBeNull(); + await expect(branding.updateBranding(TENANT, { cancellationPolicy: FEE_POLICY })) + .rejects.toThrow(/agreement contains a cancellation clause/i); + }); + + it('does not honour an attestation against a DIFFERENT tenant agreement being edited', async () => { + // `agreements` is multi-row per tenant: a commercial template's edit must + // not void the residential attestation, which a bare timestamp would do. + const residential = await seedAgreement('agr-residential'); + const commercial = await agreementSvc.createAgreement(TENANT, 'Commercial', '

Other terms.

'); + await branding.attestCancellationClause(TENANT, residential); + + await agreementSvc.updateAgreement(commercial.id, TENANT, undefined, '

Edited.

'); + + expect(await branding.getCancellationAttestation(TENANT)).not.toBeNull(); + await expect(branding.updateBranding(TENANT, { cancellationPolicy: FEE_POLICY })).resolves.toBeDefined(); + }); + + it('deleting the attested agreement withdraws the attestation', async () => { + const agreementId = await seedAgreement(); + await branding.attestCancellationClause(TENANT, agreementId); + await agreementSvc.deleteAgreement(agreementId, TENANT); + expect(await branding.getCancellationAttestation(TENANT)).toBeNull(); + }); + + it('needs no attestation for a policy that never charges', async () => { + await branding.updateBranding(TENANT, { cancellationPolicy: FREE_POLICY }); + expect(await storedPolicy()).toEqual(FREE_POLICY); + }); + + it('rejects a percent fee above 100', () => { + const parsed = UpdateBrandingSchema.safeParse({ + cancellationPolicy: { ...FEE_POLICY, noShowFee: { type: 'percent', percent: 150 } }, + }); + expect(parsed.success).toBe(false); + }); + + it('rejects a fixed fee expressed in fractional cents', () => { + const parsed = UpdateBrandingSchema.safeParse({ + cancellationPolicy: { ...FEE_POLICY, lateFee: { type: 'fixed', amountCents: 300.5 } }, + }); + expect(parsed.success).toBe(false); + }); + + it('omits the key entirely when a save does not mention the policy', () => { + // Assert the ABSENCE OF THE KEY, not the resulting value: a `.default()` + // on this field would make an unrelated Workspace save silently overwrite + // a configured ladder, and a value assertion cannot tell the two apart. + const parsed = UpdateBrandingSchema.parse({ companyName: 'Acme' }); + expect('cancellationPolicy' in parsed).toBe(false); + expect('attestCancellationClause' in parsed).toBe(false); + }); + + it('leaves a configured policy alone when a later save does not mention it', async () => { + const agreementId = await seedAgreement(); + await branding.attestCancellationClause(TENANT, agreementId); + await branding.updateBranding(TENANT, { cancellationPolicy: FEE_POLICY }); + await branding.updateBranding(TENANT, { companyName: 'Acme Inspections' }); + expect(await storedPolicy()).toEqual(FEE_POLICY); + }); + + it('clears the policy when the caller explicitly sends null', async () => { + const agreementId = await seedAgreement(); + await branding.attestCancellationClause(TENANT, agreementId); + await branding.updateBranding(TENANT, { cancellationPolicy: FEE_POLICY }); + await branding.updateBranding(TENANT, { cancellationPolicy: null }); + expect(await storedPolicy()).toBeNull(); + }); + + it('refuses to attest an agreement belonging to another tenant', async () => { + await testDb.insert(schema.tenants).values({ + id: 'other', name: 'Other', slug: 'other', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + const foreign = await agreementSvc.createAgreement('other', 'Theirs', '

x

'); + await expect(branding.attestCancellationClause(TENANT, foreign.id)).rejects.toThrow(/not found/i); + }); +}); From 9e49013e782a013170e14c957e17dfa02ca4257d Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 18:26:59 +0800 Subject: [PATCH 50/77] feat(cancellation): the outcome resolver, pure and case-by-case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveCancellation` computes fee + refund + a reason CODE. No DB, no clock, no side effects: this is the piece a tenant will dispute, so it has to read as a table of cases. Three input decisions that the tests depend on: `priceCents` is separate from `paidCents`. A percentage fee is of the PRICE, but only what was COLLECTED can be kept. Every case where the two are equal agrees with both readings, so the spec has one where they differ — price 45000, paid 9000, late fee 50% wants 22500 and charges 9000. The wrong reading gives 4500 and passes everything else. `initiator` and `event` are separate axes, not one `by` field. "The client no-showed" cannot be expressed when the actor and the event share a slot. Persistence for both is Task 3's `cancel_reason`. The reason is a code, not a sentence. It crosses the wire to a UI that has to render it in the reader's language. Hours only, and no scheduled instant means no charge: `scheduled_start_ms` is NULL on legacy and manually-created orders, and the wrong direction to guess is the one that charges someone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- server/lib/billing/cancellation-outcome.ts | 128 +++++++++++++++ .../unit/billing/cancellation-outcome.spec.ts | 155 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 server/lib/billing/cancellation-outcome.ts create mode 100644 tests/unit/billing/cancellation-outcome.spec.ts diff --git a/server/lib/billing/cancellation-outcome.ts b/server/lib/billing/cancellation-outcome.ts new file mode 100644 index 000000000..1539f6168 --- /dev/null +++ b/server/lib/billing/cancellation-outcome.ts @@ -0,0 +1,128 @@ +/** + * What a cancellation costs, and what goes back. + * + * Pure — no DB, no clock, no side effects. This is the piece a tenant will + * dispute, so it has to read as a table of cases, and it has to be testable + * without a database in the room. Nothing here executes a refund: it computes + * an outcome and the ledger records it. + * + * ON EPOCH ARITHMETIC AND TIMEZONES. The notice threshold is in HOURS between + * two instants, and for that, subtracting epoch milliseconds is exactly right — + * an hour is an hour across a DST boundary, and no timezone enters the + * calculation. The timezone hazard is precisely why CALENDAR days are not + * shipped: "2 business days", the other phrasing every published policy uses, + * needs the tenant's zone, a weekend rule and a holiday list. Two of those + * exist here and one does not, so v1 is hours only. + * + * ON THE FEE BASE. A percentage fee is of the inspection PRICE, but only what + * was actually COLLECTED can be kept. Those are different numbers whenever a + * deposit is involved, and conflating them invents a receivable nobody agreed + * to: a 100% no-show fee against a 20% deposit must charge the deposit, not + * bill the client for the other 80%. + */ +import type { CancellationFee, CancellationPolicy } from './cancellation-policy'; + +/** Who ended the appointment. Not the same axis as what happened. */ +export type CancellationInitiator = 'client' | 'inspector'; + +/** What happened. A no-show is an event, not an actor. */ +export type CancellationEvent = 'cancellation' | 'no_show'; + +/** + * Why the outcome is what it is. A CODE, not prose: this crosses the wire to a + * UI that has to render it in the reader's language, and an English sentence + * baked in here would be untranslatable by construction. + */ +export type CancellationReasonCode = + /** The company cancelled. Always a full refund; not configurable. */ + | 'inspector_initiated' + /** No ladder configured. The platform charges nothing. */ + | 'no_policy' + /** No precise scheduled instant on the order, so notice cannot be measured. */ + | 'no_scheduled_instant' + | 'sufficient_notice' + | 'late_cancellation' + | 'no_show'; + +export interface CancellationInput { + policy: CancellationPolicy | null; + /** + * The instant the work was due to begin (`inspections.scheduled_start_ms`). + * NULL on legacy and manually-created orders — see the fail-closed rule. + */ + scheduledAt: Date | number | null; + now: Date | number; + /** The agreed price of the work. A PERCENT fee is a share of THIS. */ + priceCents: number; + /** What was actually collected. Only this can be kept, and only this refunded. */ + paidCents: number; + initiator: CancellationInitiator; + event: CancellationEvent; +} + +export interface CancellationOutcome { + /** Kept by the tenant. Never exceeds `paidCents`. */ + feeCents: number; + /** Returned to the payer. Always `paidCents - feeCents`. */ + refundCents: number; + reason: CancellationReasonCode; + /** + * The ladder asked for more than was collected and the charge was reduced + * to what there was. Worth surfacing: the tenant is owed less than their + * own policy says, and they should find that out here rather than from a + * reconciliation three weeks later. + */ + cappedAtCollected: boolean; +} + +const MS_PER_HOUR = 3_600_000; + +const asMs = (t: Date | number): number => (t instanceof Date ? t.getTime() : t); + +/** A fee rung resolved against the price. Rounded to whole cents. */ +function feeAgainstPrice(fee: CancellationFee, priceCents: number): number { + if (fee.type === 'fixed') return Math.max(0, Math.round(fee.amountCents)); + return Math.max(0, Math.round((priceCents * fee.percent) / 100)); +} + +export function resolveCancellation(input: CancellationInput): CancellationOutcome { + const { policy, priceCents, paidCents, initiator, event } = input; + + const free = (reason: CancellationReasonCode): CancellationOutcome => ({ + feeCents: 0, + refundCents: paidCents, + reason, + cappedAtCollected: false, + }); + + // A policy that penalises a client for the company's own cancellation is + // the one outcome no published policy permits. Checked FIRST, before the + // ladder is even consulted, so no configuration can reach it. + if (initiator === 'inspector') return free('inspector_initiated'); + + // Ships this way for every workspace: no ladder, no charge. + if (!policy) return free('no_policy'); + + if (event === 'no_show') return charge(policy.noShowFee, 'no_show'); + + // Fail closed. Without a precise scheduled instant there is no honest + // answer to "how much notice was that", and the wrong direction to guess is + // the one that charges someone. + if (input.scheduledAt == null) return free('no_scheduled_instant'); + + const hoursOfNotice = (asMs(input.scheduledAt) - asMs(input.now)) / MS_PER_HOUR; + if (hoursOfNotice >= policy.noticeHours) return free('sufficient_notice'); + + return charge(policy.lateFee, 'late_cancellation'); + + function charge(fee: CancellationFee, reason: CancellationReasonCode): CancellationOutcome { + const wanted = feeAgainstPrice(fee, priceCents); + const feeCents = Math.min(wanted, Math.max(0, paidCents)); + return { + feeCents, + refundCents: Math.max(0, paidCents) - feeCents, + reason, + cappedAtCollected: wanted > feeCents, + }; + } +} diff --git a/tests/unit/billing/cancellation-outcome.spec.ts b/tests/unit/billing/cancellation-outcome.spec.ts new file mode 100644 index 000000000..f6f53413f --- /dev/null +++ b/tests/unit/billing/cancellation-outcome.spec.ts @@ -0,0 +1,155 @@ +/** + * The cancellation ladder, as a table of cases. + * + * Two invariants carry the weight here and both have a case that would pass + * under the wrong reading: + * + * - the inspector exception (a full refund regardless of notice, not + * configurable), and + * - the fee base: a PERCENT fee is of the PRICE, but only what was COLLECTED + * can be kept. Every case where price == paid is blind to that distinction, + * so the cases below deliberately separate them. + */ +import { describe, it, expect } from 'vitest'; +import { resolveCancellation } from '../../../server/lib/billing/cancellation-outcome'; +import type { CancellationPolicy } from '../../../server/lib/billing/cancellation-policy'; + +const POLICY: CancellationPolicy = { + noticeHours: 24, + lateFee: { type: 'percent', percent: 50 }, + noShowFee: { type: 'percent', percent: 100 }, + remedy: 'refund', +}; + +const NOW = Date.UTC(2026, 7, 6, 12, 0, 0); +const h = (n: number) => NOW + n * 3_600_000; + +/** Price and paid equal unless a case says otherwise. */ +const at = ( + scheduledAt: number | null, + over: Partial[0]> = {}, +) => resolveCancellation({ + policy: POLICY, + scheduledAt, + now: NOW, + priceCents: 45000, + paidCents: 45000, + initiator: 'client', + event: 'cancellation', + ...over, +}); + +describe('resolveCancellation', () => { + it('refunds in full with sufficient notice', () => { + expect(at(h(48))).toMatchObject({ feeCents: 0, refundCents: 45000, reason: 'sufficient_notice' }); + }); + + it('treats notice exactly at the threshold as sufficient', () => { + // The boundary belongs to the client. A policy saying "24 hours notice" + // is read by everyone as "24 hours is enough", and the alternative + // charges someone for being punctual to the minute. + expect(at(h(24))).toMatchObject({ feeCents: 0, reason: 'sufficient_notice' }); + }); + + it('keeps the late fee inside the notice window', () => { + expect(at(h(12))).toMatchObject({ feeCents: 22500, refundCents: 22500, reason: 'late_cancellation' }); + }); + + it('keeps the no-show fee', () => { + expect(at(h(-24), { event: 'no_show' })) + .toMatchObject({ feeCents: 45000, refundCents: 0, reason: 'no_show' }); + }); + + it('charges the no-show fee even when the notice window was never breached', () => { + // A no-show is an EVENT, not a notice failure — this is why the input + // splits initiator from event instead of carrying `by: 'no_show'`, + // which cannot express "the client no-showed" at all. + expect(at(h(48), { event: 'no_show' })).toMatchObject({ feeCents: 45000, reason: 'no_show' }); + }); + + it('ALWAYS refunds in full when the inspector cancels, inside the window or not', () => { + expect(at(h(1), { initiator: 'inspector' })) + .toMatchObject({ feeCents: 0, refundCents: 45000, reason: 'inspector_initiated' }); + }); + + it('refunds in full when the inspector is the reason for a no-show', () => { + expect(at(h(-1), { initiator: 'inspector', event: 'no_show' })) + .toMatchObject({ feeCents: 0, refundCents: 45000, reason: 'inspector_initiated' }); + }); + + it('charges a percentage of the PRICE, not of what was collected', () => { + // 50% of a 45000 price against a 9000 deposit is 22500 wanted — which is + // then capped at the 9000 there is. Reading the percentage off the + // COLLECTED figure instead would give 4500, and every case where price + // equals paid agrees with both readings, so this is the only case that + // can tell them apart. + expect(at(h(12), { priceCents: 45000, paidCents: 9000 })) + .toMatchObject({ feeCents: 9000, refundCents: 0, cappedAtCollected: true }); + }); + + it('never charges a fee larger than what was collected', () => { + expect(at(h(-24), { event: 'no_show', paidCents: 9000 })) + .toMatchObject({ feeCents: 9000, refundCents: 0, cappedAtCollected: true }); + }); + + it('does not flag a cap when the ladder fits inside what was collected', () => { + expect(at(h(12))).toMatchObject({ cappedAtCollected: false }); + }); + + it('charges nothing against an unpaid order, and invents no receivable', () => { + expect(at(h(-24), { event: 'no_show', paidCents: 0 })) + .toMatchObject({ feeCents: 0, refundCents: 0 }); + }); + + it('charges nothing when no policy is configured', () => { + expect(at(h(1), { policy: null })) + .toMatchObject({ feeCents: 0, refundCents: 45000, reason: 'no_policy' }); + }); + + it('charges nothing when the order has no precise scheduled instant', () => { + // `scheduled_start_ms` is NULL on legacy and manually-created orders. + // Notice cannot be measured, and the wrong direction to guess is the + // one that charges someone. + expect(at(null)).toMatchObject({ feeCents: 0, refundCents: 45000, reason: 'no_scheduled_instant' }); + }); + + it('still charges a no-show without a scheduled instant', () => { + // The no-show did not need the clock; only the notice test does. + expect(at(null, { event: 'no_show' })).toMatchObject({ feeCents: 45000, reason: 'no_show' }); + }); + + it('applies a fixed fee in cents, not as a share', () => { + expect(at(h(12), { policy: { ...POLICY, lateFee: { type: 'fixed', amountCents: 30000 } } })) + .toMatchObject({ feeCents: 30000, refundCents: 15000 }); + }); + + it('rounds a percentage to whole cents', () => { + expect(at(h(12), { priceCents: 33333, paidCents: 33333 })) + .toMatchObject({ feeCents: 16667, refundCents: 16666 }); + }); + + it('a zero-fee ladder refunds in full inside the window', () => { + const free: CancellationPolicy = { + ...POLICY, lateFee: { type: 'percent', percent: 0 }, noShowFee: { type: 'fixed', amountCents: 0 }, + }; + expect(at(h(1), { policy: free })).toMatchObject({ feeCents: 0, refundCents: 45000 }); + }); + + it('measures notice in real hours across a DST boundary', () => { + // 2026-11-01 America/New_York gains an hour. The threshold is hours + // between two INSTANTS, so the answer must not move: 25 elapsed hours + // clears a 24-hour threshold whatever the wall clocks did in between. + const before = Date.UTC(2026, 10, 1, 0, 30); + expect(resolveCancellation({ + policy: POLICY, scheduledAt: before + 25 * 3_600_000, now: before, + priceCents: 45000, paidCents: 45000, initiator: 'client', event: 'cancellation', + })).toMatchObject({ feeCents: 0, reason: 'sufficient_notice' }); + }); + + it('accepts Date instants as readily as epoch milliseconds', () => { + expect(at(h(12))).toEqual(resolveCancellation({ + policy: POLICY, scheduledAt: new Date(h(12)), now: new Date(NOW), + priceCents: 45000, paidCents: 45000, initiator: 'client', event: 'cancellation', + })); + }); +}); From 78e963df8ad7b89154d07c9ec47332348a95b0f9 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 18:44:05 +0800 Subject: [PATCH 51/77] feat(invoices): a partial refund writer, and both refund writers in one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing could return 22500 of 45000. `markRefunded` reverses everything received; `correctPayment` is explicitly not a refund and refuses a second correction on the same row; `markPartial` only reaches a refund as a negative delta from an externally-reported cumulative figure. `refundPartial(db, tenantId, id, {amountCents, reason, recordedBy?, occurredAt?})` appends one refund row and RETURNS it. Returning the row rather than void is the point: `qbo_entity_map` is uniquely indexed on (tenant, oiType, oiId) and holds exactly one credit memo per invoice forever, so a hand-off keyed on the invoice makes a second refund throw INSIDE the push — memo in QuickBooks, map row lost. Per-row identity is the only shape that survives a second refund, and a void return would have recreated the bug one layer up. It refuses to refund more than has been received, and it re-syncs the report gate: a refund can take an invoice out of paid, and a report left publicly readable with no backing payment is what that gate is for. `markRefunded` MOVED here unchanged, same signature (`(db, id, tenantId)` — tenant second, not tidied). It did not fit alongside the new writer in `invoice-payments.service.ts` (359 of 400 lines), and two refund writers in two files is the arrangement where one of them gets the next fix. The move touched no test file: every caller goes through `InvoiceService.markRefunded`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- server/services/invoice-payments.service.ts | 36 +---- server/services/invoice.service.ts | 11 +- server/services/invoice/refund.ts | 149 ++++++++++++++++++++ tests/unit/invoices/partial-refund.spec.ts | 136 ++++++++++++++++++ 4 files changed, 299 insertions(+), 33 deletions(-) create mode 100644 server/services/invoice/refund.ts create mode 100644 tests/unit/invoices/partial-refund.spec.ts diff --git a/server/services/invoice-payments.service.ts b/server/services/invoice-payments.service.ts index dafdd03fb..644677f75 100644 --- a/server/services/invoice-payments.service.ts +++ b/server/services/invoice-payments.service.ts @@ -326,34 +326,8 @@ export async function markPartial( }); } -/** - * 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. - */ -export async function markRefunded( - db: DrizzleD1Database, - id: string, - tenantId: string, -): Promise { - 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 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 syncInspectionPaymentGate(db, tenantId, existing.inspectionId); -} +// `markRefunded` MOVED to `./invoice/refund.ts`, which now holds both refund +// writers. The partial refund the cancellation ladder needs did not fit here +// (this file is near its size ceiling), and putting the two refund writers in +// different files is the arrangement where one of them gets the next fix. +// This module still owns every other read and write of `order_payments`. diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts index 66167a5e6..42f26b67c 100644 --- a/server/services/invoice.service.ts +++ b/server/services/invoice.service.ts @@ -11,6 +11,8 @@ import type { AppendedPayment } from './payment-ledger.service'; import { syncInspectionPaymentGate } from './invoice-payment-gate'; import * as ledger from './invoice-payments.service'; import type { OfflinePaymentInput, PaymentCorrectionInput } from './invoice-payments.service'; +import * as refunds from './invoice/refund'; +import type { PartialRefundInput } from './invoice/refund'; 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'; @@ -172,9 +174,14 @@ export class InvoiceService { return ledger.markPartial(this.getDrizzle(), id, tenantId, source, amountPaidCents); } - /** @see ledger.markRefunded — reverses everything received. */ + /** @see refunds.markRefunded — reverses everything received. */ async markRefunded(id: string, tenantId: string): Promise { - return ledger.markRefunded(this.getDrizzle(), id, tenantId); + return refunds.markRefunded(this.getDrizzle(), id, tenantId); + } + + /** @see refunds.refundPartial — returns the appended row; keys the QBO memo. */ + async refundPartial(tenantId: string, id: string, input: PartialRefundInput): Promise { + return refunds.refundPartial(this.getDrizzle(), tenantId, id, input); } async setQboSyncStatus(id: string, tenantId: string, status: 'synced' | 'pending' | 'failed'): Promise { diff --git a/server/services/invoice/refund.ts b/server/services/invoice/refund.ts new file mode 100644 index 000000000..1dc4b7b3a --- /dev/null +++ b/server/services/invoice/refund.ts @@ -0,0 +1,149 @@ +/** + * Money going back out. + * + * Both refund writers live here, together, on purpose. They were about to be + * split across two files — `markRefunded` where the ledger already was, and + * the partial next to its caller — and two writers of the same concept in two + * places is the shape where one of them gets the next fix and the other + * quietly does not. There is exactly one place to look for "how does this + * product refund". + * + * The house rules both obey: + * + * - **Append, never rewrite.** A refund is a `refund`-kind ROW. The invoice's + * derived columns are recomputed from the ledger by its single writer. + * - **Re-sync the report gate.** `inspections.payment_status = 'paid'` is a + * CACHE of "some unvoided invoice on this inspection is paid", and a refund + * can falsify it. Skipping the re-sync leaves a report publicly readable + * with no backing payment, and no test that ignores `inspections` notices. + * - **Return the row.** `refundPartial` answers with the appended row rather + * than void, because an external book of record has to key its credit memo + * on the ROW id: `qbo_entity_map` is uniquely indexed on + * (tenant, type, oiId) and can hold exactly one credit memo per invoice + * forever, so keying on the invoice makes a second refund throw INSIDE the + * push — the memo lands in QuickBooks and the map row is lost. Per-row + * identity is the only shape that survives a second refund. + */ +import { and, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { invoices } from '../../lib/db/schema/invoice'; +import { Errors } from '../../lib/errors'; +import { + recordPayment, + recomputeInvoicePaymentState, + getNetReceivedCents, + seedLedgerFromInvoiceRecord, +} from '../payment-ledger.service'; +import type { AppendedPayment } from '../payment-ledger.service'; +import { syncInspectionPaymentGate } from '../invoice-payment-gate'; + +/** Body of a partial refund. */ +export interface PartialRefundInput { + /** Positive integer cents. Direction is carried by the row's `kind`. */ + amountCents: number; + /** Why the money went back. Stored on the row and read by humans later. */ + reason: string; + recordedBy?: string | null; + /** When the money MOVED. Defaults to now. */ + occurredAt?: Date; +} + +/** + * Send SOME of the money back. + * + * The gap `markRefunded` cannot fill: it reverses everything received, and + * `correctPayment` is explicitly not a refund (it rewrites a mistyped figure + * and refuses a second correction on the same row). Neither can return 22500 + * of 45000 and leave the rest retained as a cancellation fee. + * + * Refuses to refund more than has been received. That is the one guard that + * matters: without it a refund invents money leaving an account that never + * held it, and the invoice's cached total goes negative in a column whose + * whole contract is that it never is. + */ +export async function refundPartial( + db: DrizzleD1Database, + tenantId: string, + id: string, + input: PartialRefundInput, +): Promise { + 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 (!Number.isInteger(input.amountCents) || input.amountCents <= 0) { + throw Errors.UnprocessableEntity('A refund must be a positive whole number of cents.'); + } + + // An invoice paid before the ledger existed has no rows to reverse, so the + // received figure would read as zero and every refund would be refused. + await seedLedgerFromInvoiceRecord(db, tenantId, id); + const received = await getNetReceivedCents(db, tenantId, id); + if (input.amountCents > received) { + // No figure in the message: it would have to be raw minor units, and + // the surface asking already shows the received total formatted in the + // invoice's own currency. + throw Errors.UnprocessableEntity( + 'This refund is larger than what has been received on this invoice.', + ); + } + + const appended = await recordPayment(db, tenantId, { + invoiceId: id, + inspectionId: existing.inspectionId, + kind: 'refund', + amountCents: input.amountCents, + method: existing.paymentMethod ?? 'other', + // No provider and no provider_ref: this row records that the money is + // owed back, not that a processor has moved it. Whoever moves it keys + // their push off the row returned here. + provider: null, + providerRef: null, + recordedBy: input.recordedBy ?? null, + note: input.reason, + ...(input.occurredAt ? { occurredAt: input.occurredAt } : {}), + }); + // `recordPayment` answers null only for a provider redelivery, and this 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 200. + if (!appended) throw Errors.Conflict('This refund was already recorded.'); + + await syncInspectionPaymentGate(db, tenantId, existing.inspectionId); + return appended; +} + +/** + * Refund an invoice in full: 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. + * + * Signature unchanged from when this lived in `invoice-payments.service` + * (`(db, id, tenantId)`, tenant SECOND) — both are strings and reordering them + * during a move is how the two get swapped in silence. + */ +export async function markRefunded( + db: DrizzleD1Database, + id: string, + tenantId: string, +): Promise { + 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 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 syncInspectionPaymentGate(db, tenantId, existing.inspectionId); +} diff --git a/tests/unit/invoices/partial-refund.spec.ts b/tests/unit/invoices/partial-refund.spec.ts new file mode 100644 index 000000000..918ded590 --- /dev/null +++ b/tests/unit/invoices/partial-refund.spec.ts @@ -0,0 +1,136 @@ +/** + * `refundPartial` — the writer that did not exist. + * + * `markRefunded` reverses everything received and `correctPayment` is + * explicitly not a refund, so nothing could return 22500 of 45000 and leave the + * rest retained. Two properties carry the weight: + * + * - it RETURNS the appended row. An external book of record keys its credit + * memo on that row id, because `qbo_entity_map` holds one memo per + * (tenant, type, oiId) forever and keying on the invoice makes a second + * refund throw inside the push — memo in QuickBooks, map row lost. + * - it re-syncs the report gate. A refund can take an invoice out of paid, and + * a report left publicly readable with no backing payment is the failure the + * gate exists to prevent. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { eq } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { refundPartial } from '../../../server/services/invoice/refund'; +import { recordPayment, getNetReceivedCents } from '../../../server/services/payment-ledger.service'; +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const INSP = 'i-1'; +const INV = 'inv-1'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyDb = any; + +describe('refundPartial', () => { + let testDb: BetterSQLite3Database; + + beforeEach(async () => { + const fix = createTestDb(); + testDb = fix.db; + await setupSchema(fix.sqlite); + await testDb.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await testDb.insert(schema.inspections).values({ + id: INSP, tenantId: TENANT, propertyAddress: '1 St', date: '2026-08-10', + status: 'scheduled', paymentStatus: 'paid', price: 45000, + agreementRequired: false, paymentRequired: true, createdAt: new Date(), + }); + await testDb.insert(schema.invoices).values({ + id: INV, tenantId: TENANT, inspectionId: INSP, amountCents: 45000, + lineItems: [{ description: 'Inspection', amountCents: 45000 }], createdAt: new Date(), + } as never); + }); + + async function collect(amountCents: number) { + await recordPayment(testDb as AnyDb, TENANT, { + invoiceId: INV, inspectionId: INSP, kind: 'balance', amountCents, method: 'card', + }); + } + + async function ledger() { + return testDb.select().from(schema.orderPayments) + .where(eq(schema.orderPayments.invoiceId, INV)).all(); + } + + async function paymentStatus() { + const row = await testDb.select({ p: schema.inspections.paymentStatus }) + .from(schema.inspections).where(eq(schema.inspections.id, INSP)).get(); + return row!.p; + } + + it('appends exactly one refund row and leaves the receipt standing', async () => { + await collect(45000); + await refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 22500, reason: 'Cancellation' }); + + const rows = await ledger(); + expect(rows.filter(r => r.kind === 'refund')).toHaveLength(1); + expect(rows.filter(r => r.kind === 'balance')).toHaveLength(1); + expect(rows.find(r => r.kind === 'refund')!.amountCents).toBe(22500); + expect(await getNetReceivedCents(testDb as AnyDb, TENANT, INV)).toBe(22500); + }); + + it('returns the appended row so a credit memo can be keyed on it', async () => { + await collect(45000); + const appended = await refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 10000, reason: 'x' }); + expect(appended.id).toEqual(expect.any(String)); + expect(appended).toMatchObject({ kind: 'refund', amountCents: 10000 }); + + const rows = await ledger(); + expect(rows.find(r => r.id === appended.id)).toBeDefined(); + }); + + it('gives each refund its own row identity, so a second one is pushable', async () => { + await collect(45000); + const first = await refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 10000, reason: 'a' }); + const second = await refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 5000, reason: 'b' }); + expect(second.id).not.toBe(first.id); + expect(await getNetReceivedCents(testDb as AnyDb, TENANT, INV)).toBe(30000); + }); + + it('re-syncs the report gate when the refund takes the invoice out of paid', async () => { + await collect(45000); + expect(await paymentStatus()).toBe('paid'); + await refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 22500, reason: 'Cancellation' }); + expect(await paymentStatus()).toBe('unpaid'); + }); + + it('refuses to refund more than has been received', async () => { + await collect(9000); + await expect(refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 22500, reason: 'x' })) + .rejects.toThrow(/larger than what has been received/i); + expect((await ledger()).filter(r => r.kind === 'refund')).toHaveLength(0); + }); + + it('refuses a zero or negative refund', async () => { + await collect(9000); + await expect(refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 0, reason: 'x' })) + .rejects.toThrow(/positive whole number/i); + await expect(refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: -100, reason: 'x' })) + .rejects.toThrow(/positive whole number/i); + }); + + it('can reverse an invoice paid before the ledger existed', async () => { + // No ledger rows at all, only the invoice's own paid record. Without + // the seed the received figure reads zero and every refund is refused. + await testDb.update(schema.invoices).set({ paidAt: new Date() }) + .where(eq(schema.invoices.id, INV)); + const appended = await refundPartial(testDb as AnyDb, TENANT, INV, { amountCents: 5000, reason: 'x' }); + expect(appended.amountCents).toBe(5000); + expect(await getNetReceivedCents(testDb as AnyDb, TENANT, INV)).toBe(40000); + }); + + it('will not touch an invoice belonging to another tenant', async () => { + await collect(45000); + await expect(refundPartial(testDb as AnyDb, 'other-tenant', INV, { amountCents: 100, reason: 'x' })) + .rejects.toThrow(/not found/i); + }); +}); From 402a773e635e7a7bd3dbdae7f1a3983e4e1db1e9 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 18:46:38 +0800 Subject: [PATCH 52/77] feat(cancellation): apply the ladder on cancel, and quote it first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /:id/cancellation-quote` computes the outcome without writing anything; `POST /:id/cancel` recomputes it and applies it. One arithmetic, so the number on the confirmation screen and the number charged cannot drift. The acknowledgement is a SERVER rule, not a UI convention: when the quote charges a fee, the cancel refuses with 409 CANCELLATION_FEE_NEEDS_CONFIRM unless the caller echoes back the exact fee it was shown. A cancellation that silently charges 50% is a chargeback, and a UI-only confirmation is skipped by the next surface — a bulk action, an MCP tool, another client. WHO cancelled and WHAT happened are derived from `cancel_reason`, which the cancel path already writes, rather than persisted twice. `no_show` joins the reason set (it existed nowhere and the ladder cannot express a no-show without it) and the set now has a drizzle enum on the column, sourced from the same constant as the wire schema. Every ambiguous reason classifies as inspector-initiated, which charges nothing — a fee the agreement may not support is the one mistake this feature must not make. The quote also carries the processing fee Stripe keeps on a refund, computed on the ORIGINAL charge (Stripe returns none of it, even on a partial refund) and only when the money actually arrived through Stripe. The route moved out of publish.ts into its own sub-router: that file was one line under its cap, and the report lifecycle is not the same concern as the money a cancellation moves. It shrank 520 -> 504 and its baseline is tightened accordingly; `inspection.service.ts` gains one line (a type import) on a grandfathered file where splitting is a refactor this change does not justify. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- scripts/file-size-baseline.json | 4 +- server/api/inspections.ts | 7 +- server/api/inspections/cancellation.ts | 144 +++++++++++++ server/api/inspections/publish.ts | 22 +- server/lib/billing/processing-fee.ts | 30 +++ server/lib/cancellation-reason.ts | 60 ++++++ server/lib/db/schema/inspection/core.ts | 7 +- server/lib/mcp/openapi-snapshot.json | 52 ++++- server/lib/validations/inspection/crud.ts | 20 +- server/services/inspection.service.ts | 3 +- .../inspection/cancellation.service.ts | 158 ++++++++++++++ .../inspection/inspection-status.service.ts | 3 +- .../inspections/cancellation-apply.spec.ts | 194 ++++++++++++++++++ 13 files changed, 667 insertions(+), 37 deletions(-) create mode 100644 server/api/inspections/cancellation.ts create mode 100644 server/lib/billing/processing-fee.ts create mode 100644 server/lib/cancellation-reason.ts create mode 100644 server/services/inspection/cancellation.service.ts create mode 100644 tests/unit/inspections/cancellation-apply.spec.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index f7efd73be..1f935e8b5 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -9,7 +9,7 @@ "server/api/sms.ts": 844, "app/routes/settings-communication.tsx": 777, "server/api/admin/admin-settings.ts": 754, - "server/services/inspection.service.ts": 741, + "server/services/inspection.service.ts": 742, "server/api/inspections/report-delivery.ts": 736, "server/services/inspection/inspection-analytics.service.ts": 729, "app/routes/template-edit.tsx": 719, @@ -37,9 +37,9 @@ "app/routes/settings-communication-templates.tsx": 525, "server/api/portal.ts": 525, "server/services/portal-access.service.ts": 525, - "server/api/inspections/publish.ts": 521, "server/api/bookings/agreement.ts": 519, "app/components/settings/ManagedComplianceWizard.tsx": 514, + "server/api/inspections/publish.ts": 505, "server/api/repair-builder.ts": 504, "app/routes/inspection-edit/action.server.ts": 501, "server/services/report-export-consumer.ts": 499, diff --git a/server/api/inspections.ts b/server/api/inspections.ts index 4d076bf9a..6df9542ee 100644 --- a/server/api/inspections.ts +++ b/server/api/inspections.ts @@ -42,6 +42,7 @@ import communicationRoutes from './inspections/communication'; import inspectionServiceRoutes from './inspections/services'; import inspectionReportRoutes from './inspections/reports'; import paySplitRoutes from './inspections/pay-splits'; +import cancellationRoutes from './inspections/cancellation'; export const inspectionsRoutes = createApiRouter() .route('/', bulkRoutes) @@ -83,6 +84,10 @@ export const inspectionsRoutes = createApiRouter() // because server/index.ts sits at its size cap; and it belongs here anyway, // since every path is per-inspection. Visibility is query scoping inside // the handler, not a capability: an inspector reads only their own row. - .route('/', paySplitRoutes); + .route('/', paySplitRoutes) + // /:id/cancel + /:id/cancellation-quote. Split out of publish.ts: the + // report lifecycle and the money a cancellation moves are different + // concerns, and that file was at its size ceiling. + .route('/', cancellationRoutes); export type InspectionsApi = typeof inspectionsRoutes; diff --git a/server/api/inspections/cancellation.ts b/server/api/inspections/cancellation.ts new file mode 100644 index 000000000..8b5893c5c --- /dev/null +++ b/server/api/inspections/cancellation.ts @@ -0,0 +1,144 @@ +// Cancellation sub-router: the priced quote, and the cancel write that applies +// it. Split out of ./publish.ts, which was at its size ceiling and which owns +// the report lifecycle rather than money. +import { createRoute, z } from '@hono/zod-openapi'; +import { createApiRouter } from '../../lib/openapi-router'; +import { requireRole } from '../../lib/middleware/rbac'; +import { CancelInspectionSchema } from '../../lib/validations/inspection.schema'; +import { CANCELLATION_REASONS } from '../../lib/cancellation-reason'; +import { getTenantId, getDrizzle } from '../../lib/route-helpers'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; +import { quoteCancellation, applyCancellationRefund } from '../../services/inspection/cancellation.service'; + +const CancellationQuoteSchema = z.object({ + feeCents: z.number().int().describe('Kept by the company. Never exceeds what was collected.'), + refundCents: z.number().int().describe('Returned to the payer.'), + reason: z.string().describe('Machine code for WHY, e.g. late_cancellation. Render it in the reader language.'), + cappedAtCollected: z.boolean().describe('The ladder asked for more than was collected and the charge was reduced.'), + priceCents: z.number().int().describe('Authoritative inspection price; a percent fee is a share of this.'), + paidCents: z.number().int().describe('Net received against the invoice.'), + currency: z.string().describe('ISO 4217 for every figure here.'), + retainedProcessingFeeCents: z.number().int() + .describe('Estimated processing fee Stripe keeps on the refund. Not recoverable. Zero for non-card money.'), + policyConfigured: z.boolean().describe('False when the workspace has configured no ladder; nothing is ever charged.'), +}).openapi('CancellationQuote'); + +const quoteRoute = createRoute(withMcpMetadata({ + method: 'get', + path: '/{id}/cancellation-quote', + tags: ['inspections'], + summary: 'Price a cancellation without performing one', + middleware: [requireRole('owner', 'manager', 'inspector')] as const, + request: { + params: z.object({ id: z.string().describe('Inspection id.') }), + query: z.object({ + reason: z.enum(CANCELLATION_REASONS).describe('The reason that would be recorded; it decides the outcome.'), + }), + }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ + success: z.literal(true), + data: CancellationQuoteSchema, + }) } }, + description: 'The computed outcome. Read-only — nothing is cancelled and no money moves.', + }, + }, + operationId: 'getCancellationQuote', + description: 'Computes the fee, the refund and the reason a cancellation would produce, so whoever cancels sees the result before confirming it. Read-only.', +}, { scopes: ['read'], tier: 'extended' })); + +const cancelRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/{id}/cancel', + tags: ['inspections'], + summary: 'Cancel inspection for current tenant', + middleware: [requireRole('owner', 'manager', 'inspector')] as const, + request: { + params: z.object({ id: z.string().describe('Inspection id.') }), + body: { content: { 'application/json': { schema: CancelInspectionSchema } } }, + }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ + success: z.literal(true), + data: z.object({ + outcome: CancellationQuoteSchema, + refundPaymentId: z.string().nullable() + .describe('Ledger row id of the refund appended, or null when nothing was refunded. An external book of record keys its credit memo on THIS, not on the invoice.'), + }), + }) } }, + description: 'Cancelled', + }, + 409: { + content: { 'application/json': { schema: z.object({ + success: z.literal(false), + error: z.object({ + code: z.literal('CANCELLATION_FEE_NEEDS_CONFIRM'), + message: z.string(), + quote: CancellationQuoteSchema, + }), + }) } }, + description: 'This cancellation carries a fee the caller has not acknowledged. Show the quote and resend with acknowledgedFeeCents.', + }, + }, + operationId: 'cancelInspection', + description: 'Cancels an inspection and applies the tenant cancellation policy: keeps the fee the policy allows and appends the refund to the payment ledger. Refuses to charge a fee the caller has not acknowledged.', +}, { scopes: ['write'], tier: 'extended' })); + +const flatten = (q: Awaited>) => ({ + ...q.outcome, + priceCents: q.priceCents, + paidCents: q.paidCents, + currency: q.currency, + retainedProcessingFeeCents: q.retainedProcessingFeeCents, + policyConfigured: q.policyConfigured, +}); + +const cancellationRoutes = createApiRouter() + .openapi(quoteRoute, async (c) => { + const tenantId = getTenantId(c); + const { id } = c.req.valid('param'); + const { reason } = c.req.valid('query'); + const quote = await quoteCancellation(getDrizzle(c), tenantId, id, reason); + return c.json({ success: true as const, data: flatten(quote) }, 200); + }) + .openapi(cancelRoute, async (c) => { + const tenantId = getTenantId(c); + const { id } = c.req.valid('param'); + const { reason, notes, acknowledgedFeeCents } = c.req.valid('json'); + const db = getDrizzle(c); + + // Quoted BEFORE the status write, because the notice window is measured + // against the scheduled instant and the outcome must describe the state + // the caller was looking at. + const quote = await quoteCancellation(db, tenantId, id, reason); + + // A cancellation that silently charges 50% is a chargeback. Making the + // acknowledgement a SERVER rule rather than a UI convention is what + // stops the next surface — a bulk action, an MCP tool, a mobile client + // — from skipping the confirmation screen and charging anyway. + if (quote.outcome.feeCents > 0 && acknowledgedFeeCents !== quote.outcome.feeCents) { + return c.json({ + success: false as const, + error: { + code: 'CANCELLATION_FEE_NEEDS_CONFIRM' as const, + message: 'This cancellation carries a fee under your cancellation policy. Confirm the amount shown to continue.', + quote: flatten(quote), + }, + }, 409); + } + + await c.var.services.inspection.cancelInspection(tenantId, id, reason, notes); + + const userId = (c.get('user') as { sub?: string } | undefined)?.sub ?? null; + const refund = await applyCancellationRefund(db, tenantId, quote, userId); + + return c.json({ + success: true as const, + data: { outcome: flatten(quote), refundPaymentId: refund?.id ?? null }, + }, 200); + }); + +export type InspectionCancellationApi = typeof cancellationRoutes; +export default cancellationRoutes; diff --git a/server/api/inspections/publish.ts b/server/api/inspections/publish.ts index 3d4432ee2..e89776ffd 100644 --- a/server/api/inspections/publish.ts +++ b/server/api/inspections/publish.ts @@ -15,7 +15,7 @@ import { getBookingHost, resolveTenantSlug } from '../../lib/url'; import { buildRenderReportUrl } from '../../lib/public-urls'; import { logger } from '../../lib/logger'; import { createApiResponseSchema, SuccessResponseSchema } from '../../lib/validations/shared.schema'; -import { PublishInspectionSchema, CreateReinspectionSchema, CancelInspectionSchema } from '../../lib/validations/inspection.schema'; +import { PublishInspectionSchema, CreateReinspectionSchema } from '../../lib/validations/inspection.schema'; import { inspections as inspectionTable } from '../../lib/db/schema'; import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; import { REPORT_STATUS } from '../../lib/status/report-status'; @@ -340,24 +340,8 @@ const publishRoutes = createApiRouter() await c.var.services.inspection.confirmInspection(tenantId, id); return c.json({ success: true }); }) - .openapi(createRoute(withMcpMetadata({ - method: 'post', path: '/{id}/cancel', - tags: ["inspections"], summary: "Cancel inspection for current tenant", - middleware: [requireRole('owner', 'manager', 'inspector')] as const, - request: { - params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), - body: { content: { 'application/json': { schema: CancelInspectionSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, - }, - responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Cancelled' } }, - operationId: "cancelInspection", - description: "Auto-generated placeholder for cancelInspection (POST /{id}/cancel, inspections domain). TODO: replace with a real description sourced from the handler." - }, { scopes: ['write'], tier: 'extended' })), async (c) => { - const tenantId = c.get('tenantId'); - const { id } = c.req.valid('param'); - const { reason, notes } = c.req.valid('json'); - await c.var.services.inspection.cancelInspection(tenantId, id, reason, notes); - return c.json({ success: true }); - }) + // POST /{id}/cancel MOVED to ./cancellation.ts, which owns the whole + // cancellation surface: the quote, the fee acknowledgement, and the refund. .openapi(createRoute(withMcpMetadata({ method: 'post', path: '/{id}/uncancel', tags: ["inspections"], summary: "Create inspection uncancel for current tenant", diff --git a/server/lib/billing/processing-fee.ts b/server/lib/billing/processing-fee.ts new file mode 100644 index 000000000..60462ae81 --- /dev/null +++ b/server/lib/billing/processing-fee.ts @@ -0,0 +1,30 @@ +/** + * What the processor keeps when money goes back. + * + * Stripe does not return its processing fee on a refund — including a partial + * one. Refunding $50 of a $100 charge still costs the merchant the fee on the + * full $100. So a tenant who promises "full refund on 24 hours notice" is out + * of pocket by roughly 2.9% + $0.30 of the ORIGINAL charge every time they + * honour it, and they find that out per-cancellation unless someone tells them + * up front. + * + * This is an ESTIMATE and must be presented as one. Stripe's rate is per + * account, per card type and per country — an international card or an + * Amex-heavy book will not match. The number exists to make the loss visible + * before a policy is written, not to reconcile against a statement. + */ + +/** Stripe's standard US card rate at the time of writing. An estimate. */ +const PERCENT_BPS = 290; +const FIXED_CENTS = 30; + +/** + * The non-recoverable processing fee on a charge of `chargedCents`. + * + * Takes the ORIGINAL charge, not the refunded amount: the fee was levied on + * what came in, and refunding part of it recovers none of the fee. + */ +export function estimateRetainedProcessingFeeCents(chargedCents: number): number { + if (chargedCents <= 0) return 0; + return Math.round((chargedCents * PERCENT_BPS) / 10_000) + FIXED_CENTS; +} diff --git a/server/lib/cancellation-reason.ts b/server/lib/cancellation-reason.ts new file mode 100644 index 000000000..51efea371 --- /dev/null +++ b/server/lib/cancellation-reason.ts @@ -0,0 +1,60 @@ +/** + * Why an inspection was cancelled — the single source for the drizzle enum on + * `inspections.cancel_reason`, the wire schema, and the classification the + * cancellation ladder needs. + * + * The ladder is driven by two axes: WHO ended the appointment and WHAT + * happened. Those are not the same question — "the client no-showed" names an + * event with a client on one side and no cancellation at all — and the reason + * the operator already picks encodes both. So nothing new is persisted: the + * axes are DERIVED from `cancel_reason`, which is written by the existing + * cancel path and is the only durable record of the decision. + * + * Every ambiguous reason maps to `inspector`, which charges nothing. That + * direction is deliberate: a fee the agreement may not support is the one + * mistake this feature must not make, so an unclassifiable cancellation costs + * the client nothing rather than defaulting to a charge. + */ +import type { CancellationEvent, CancellationInitiator } from './billing/cancellation-outcome'; + +export const CANCELLATION_REASONS = [ + 'client_cancelled', + 'no_show', + 'weather', + 'inspector_unavailable', + 'property_unavailable', + 'rescheduled', + 'other', +] as const; + +export type CancellationReason = (typeof CANCELLATION_REASONS)[number]; + +interface Classification { + initiator: CancellationInitiator; + event: CancellationEvent; +} + +const CLASSIFICATION: Record = { + // The client called it off. The notice window decides what it costs. + client_cancelled: { initiator: 'client', event: 'cancellation' }, + // Nobody called it off; the client did not turn up. The notice window is + // irrelevant, which is exactly why event is a separate axis. + no_show: { initiator: 'client', event: 'no_show' }, + // A storm is not the client's doing. The company made the call, so the + // company's own always-full-refund rule applies. + weather: { initiator: 'inspector', event: 'cancellation' }, + inspector_unavailable: { initiator: 'inspector', event: 'cancellation' }, + // Access was not provided — the client's side of the appointment failed. + // Distinct from a no-show only in that the client may well have been there. + property_unavailable: { initiator: 'client', event: 'cancellation' }, + // Not really a cancellation: the money follows the job to its new date. + // Charging a late fee for moving an appointment is not a published policy + // anyone has, so this never charges. + rescheduled: { initiator: 'inspector', event: 'cancellation' }, + // Unclassifiable. Charges nothing, on purpose. + other: { initiator: 'inspector', event: 'cancellation' }, +}; + +export function classifyCancellationReason(reason: string): Classification { + return CLASSIFICATION[reason as CancellationReason] ?? CLASSIFICATION.other; +} diff --git a/server/lib/db/schema/inspection/core.ts b/server/lib/db/schema/inspection/core.ts index ae8cbff8d..d5878fff7 100644 --- a/server/lib/db/schema/inspection/core.ts +++ b/server/lib/db/schema/inspection/core.ts @@ -2,6 +2,7 @@ import { sqliteTable, text, integer, real, blob, uniqueIndex, index } from 'driz import { tenants, users } from '../tenant'; import { INSPECTION_STATUSES } from '../../../status/inspection-status'; import { REPORT_STATUSES } from '../../../status/report-status'; +import { CANCELLATION_REASONS } from '../../../cancellation-reason'; import { templates } from './template-rating'; import { discountCodes } from './services'; @@ -42,7 +43,11 @@ export const inspections = sqliteTable('inspections', { createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), // Phase 0 parity additions confirmedAt: integer('confirmed_at', { mode: 'timestamp_ms' }), - cancelReason: text('cancel_reason'), + // The reason drives the cancellation ladder: `classifyCancellationReason` + // (server/lib/cancellation-reason.ts) derives WHO ended the appointment and + // WHAT happened from this one value, so no second column is needed and the + // two can never disagree. Enum is type-layer only, no DDL. + cancelReason: text('cancel_reason', { enum: [...CANCELLATION_REASONS] }), cancelNotes: text('cancel_notes'), // Spec 3A paymentRequired: integer('is_payment_required', { mode: 'boolean' }).notNull().default(false), agreementRequired: integer('is_agreement_required', { mode: 'boolean' }).notNull().default(false), diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 9e3b316ba..bae4c9e89 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -750,10 +750,10 @@ "name": "id", "in": "path", "required": true, - "description": "TODO describe id field for the OpenInspection MCP integration", + "description": "Inspection id.", "schema": { "type": "string", - "description": "TODO describe id field for the OpenInspection MCP integration" + "description": "Inspection id." } } ], @@ -762,7 +762,7 @@ } }, "summary": "Cancel inspection for current tenant", - "description": "Auto-generated placeholder for cancelInspection (POST /{id}/cancel, inspections domain). TODO: replace with a real description sourced from the handler." + "description": "Cancels an inspection and applies the tenant cancellation policy: keeps the fee the policy allows and appends the refund to the payment ledger. Refuses to charge a fee the caller has not acknowledged." }, { "operationId": "cancelTeamInvite", @@ -7222,6 +7222,52 @@ "summary": "Get combined sign & pay checkout context (public, token-gated)", "description": "Combined sign & pay context for the public checkout page (GET /checkout/:token, bookings domain). Resolves a signer token to the agreement snapshot, envelope progress, outstanding invoice/payment state, and tenant branding." }, + { + "operationId": "getCancellationQuote", + "method": "GET", + "pathTemplate": "/api/inspections/{id}/cancellation-quote", + "scopes": [ + "read" + ], + "tag": "inspections", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inspection id.", + "schema": { + "type": "string", + "description": "Inspection id." + } + }, + { + "name": "reason", + "in": "query", + "required": true, + "description": "The reason that would be recorded; it decides the outcome.", + "schema": { + "type": "string", + "enum": [ + "client_cancelled", + "no_show", + "weather", + "inspector_unavailable", + "property_unavailable", + "rescheduled", + "other" + ], + "description": "The reason that would be recorded; it decides the outcome." + } + } + ], + "body": null + }, + "summary": "Price a cancellation without performing one", + "description": "Computes the fee, the refund and the reason a cancellation would produce, so whoever cancels sees the result before confirming it. Read-only." + }, { "operationId": "getCommunicationConfig", "method": "GET", diff --git a/server/lib/validations/inspection/crud.ts b/server/lib/validations/inspection/crud.ts index 99ead2470..6b63aca3a 100644 --- a/server/lib/validations/inspection/crud.ts +++ b/server/lib/validations/inspection/crud.ts @@ -1,6 +1,7 @@ import { z } from '@hono/zod-openapi'; import { createApiResponseSchema } from '../shared.schema'; import { INSPECTION_STATUSES } from '../../status/inspection-status'; +import { CANCELLATION_REASONS } from '../../cancellation-reason'; /** * Core Inspection Schema (Output) @@ -214,18 +215,19 @@ export const BulkInspectionSchema = z.object({ */ export const InspectionListResponseSchema = createApiResponseSchema(z.array(InspectionSchema)).openapi('InspectionListResponse'); -const CancellationReasonSchema = z.enum([ - 'client_cancelled', - 'weather', - 'inspector_unavailable', - 'property_unavailable', - 'rescheduled', - 'other', -]).openapi('CancellationReason'); +// Sourced from the same constant as the drizzle enum on +// `inspections.cancel_reason`, so the wire and the column cannot drift. +const CancellationReasonSchema = z.enum(CANCELLATION_REASONS).openapi('CancellationReason'); export const CancelInspectionSchema = z.object({ - reason: CancellationReasonSchema.describe('TODO describe reason field for the OpenInspection MCP integration'), + reason: CancellationReasonSchema.describe('Why the inspection was cancelled; also classifies the cancellation for the fee ladder.'), notes: z.string().max(500).optional().describe('TODO describe notes field for the OpenInspection MCP integration'), + // The fee the caller was shown and is confirming. A cancellation that + // silently charges 50% is a chargeback, so the server refuses to charge a + // fee the caller has not echoed back: whoever cancels has to have seen the + // number. Omit it when the quote says the cancellation is free. + acknowledgedFeeCents: z.number().int().min(0).optional() + .describe('Fee, in cents, shown to the caller by the cancellation quote. Required when the quote charges one.'), }).openapi('CancelInspectionRequest'); // Round-2 F1 — per-recipient delivery selection. Each recipient row chooses diff --git a/server/services/inspection.service.ts b/server/services/inspection.service.ts index 9c7ab5a48..9af405842 100644 --- a/server/services/inspection.service.ts +++ b/server/services/inspection.service.ts @@ -34,6 +34,7 @@ import { InspectionReportService } from './inspection/inspection-report.service' import { InspectionPublishService } from './inspection/inspection-publish.service'; import { InspectionCoreService } from './inspection/inspection-core.service'; import type { PlanQuotaGuard } from '../features/plan-quota/guard'; +import type { CancellationReason } from '../lib/cancellation-reason'; export { resolveCoverUrl, sanitizeDefectStates, @@ -620,7 +621,7 @@ export class InspectionService { return this.status.confirmInspection(tenantId, id); } - async cancelInspection(tenantId: string, id: string, reason: string, notes?: string): Promise { + async cancelInspection(tenantId: string, id: string, reason: CancellationReason, notes?: string): Promise { return this.status.cancelInspection(tenantId, id, reason, notes); } diff --git a/server/services/inspection/cancellation.service.ts b/server/services/inspection/cancellation.service.ts new file mode 100644 index 000000000..4af07e637 --- /dev/null +++ b/server/services/inspection/cancellation.service.ts @@ -0,0 +1,158 @@ +/** + * Cancelling an inspection, priced. + * + * Two halves, deliberately separate. `quoteCancellation` reads and computes and + * writes NOTHING, so the same function answers "what would this cost" for the + * confirmation screen and "what does this cost" for the write — one arithmetic, + * not two that drift. `applyCancellationRefund` takes a quote and appends the + * ledger row. + * + * The quote is scoped to the inspection's invoice. A booking deposit taken + * before any invoice exists is representable in the ledger (`order_payments` + * allows a null `invoice_id`) but nothing writes one today, and there would be + * no invoice to append the reversal against — so an order with no invoice + * quotes zero collected, which charges nothing and refunds nothing. When the + * deposit path lands, that is the line to revisit. + */ +import { and, desc, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { inspections, tenantConfigs } from '../../lib/db/schema'; +import { inspectionServices } from '../../lib/db/schema/inspection/services'; +import { invoices } from '../../lib/db/schema/invoice'; +import { orderPayments } from '../../lib/db/schema/order-payment'; +import { Errors } from '../../lib/errors'; +import { getEffectivePriceCents } from '../../lib/effective-price'; +import { classifyCancellationReason } from '../../lib/cancellation-reason'; +import { resolveCancellation, type CancellationOutcome } from '../../lib/billing/cancellation-outcome'; +import { estimateRetainedProcessingFeeCents } from '../../lib/billing/processing-fee'; +import { getNetReceivedCents } from '../payment-ledger.service'; +import { refundPartial } from '../invoice/refund'; +import type { AppendedPayment } from '../payment-ledger.service'; + +export interface CancellationQuote { + outcome: CancellationOutcome; + /** The authoritative price, via the money-authority chain. */ + priceCents: number; + /** Net received against the invoice — receipts minus refunds. */ + paidCents: number; + /** Null when the order has no invoice; then nothing can be refunded. */ + invoiceId: string | null; + currency: string; + /** + * What the tenant does NOT get back if they refund. Stripe keeps its + * processing fee on refunds, including partial ones — refunding $50 of a + * $100 charge still costs the merchant the full original fee. Zero unless + * the money actually came in through Stripe; quoting a card fee against a + * cheque would be a scarier number than the truth. + */ + retainedProcessingFeeCents: number; + /** False when the workspace has configured no ladder at all. */ + policyConfigured: boolean; +} + +/** + * Price a cancellation without performing one. Read-only. + * + * `now` is a parameter rather than `Date.now()` so the quote shown on the + * confirmation screen and the quote taken at the write can be compared, and so + * the notice boundary is testable at all. + */ +export async function quoteCancellation( + db: DrizzleD1Database, + tenantId: string, + inspectionId: string, + reason: string, + now: Date = new Date(), +): Promise { + const inspection = await db.select({ + id: inspections.id, + priceCents: inspections.price, + scheduledStartMs: inspections.scheduledStartMs, + }) + .from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) + .get(); + if (!inspection) throw Errors.NotFound('Inspection not found'); + + const config = await db.select({ + cancellationPolicy: tenantConfigs.cancellationPolicy, + currency: tenantConfigs.currency, + }) + .from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, tenantId)) + .get(); + + const invoice = await db.select({ id: invoices.id, amountCents: invoices.amountCents }) + .from(invoices) + .where(and(eq(invoices.tenantId, tenantId), eq(invoices.inspectionId, inspectionId))) + .orderBy(desc(invoices.createdAt)) + .limit(1) + .get(); + + const serviceLines = await db.select({ + priceSnapshot: inspectionServices.priceSnapshot, + priceOverride: inspectionServices.priceOverride, + }) + .from(inspectionServices) + .where(and(eq(inspectionServices.tenantId, tenantId), eq(inspectionServices.inspectionId, inspectionId))) + .all(); + + const priceCents = getEffectivePriceCents({ + invoiceAmountCents: invoice?.amountCents ?? null, + serviceLines, + inspectionPriceCents: inspection.priceCents, + }); + const paidCents = invoice ? await getNetReceivedCents(db, tenantId, invoice.id) : 0; + + const { initiator, event } = classifyCancellationReason(reason); + const policy = config?.cancellationPolicy ?? null; + const outcome = resolveCancellation({ + policy, + scheduledAt: inspection.scheduledStartMs ?? null, + now, + priceCents, + paidCents, + initiator, + event, + }); + + const paidThroughStripe = invoice + ? Boolean(await db.select({ id: orderPayments.id }).from(orderPayments) + .where(and( + eq(orderPayments.tenantId, tenantId), + eq(orderPayments.invoiceId, invoice.id), + eq(orderPayments.provider, 'stripe'), + )) + .limit(1).get()) + : false; + + return { + outcome, + priceCents, + paidCents, + invoiceId: invoice?.id ?? null, + currency: config?.currency ?? 'USD', + retainedProcessingFeeCents: + outcome.refundCents > 0 && paidThroughStripe ? estimateRetainedProcessingFeeCents(paidCents) : 0, + policyConfigured: policy !== null, + }; +} + +/** + * Append the refund a quote calls for. Returns the ledger row so the caller can + * hand its id to an external book of record; null when there is nothing to + * refund, which is the common case. + */ +export async function applyCancellationRefund( + db: DrizzleD1Database, + tenantId: string, + quote: CancellationQuote, + recordedBy: string | null, +): Promise { + if (quote.outcome.refundCents <= 0 || !quote.invoiceId) return null; + return refundPartial(db, tenantId, quote.invoiceId, { + amountCents: quote.outcome.refundCents, + reason: `Cancellation refund (${quote.outcome.reason})`, + recordedBy, + }); +} diff --git a/server/services/inspection/inspection-status.service.ts b/server/services/inspection/inspection-status.service.ts index 63219eb86..1d2b36c9c 100644 --- a/server/services/inspection/inspection-status.service.ts +++ b/server/services/inspection/inspection-status.service.ts @@ -5,6 +5,7 @@ import { fireAutomation } from './shared'; import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; import { REPORT_STATUS } from '../../lib/status/report-status'; import { InspectionSubService } from './base'; +import type { CancellationReason } from '../../lib/cancellation-reason'; /** * Inspection + report status-machine transitions: confirm / cancel / uncancel @@ -33,7 +34,7 @@ export class InspectionStatusService extends InspectionSubService { await fireAutomation(this.db, tenantId, id, 'inspection.confirmed'); } - async cancelInspection(tenantId: string, id: string, reason: string, notes?: string): Promise { + async cancelInspection(tenantId: string, id: string, reason: CancellationReason, notes?: string): Promise { const { db } = await this.fetchForStatusChange(tenantId, id); await db.update(inspections).set({ status: INSPECTION_STATUS.CANCELLED, diff --git a/tests/unit/inspections/cancellation-apply.spec.ts b/tests/unit/inspections/cancellation-apply.spec.ts new file mode 100644 index 000000000..936f8362b --- /dev/null +++ b/tests/unit/inspections/cancellation-apply.spec.ts @@ -0,0 +1,194 @@ +/** + * Applying the cancellation ladder end to end: quote the outcome, then record + * the fee and the refund as ledger rows and NOTHING else. + * + * The assertion that matters most is not the arithmetic — Task 2's spec covers + * that without a database. It is that the money lands as ledger rows, that the + * retained fee is what the invoice ends up showing as received, and that the + * report's payment gate comes back down. A refund that leaves + * `inspections.payment_status = 'paid'` hands out a report with no payment + * behind it, and no test that only reads `order_payments` can see it. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { eq } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { quoteCancellation, applyCancellationRefund } from '../../../server/services/inspection/cancellation.service'; +import { recordPayment, getNetReceivedCents } from '../../../server/services/payment-ledger.service'; +import type { CancellationPolicy } from '../../../server/lib/billing/cancellation-policy'; +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const INSP = 'i-1'; +const INV = 'inv-1'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyDb = any; + +const NOW = new Date(Date.UTC(2026, 7, 6, 12, 0, 0)); +const IN_12H = new Date(NOW.getTime() + 12 * 3_600_000); +const IN_48H = new Date(NOW.getTime() + 48 * 3_600_000); + +const POLICY: CancellationPolicy = { + noticeHours: 24, + lateFee: { type: 'percent', percent: 50 }, + noShowFee: { type: 'percent', percent: 100 }, + remedy: 'refund', +}; + +describe('cancellation — quote and apply', () => { + let testDb: BetterSQLite3Database; + + beforeEach(async () => { + const fix = createTestDb(); + testDb = fix.db; + await setupSchema(fix.sqlite); + await testDb.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + }); + + async function seed(opts: { + policy?: CancellationPolicy | null; + scheduledStartMs?: Date | null; + collectCents?: number; + method?: 'card' | 'check'; + provider?: 'stripe' | null; + } = {}) { + await testDb.insert(schema.tenantConfigs).values({ + tenantId: TENANT, updatedAt: new Date(), + cancellationPolicy: opts.policy === undefined ? POLICY : opts.policy, + } as never); + await testDb.insert(schema.inspections).values({ + id: INSP, tenantId: TENANT, propertyAddress: '1 St', date: '2026-08-07', + status: 'confirmed', paymentStatus: 'paid', price: 45000, + scheduledStartMs: opts.scheduledStartMs === undefined ? IN_12H : opts.scheduledStartMs, + agreementRequired: false, paymentRequired: true, createdAt: new Date(), + } as never); + await testDb.insert(schema.invoices).values({ + id: INV, tenantId: TENANT, inspectionId: INSP, amountCents: 45000, + lineItems: [{ description: 'Inspection', amountCents: 45000 }], createdAt: new Date(), + } as never); + const collect = opts.collectCents ?? 45000; + if (collect > 0) { + await recordPayment(testDb as AnyDb, TENANT, { + invoiceId: INV, inspectionId: INSP, kind: 'balance', amountCents: collect, + method: opts.method ?? 'card', + provider: opts.provider === undefined ? 'stripe' : opts.provider, + providerRef: opts.provider === null ? null : 'pi_1', + }); + } + } + + async function ledger() { + return testDb.select().from(schema.orderPayments) + .where(eq(schema.orderPayments.invoiceId, INV)).all(); + } + + async function paymentStatus() { + const row = await testDb.select({ p: schema.inspections.paymentStatus }) + .from(schema.inspections).where(eq(schema.inspections.id, INSP)).get(); + return row!.p; + } + + it('records the fee and the refund as ledger rows, and nothing else', async () => { + await seed(); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + expect(quote.outcome).toMatchObject({ feeCents: 22500, refundCents: 22500, reason: 'late_cancellation' }); + + await applyCancellationRefund(testDb as AnyDb, TENANT, quote, null); + + const rows = await ledger(); + expect(rows.filter(r => r.kind === 'refund')).toHaveLength(1); + expect(rows.find(r => r.kind === 'refund')!.amountCents).toBe(22500); + expect(rows).toHaveLength(2); + // The retained fee, read back off the ledger rather than asserted twice. + expect(await getNetReceivedCents(testDb as AnyDb, TENANT, INV)).toBe(22500); + }); + + it('takes the report gate back down when the refund unpays the invoice', async () => { + await seed(); + expect(await paymentStatus()).toBe('paid'); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + await applyCancellationRefund(testDb as AnyDb, TENANT, quote, null); + expect(await paymentStatus()).toBe('unpaid'); + }); + + it('refunds everything and keeps nothing when the inspector cancels late', async () => { + await seed(); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'inspector_unavailable', NOW); + expect(quote.outcome).toMatchObject({ feeCents: 0, refundCents: 45000, reason: 'inspector_initiated' }); + await applyCancellationRefund(testDb as AnyDb, TENANT, quote, null); + expect(await getNetReceivedCents(testDb as AnyDb, TENANT, INV)).toBe(0); + }); + + it('classifies a no-show off the recorded reason, without a second column', async () => { + await seed({ scheduledStartMs: new Date(NOW.getTime() - 24 * 3_600_000) }); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'no_show', NOW); + expect(quote.outcome).toMatchObject({ feeCents: 45000, refundCents: 0, reason: 'no_show' }); + expect(await applyCancellationRefund(testDb as AnyDb, TENANT, quote, null)).toBeNull(); + expect((await ledger()).filter(r => r.kind === 'refund')).toHaveLength(0); + }); + + it('appends nothing at all when the workspace has no policy', async () => { + await seed({ policy: null }); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + expect(quote.policyConfigured).toBe(false); + expect(quote.outcome).toMatchObject({ feeCents: 0, refundCents: 45000, reason: 'no_policy' }); + await applyCancellationRefund(testDb as AnyDb, TENANT, quote, null); + // A full refund is still a refund: it IS recorded, because the money + // going back is a fact. What is not recorded is a fee. + expect((await ledger()).filter(r => r.kind === 'refund')).toHaveLength(1); + expect(await getNetReceivedCents(testDb as AnyDb, TENANT, INV)).toBe(0); + }); + + it('charges only what was collected against a deposit, not a share of the price', async () => { + await seed({ collectCents: 9000 }); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + // 50% of the 45000 PRICE is 22500; only 9000 came in, so 9000 is kept. + expect(quote).toMatchObject({ priceCents: 45000, paidCents: 9000 }); + expect(quote.outcome).toMatchObject({ feeCents: 9000, refundCents: 0, cappedAtCollected: true }); + expect(await applyCancellationRefund(testDb as AnyDb, TENANT, quote, null)).toBeNull(); + }); + + it('charges nothing with sufficient notice', async () => { + await seed({ scheduledStartMs: IN_48H }); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + expect(quote.outcome).toMatchObject({ feeCents: 0, reason: 'sufficient_notice' }); + }); + + it('charges nothing when the order has no precise scheduled instant', async () => { + await seed({ scheduledStartMs: null }); + const quote = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + expect(quote.outcome).toMatchObject({ feeCents: 0, reason: 'no_scheduled_instant' }); + }); + + it('quotes the processing fee Stripe keeps, and only for card money', async () => { + await seed(); + const card = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + // 2.9% + 30c of the ORIGINAL 45000 charge, not of the 22500 refunded. + expect(card.retainedProcessingFeeCents).toBe(1335); + }); + + it('quotes no processing fee against money that never went through Stripe', async () => { + await seed({ method: 'check', provider: null }); + const cheque = await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'client_cancelled', NOW); + expect(cheque.outcome.refundCents).toBe(22500); + expect(cheque.retainedProcessingFeeCents).toBe(0); + }); + + it('quotes without writing anything', async () => { + await seed(); + const before = await ledger(); + await quoteCancellation(testDb as AnyDb, TENANT, INSP, 'no_show', NOW); + expect(await ledger()).toHaveLength(before.length); + expect(await paymentStatus()).toBe('paid'); + }); + + it('will not quote an inspection belonging to another tenant', async () => { + await seed(); + await expect(quoteCancellation(testDb as AnyDb, 'other-tenant', INSP, 'client_cancelled', NOW)) + .rejects.toThrow(/not found/i); + }); +}); From 7880b2541fec881cd8636f2b6b82bc00aa3f5c07 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 18:57:09 +0800 Subject: [PATCH 53/77] chore(cancellation): three exports nothing imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dead-code gate caught them and they were all avoidable rather than baseline material: `CancellationPolicySchema` is only composed into `UpdateBrandingSchema` in the same file, `CancellationReasonCode` reaches consumers structurally through `CancellationOutcome['reason']`, and no sibling inspection sub-router publishes an `...Api` type — the merged RPC type comes from `server/api/inspections.ts`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- server/api/inspections/cancellation.ts | 3 ++- server/lib/billing/cancellation-outcome.ts | 2 +- server/lib/validations/admin/settings.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/api/inspections/cancellation.ts b/server/api/inspections/cancellation.ts index 8b5893c5c..f2ac61878 100644 --- a/server/api/inspections/cancellation.ts +++ b/server/api/inspections/cancellation.ts @@ -140,5 +140,6 @@ const cancellationRoutes = createApiRouter() }, 200); }); -export type InspectionCancellationApi = typeof cancellationRoutes; +// No `...Api` type export: no sibling inspection sub-router has one, and the +// merged RPC type is published by `server/api/inspections.ts`. export default cancellationRoutes; diff --git a/server/lib/billing/cancellation-outcome.ts b/server/lib/billing/cancellation-outcome.ts index 1539f6168..d974976f2 100644 --- a/server/lib/billing/cancellation-outcome.ts +++ b/server/lib/billing/cancellation-outcome.ts @@ -33,7 +33,7 @@ export type CancellationEvent = 'cancellation' | 'no_show'; * UI that has to render it in the reader's language, and an English sentence * baked in here would be untranslatable by construction. */ -export type CancellationReasonCode = +type CancellationReasonCode = /** The company cancelled. Always a full refund; not configurable. */ | 'inspector_initiated' /** No ladder configured. The platform charges nothing. */ diff --git a/server/lib/validations/admin/settings.ts b/server/lib/validations/admin/settings.ts index 031887e49..250cc704a 100644 --- a/server/lib/validations/admin/settings.ts +++ b/server/lib/validations/admin/settings.ts @@ -28,7 +28,7 @@ const CancellationFeeSchema = z.discriminatedUnion('type', [ * The ladder itself. Hours only in v1 — see the column comment for why * "2 business days" is deferred rather than approximated. */ -export const CancellationPolicySchema = z.object({ +const CancellationPolicySchema = z.object({ noticeHours: z.number().int().min(0).max(720).openapi({ example: 24 }) .describe('Notice threshold in hours. Cancelling with at least this much notice is free.'), lateFee: CancellationFeeSchema.describe('Charged when the client cancels inside the notice window.'), From 519fc89daab8f475ebb7d05b602244cf57dcc39e Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 19:02:37 +0800 Subject: [PATCH 54/77] chore(gate): tighten the ratchet on inspection-core, which I left loose Today's extraction took it 1131 -> 464 but left its cap at 1132, on an instruction meant to stop agents re-baselining files they had shrunk. For a file still over 400 that reading is wrong: it has to stay listed, and listing it at 1132 leaves 667 lines of silent growth. Files that drop UNDER 400 leave the list; files that stay over it get their new size. Both directions are the ratchet tightening. --- scripts/file-size-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 1f935e8b5..28e742b5f 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -1,7 +1,6 @@ { "app/routes/inspection-edit.tsx": 2530, "app/routes/inspector-portal.tsx": 1258, - "server/services/inspection/inspection-core.service.ts": 1132, "server/services/inspection/inspection-report.service.ts": 952, "server/durable-objects/inspection-doc.ts": 928, "app/routes/inspections.tsx": 879, @@ -47,6 +46,7 @@ "server/api/bookings.ts": 477, "server/api/admin/admin-config.ts": 472, "server/lib/compliance/erasure-orchestrator.ts": 472, + "server/services/inspection/inspection-core.service.ts": 465, "app/components/inspection/PeopleEditor.tsx": 457, "app/components/editor/CostItemsPanel.tsx": 449, "server/portal/integration.routes.ts": 441, From 2300e0bcbebbf467803edaf6db4d6f2bd3b949e3 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 19:22:35 +0800 Subject: [PATCH 55/77] schema(deposit): three tiers, and a flag so tier 3 survives a re-resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ type, value }` is the shape this was specified in and not the shape it ships in. A bare `value` on a money field has no unit — 50 is half the price under one type and fifty cents under another — which is the exact correction `CancellationFee` and `service_pay_rules` each already made. The fields name their own units. It is one refined object rather than the discriminated union that reasoning would otherwise produce, because `service.schema.ts` records a MEASURED type-check heap death from union members crossing hono/client on these same services routes. The order rule is the part worth reading twice. A workspace default applies ONCE to an order, not once per line, or a flat $100 deposit becomes $300 on a three-service booking. But applying it to the whole order total instead re-charges the lines that said `{ type: 'none' }` — and opting out is the only reason tier 2 exists. So the default covers what no service has spoken for, and the services that price their own deposit are added to it. Both failures are asserted. `none` is a value, not an absence: NULL already means inherit. Tier 3 gets a flag rather than one clever column. `deposit_required_cents` alone cannot tell a computed snapshot from a figure an operator agreed on the phone, so a later re-resolve would overwrite the second in silence. All four columns append at table END — `inspections`, `services` and `tenant_configs` are all FK-referenced, and a mid-table insert makes drizzle rebuild the table, which loses tables on remote D1 without `db:check` saying a word. Two files had no room for any of this and neither baseline moved. The discount codes were interleaved through `ServiceService` rather than grouped, so they are now one file like qualification and pay-rules already are; the three soft references an inspection PATCH can dangle were three look-alike blocks in a route handler and are now one unit, where whoever adds the fourth will find them. --- migrations/0042_acoustic_khan.sql | 4 + migrations/meta/0042_snapshot.json | 10854 ++++++++++++++++ migrations/meta/_journal.json | 7 + server/api/inspections/core.ts | 66 +- server/api/inspections/patch-guards.ts | 82 + server/lib/billing/deposit-policy.ts | 123 + server/lib/db/schema/inspection/core.ts | 24 + server/lib/db/schema/inspection/services.ts | 12 + server/lib/db/schema/tenant/core.ts | 14 + server/lib/validations/admin/settings.ts | 8 + .../lib/validations/deposit-policy.schema.ts | 65 + server/lib/validations/inspection/crud.ts | 6 + server/lib/validations/service.schema.ts | 9 + server/services/service.service.ts | 113 +- server/services/service/discount-codes.ts | 124 + tests/helpers/inline-ddl.ts | 2 +- .../unit/bookings/deposit-resolution.spec.ts | 140 + 17 files changed, 11517 insertions(+), 136 deletions(-) create mode 100644 migrations/0042_acoustic_khan.sql create mode 100644 migrations/meta/0042_snapshot.json create mode 100644 server/api/inspections/patch-guards.ts create mode 100644 server/lib/billing/deposit-policy.ts create mode 100644 server/lib/validations/deposit-policy.schema.ts create mode 100644 server/services/service/discount-codes.ts create mode 100644 tests/unit/bookings/deposit-resolution.spec.ts diff --git a/migrations/0042_acoustic_khan.sql b/migrations/0042_acoustic_khan.sql new file mode 100644 index 000000000..ebcc54596 --- /dev/null +++ b/migrations/0042_acoustic_khan.sql @@ -0,0 +1,4 @@ +ALTER TABLE `inspections` ADD `deposit_required_cents` integer;--> statement-breakpoint +ALTER TABLE `inspections` ADD `is_deposit_overridden` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `services` ADD `deposit_policy` text;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `deposit_policy` text; \ No newline at end of file diff --git a/migrations/meta/0042_snapshot.json b/migrations/meta/0042_snapshot.json new file mode 100644 index 000000000..e226da3f2 --- /dev/null +++ b/migrations/meta/0042_snapshot.json @@ -0,0 +1,10854 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d47143cb-7f09-4b56-8a4d-c36969041c82", + "prevId": "c6ec1626-1db2-4dd4-bf6d-bf5e6b550be7", + "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_service_pay_splits": { + "name": "inspection_service_pay_splits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_service_id": { + "name": "inspection_service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrects_split_id": { + "name": "corrects_split_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "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_pay_split_line_user": { + "name": "uq_pay_split_line_user", + "columns": [ + "tenant_id", + "inspection_service_id", + "user_id" + ], + "isUnique": true, + "where": "corrects_split_id IS NULL" + }, + "idx_pay_split_user": { + "name": "idx_pay_split_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_pay_split_line": { + "name": "idx_pay_split_line", + "columns": [ + "tenant_id", + "inspection_service_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "deposit_required_cents": { + "name": "deposit_required_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deposit_overridden": { + "name": "is_deposit_overridden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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": {} + }, + "service_pay_rules": { + "name": "service_pay_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_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 + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deduction_cents": { + "name": "deduction_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_service_pay_rules_user": { + "name": "uq_service_pay_rules_user", + "columns": [ + "tenant_id", + "service_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "uq_service_pay_rules_default": { + "name": "uq_service_pay_rules_default", + "columns": [ + "tenant_id", + "service_id" + ], + "isUnique": true, + "where": "user_id IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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 + }, + "deposit_policy": { + "name": "deposit_policy", + "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'" + }, + "cancellation_policy": { + "name": "cancellation_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_agreement_id": { + "name": "cancellation_clause_agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_version": { + "name": "cancellation_clause_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_attested_at": { + "name": "cancellation_clause_attested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deposit_policy": { + "name": "deposit_policy", + "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 + }, + "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 9a74318c4..87ef4ed72 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -295,6 +295,13 @@ "when": 1786010842389, "tag": "0041_medical_prima", "breakpoints": true + }, + { + "idx": 42, + "version": "6", + "when": 1786014741502, + "tag": "0042_acoustic_khan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/inspections/core.ts b/server/api/inspections/core.ts index 5873e914b..84cc329d2 100644 --- a/server/api/inspections/core.ts +++ b/server/api/inspections/core.ts @@ -16,11 +16,12 @@ import { getInspectionRoster } from '../../lib/inspection/roster'; import { createApiResponseSchema, SuccessResponseSchema } from '../../lib/validations/shared.schema'; import { InspectionSchema, CreateInspectionSchema, UpdateInspectionSchema } from '../../lib/validations/inspection.schema'; import { CreateInspectionFromWizardSchema } from '../../lib/validations/wizard.schema'; -import { inspections as inspectionTable, inspectionResults, users } from '../../lib/db/schema'; +import { inspections as inspectionTable, inspectionResults } from '../../lib/db/schema'; import { datePatchValues } from '../../services/inspection/reschedule-date'; +import { findPatchRefusal } from './patch-guards'; import { deleteInspectionCascade } from '../../services/inspection/inspection-cascade'; import { syncAssignmentsAndSplits } from '../../services/pay-split.service'; -import { eq, and, isNull } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { withMcpMetadata } from '../../lib/route-metadata-standards'; import type { HonoConfig } from '../../types/hono'; import { logger } from '../../lib/logger'; @@ -298,55 +299,28 @@ const coreRoutes = createApiRouter() const { inspection } = await c.var.services.inspection.getInspection(id, tenantId); - // DB-16 — coverPhotoId holds the R2 key of a photo belonging to THIS - // inspection (an attached item photo or a loose pool photo); null clears - // the cover. Reject foreign/dangling keys so the preflight gate + report - // renderer can always resolve the image. - if (typeof body.coverPhotoId === 'string') { - const ok = await c.var.services.inspection.isInspectionPhotoKey(id, tenantId, body.coverPhotoId); - if (!ok) { - return c.json({ success: false as const, error: { code: 'INVALID_COVER_PHOTO', message: 'coverPhotoId does not reference a photo of this inspection' } }, 400); - } - } - - // `inspectorId` names a row in `users`, and nothing downstream re-checks - // it: the value is written straight onto the inspection and mirrored - // into the assignment link table. A format check can't stand in for a - // membership check — a UUID from another tenant is still a UUID — so - // resolve it inside the caller's tenant, exactly as the sibling people - // route re-resolves contactId and roleProfileId before linking them. - if (typeof body.inspectorId === 'string') { - const member = await db.select({ id: users.id }).from(users) - .where(and( - eq(users.id, body.inspectorId), - eq(users.tenantId, tenantId), - isNull(users.deletedAt), - )) - .limit(1).get(); - if (!member) { - return c.json({ success: false as const, error: { code: 'INVALID_INSPECTOR', message: 'inspectorId is not a member of this tenant' } }, 400); - } - } - - // Task 8 — a referrer must be one of THIS tenant's contacts. Reject a - // foreign or unknown id with a 400 rather than writing a dangling soft - // reference (the column has no FK by Schema Rules, so the app layer is - // the only guard). - if (typeof body.referredByContactId === 'string' && body.referredByContactId) { - const { contacts } = await import('../../lib/db/schema'); - const owner = await db.select({ id: contacts.id }).from(contacts) - .where(and(eq(contacts.id, body.referredByContactId), eq(contacts.tenantId, tenantId))) - .get(); - if (!owner) { - return c.json({ success: false as const, error: { code: 'INVALID_REFERRER', message: 'referredByContactId is not a contact in this tenant' } }, 400); - } - } + // Every soft reference this patch can dangle, resolved inside the + // caller's tenant before anything is written. See ./patch-guards. + const refusal = await findPatchRefusal( + db, tenantId, id, body, + (i, t, key) => c.var.services.inspection.isInspectionPhotoKey(i, t, key), + ); + if (refusal) return c.json({ success: false as const, error: refusal }, 400); // A date PATCH moves the scheduled instant with the civil day // (§7.5 item 3) — see services/inspection/reschedule-date.ts. const updateValues: Record = typeof body.date === 'string' ? await datePatchValues(db, tenantId, id, body as Record & { date: string }) - : body; + : { ...body }; + + // Deposit tier 3. The FLAG is the whole point: `deposit_required_cents` + // alone cannot tell a computed snapshot from a figure an operator + // agreed on the phone, so a later re-resolve would silently overwrite + // the second. Clearing the amount clears the override with it — + // otherwise the order stays pinned to a number that no longer exists. + if ('depositRequiredCents' in body) { + updateValues.depositOverridden = body.depositRequiredCents != null; + } // Tenant-ownership pre-check above guards access. The validated `body` // can legitimately be empty: the settings sheet forwards its whole form diff --git a/server/api/inspections/patch-guards.ts b/server/api/inspections/patch-guards.ts new file mode 100644 index 000000000..f00b2bc56 --- /dev/null +++ b/server/api/inspections/patch-guards.ts @@ -0,0 +1,82 @@ +/** + * The soft references an inspection PATCH can dangle, re-resolved before the write. + * + * Three fields on `UpdateInspection` name a row in another table — + * `coverPhotoId`, `inspectorId`, `referredByContactId` — and Schema Rules + * forbid new foreign keys, so nothing in the database objects to a value that + * points nowhere. A format check is not a substitute either: a UUID from + * another tenant is still a UUID. Each one has to be resolved INSIDE the + * caller's tenant, and that is what this does. + * + * They are one unit because they fail the same way and are forgotten the same + * way: whoever adds the fourth such field will find three neighbours here + * rather than three look-alike blocks strung through a route handler. + * + * Returns the refusal (code + message) or null. Deliberately does not build the + * Response — the route owns its own status codes and envelope shape. + */ +import { and, eq, isNull } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { users } from '../../lib/db/schema'; + +export interface PatchRefusal { + code: 'INVALID_COVER_PHOTO' | 'INVALID_INSPECTOR' | 'INVALID_REFERRER'; + message: string; +} + +/** Only the three fields this checks; the rest of the body is none of its business. */ +interface GuardedPatch { + coverPhotoId?: string | null | undefined; + inspectorId?: string | null | undefined; + referredByContactId?: string | null | undefined; +} + +export async function findPatchRefusal( + db: DrizzleD1Database, + tenantId: string, + inspectionId: string, + body: GuardedPatch, + // Ownership of a photo key is an inspection-service question (it spans the + // item photos and the loose pool), so it stays where that knowledge lives. + isInspectionPhotoKey: (id: string, tenantId: string, key: string) => Promise, +): Promise { + // DB-16 — coverPhotoId holds the R2 key of a photo belonging to THIS + // inspection (an attached item photo or a loose pool photo); null clears + // the cover. Reject foreign/dangling keys so the preflight gate + report + // renderer can always resolve the image. + if (typeof body.coverPhotoId === 'string') { + if (!(await isInspectionPhotoKey(inspectionId, tenantId, body.coverPhotoId))) { + return { code: 'INVALID_COVER_PHOTO', message: 'coverPhotoId does not reference a photo of this inspection' }; + } + } + + // `inspectorId` names a row in `users`, and nothing downstream re-checks + // it: the value is written straight onto the inspection and mirrored into + // the assignment link table. + if (typeof body.inspectorId === 'string') { + const member = await db.select({ id: users.id }).from(users) + .where(and( + eq(users.id, body.inspectorId), + eq(users.tenantId, tenantId), + isNull(users.deletedAt), + )) + .limit(1).get(); + if (!member) { + return { code: 'INVALID_INSPECTOR', message: 'inspectorId is not a member of this tenant' }; + } + } + + // Task 8 — a referrer must be one of THIS tenant's contacts. Refuse a + // foreign or unknown id rather than writing a dangling soft reference. + if (typeof body.referredByContactId === 'string' && body.referredByContactId) { + const { contacts } = await import('../../lib/db/schema'); + const owner = await db.select({ id: contacts.id }).from(contacts) + .where(and(eq(contacts.id, body.referredByContactId), eq(contacts.tenantId, tenantId))) + .get(); + if (!owner) { + return { code: 'INVALID_REFERRER', message: 'referredByContactId is not a contact in this tenant' }; + } + } + + return null; +} diff --git a/server/lib/billing/deposit-policy.ts b/server/lib/billing/deposit-policy.ts new file mode 100644 index 000000000..fe563586c --- /dev/null +++ b/server/lib/billing/deposit-policy.ts @@ -0,0 +1,123 @@ +/** + * What a client pays up front to hold the slot. + * + * Pure — no DB, no clock. A deposit is a SCHEDULING instrument: it exists so a + * no-show costs the client something, not to finance the work. Nothing here + * charges anything; it answers "how much", and the booking path snapshots the + * answer. + * + * THREE TIERS, and the middle one sets a RATE, not a row. + * + * 1. the workspace default (`tenant_configs.deposit_policy`) + * 2. the service's own policy (`services.deposit_policy`), NULL = inherit + * 3. a human's number on one booking (`inspections.deposit_required_cents` + * with `deposit_overridden`), which nothing here computes — the whole + * point of tier 3 is that it is not computed. + * + * `none` is a VALUE, not an absence. A workspace that requires 20% still has + * one add-on it never charges for, and `{ type: 'none' }` on that service is + * how it says so. NULL means "inherit"; the two are different answers. + * + * ON THE UNIT CONTRACT. `{ type, value }` is the shape this was specified in + * and it is not the shape it ships in: a bare `value` on a money field has no + * unit, so `50` is half the price under one `type` and fifty cents under + * another, and nothing in the type system objects. The fields name their own + * units instead — the same correction `CancellationFee` (next door) and + * `service_pay_rules` (validations/service.schema.ts) both already made. + * + * It is a plain object with a `type` enum rather than a discriminated union, + * which is what the same lesson would otherwise suggest, and that is + * deliberate: this type crosses `hono/client` on the services routes, and + * `service.schema.ts` records a MEASURED type-check heap death from exactly + * that (six union members through the RPC type took `type-check:app` past 8 GB + * with no error to read). One object plus a cross-field refinement enforces the + * identical contract at one plain object's type cost. + */ + +export interface DepositPolicy { + /** + * `none` charges nothing — as an OVERRIDE it is how a service opts out of a + * workspace default that would otherwise apply to it. + */ + type: 'none' | 'percent' | 'fixed'; + /** Whole percent, 0-100. Meaningful only when `type` is 'percent'. */ + percent?: number | undefined; + /** Integer cents. Meaningful only when `type` is 'fixed'. */ + amountCents?: number | undefined; +} + +/** One selected service, with whatever policy it carries of its own. */ +export interface DepositLine { + priceCents: number; + /** NULL = inherit the workspace default. */ + policy: DepositPolicy | null; +} + +/** + * One policy against one price. Never more than the price: a fixed $200 deposit + * against a $150 add-on is a deposit of $150, not a $50 receivable nobody + * agreed to. Rounded to whole cents, because that is the only unit money moves + * in. + */ +function chargeAgainst(policy: DepositPolicy, priceCents: number): number { + const price = Math.max(0, Math.round(priceCents)); + if (policy.type === 'none') return 0; + const wanted = policy.type === 'fixed' + ? Math.round(policy.amountCents ?? 0) + : Math.round((price * (policy.percent ?? 0)) / 100); + return Math.min(Math.max(0, wanted), price); +} + +/** + * The deposit for ONE line: the service's own policy when it has one, the + * workspace default otherwise, nothing when neither exists. + */ +export function resolveDeposit(input: { + tenant: DepositPolicy | null; + service: DepositPolicy | null; + priceCents: number; +}): number { + const policy = input.service ?? input.tenant; + if (!policy) return 0; + return chargeAgainst(policy, input.priceCents); +} + +/** + * The deposit for a whole ORDER — one number, pinned to the primary inspection, + * never mapped per service. A multi-service booking is one order with ancillary + * services beneath it (the competitor model, and the reason the N-inspections + * shape we chose internally must not leak into the money). + * + * The workspace default is applied ONCE, to the part of the order no service + * has spoken for; services that carry their own policy are resolved + * individually and added. Both halves are load-bearing: + * + * - Applying the workspace default per line would turn a flat "$100 deposit" + * into $300 on a three-service booking. It is one number for the order. + * - Applying it to the WHOLE order would silently re-charge the lines that + * opted out with `{ type: 'none' }`, which is the one thing tier 2 exists + * to allow. + * + * When every policy in play is a percentage the two readings agree, which is + * the common case; they diverge exactly where the tenant has configured a + * difference, and then this is the reading that honours it. + */ +export function resolveOrderDeposit(input: { + tenant: DepositPolicy | null; + lines: DepositLine[]; +}): number { + const { tenant, lines } = input; + let owed = 0; + let unspokenForCents = 0; + let totalCents = 0; + for (const line of lines) { + const price = Math.max(0, Math.round(line.priceCents)); + totalCents += price; + if (line.policy) owed += resolveDeposit({ tenant: null, service: line.policy, priceCents: price }); + else unspokenForCents += price; + } + if (unspokenForCents > 0) { + owed += resolveDeposit({ tenant, service: null, priceCents: unspokenForCents }); + } + return Math.min(owed, totalCents); +} diff --git a/server/lib/db/schema/inspection/core.ts b/server/lib/db/schema/inspection/core.ts index d5878fff7..20b66b218 100644 --- a/server/lib/db/schema/inspection/core.ts +++ b/server/lib/db/schema/inspection/core.ts @@ -241,6 +241,30 @@ export const inspections = sqliteTable('inspections', { // NULL = the lines have not been turned into deliverables yet. // Appended at table end for D1 rebuild safety. reportsGeneratedAt: integer('reports_generated_at', { mode: 'timestamp_ms' }), + // What this ORDER was asked for up front, frozen at booking. + // + // A SNAPSHOT, not a policy reference. A percentage resolves against the + // catalogue price on the day; if the tenant reprices the service next week, + // the client still owes what they agreed to. NULL = no deposit was asked + // for. On a multi-service booking this number lives on the PRIMARY + // inspection and the siblings carry 0 — one deposit per order, because the + // N-inspections shape is our own modelling choice and the money should not + // inherit it. + // + // This is the amount OWED, never the amount PAID: what was actually + // collected is `order_payments` rows with `kind = 'deposit'`. A declined + // card leaves this set and the ledger empty, which is exactly the state the + // tenant needs to see. + // Appended at table end for D1 rebuild safety. + depositRequiredCents: integer('deposit_required_cents'), + // Tier 3 — a human set the number above, so nothing may recompute it. + // + // Without this flag one column has to mean two things, and a later + // re-resolve silently overwrites the figure an operator agreed with a + // client over the phone. Same marker the pay splits adopted, for the same + // reason. + // Appended at table end for D1 rebuild safety. + depositOverridden: integer('is_deposit_overridden', { mode: 'boolean' }).notNull().default(false), }, (t) => [ index('idx_inspections_tenant').on(t.tenantId), index('idx_inspections_request').on(t.requestId), diff --git a/server/lib/db/schema/inspection/services.ts b/server/lib/db/schema/inspection/services.ts index 4def59ff7..6368989ee 100644 --- a/server/lib/db/schema/inspection/services.ts +++ b/server/lib/db/schema/inspection/services.ts @@ -4,6 +4,7 @@ import { tenants } from '../tenant'; import { templates } from './template-rating'; import { agreements } from './agreements'; import { inspections } from './core'; +import type { DepositPolicy } from '../../../billing/deposit-policy'; export const services = sqliteTable('services', { id: text('id').primaryKey(), @@ -30,6 +31,17 @@ export const services = sqliteTable('services', { // proposal. // Appended at table end for D1 rebuild safety. defaultEventTypeSlugs: text('default_event_type_slugs', { mode: 'json' }).$type(), + // Tier 2 of the booking deposit — this service's own answer, overriding the + // workspace default in `tenant_configs.deposit_policy`. + // + // NULL and `{ type: 'none' }` are DIFFERENT answers and the distinction is + // the reason this column is nullable rather than defaulted: NULL inherits, + // `none` opts out. A workspace that takes 20% on everything still has one + // $95 add-on it never asks a deposit for, and there is no way to say that + // without both values. + // Appended at table end for D1 rebuild safety (`services` is FK-referenced + // by inspection_services and service_inspectors). + depositPolicy: text('deposit_policy', { mode: 'json' }).$type(), }, (t) => [ index('idx_services_tenant').on(t.tenantId), ]); diff --git a/server/lib/db/schema/tenant/core.ts b/server/lib/db/schema/tenant/core.ts index f3a126d06..318654da3 100644 --- a/server/lib/db/schema/tenant/core.ts +++ b/server/lib/db/schema/tenant/core.ts @@ -2,6 +2,7 @@ import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core' import { sql } from 'drizzle-orm'; import type { ReportLinkTtl } from '../../../report-link-ttl'; import type { CancellationPolicy } from '../../../billing/cancellation-policy'; +import type { DepositPolicy } from '../../../billing/deposit-policy'; export const tenants = sqliteTable('tenants', { id: text('id').primaryKey(), @@ -294,6 +295,19 @@ export const tenantConfigs = sqliteTable('tenant_configs', { cancellationClauseAgreementId: text('cancellation_clause_agreement_id'), cancellationClauseVersion: integer('cancellation_clause_version'), cancellationClauseAttestedAt: integer('cancellation_clause_attested_at', { mode: 'timestamp_ms' }), + // Tier 1 of the booking deposit: what the workspace asks for up front on + // any service that does not say otherwise. NULL = no deposit anywhere, + // which is how every workspace ships — nothing changes for an existing + // tenant until they opt in. + // + // Tier 2 is the same shape on `services`; tier 3 is + // `inspections.deposit_required_cents` + `deposit_overridden`. The + // arithmetic that combines them is `lib/billing/deposit-policy.ts`, and it + // is pure precisely so the number a client is quoted and the number they + // are charged come from one function. + // Appended at END of the table per the D1 add-column-at-end rule + // (tenant_configs is FK-referenced). + depositPolicy: text('deposit_policy', { mode: 'json' }).$type(), }); /** diff --git a/server/lib/validations/admin/settings.ts b/server/lib/validations/admin/settings.ts index 250cc704a..3c74d9213 100644 --- a/server/lib/validations/admin/settings.ts +++ b/server/lib/validations/admin/settings.ts @@ -3,6 +3,7 @@ import { createApiResponseSchema } from '../shared.schema'; import { isValidTimeZone } from '../../tz'; import { isValidLocale } from '../../locale'; import { DATE_FORMATS, TIME_FORMATS } from '../../session/display-prefs'; +import { DepositPolicySchema } from '../deposit-policy.schema'; /** * One rung of the cancellation ladder. @@ -120,6 +121,13 @@ export const UpdateBrandingSchema = z.object({ // attesting can be one save. attestCancellationClause: z.string().min(1).nullable().optional() .describe("Agreement template id the tenant attests contains their cancellation clause; null withdraws it."), + // Tier 1 of the booking deposit — the workspace default. `null` clears it + // back to "no deposit anywhere", which is where every workspace starts. + // `.optional()` with NO `.default()`, for the same reason the policy above + // has none: a default would make a save that never mentions the deposit + // silently wipe a configured one. + depositPolicy: DepositPolicySchema.nullable().optional() + .describe('Default deposit taken at booking; null clears it. A service may override or opt out.'), }).openapi('UpdateBranding'); /** diff --git a/server/lib/validations/deposit-policy.schema.ts b/server/lib/validations/deposit-policy.schema.ts new file mode 100644 index 000000000..1a35fe7b4 --- /dev/null +++ b/server/lib/validations/deposit-policy.schema.ts @@ -0,0 +1,65 @@ +import { z } from '@hono/zod-openapi'; + +/** + * The wire shape of a deposit policy — tier 1 (`UpdateBranding`) and tier 2 + * (`CreateService` / `UpdateService`) share it, because they are the same + * question asked at two scopes and two schemas would drift. + * + * The unit lives in the FIELD NAME, never in a bare `value` whose meaning + * depends on a sibling: `50` is half the price under one type and fifty cents + * under another, and no type system objects. See `lib/billing/deposit-policy.ts` + * for why this is one refined object rather than the discriminated union the + * same reasoning would otherwise produce (measured `type-check:app` heap cost + * through the hono/client RPC type — `service.schema.ts` records the incident). + * + * NOT `.strict()`: this object round-trips through settings forms that echo + * back what they were given, and a stray key is not worth a 400. The refinement + * below still refuses a value in the WRONG unit slot, which is the failure that + * moves money. + */ +const FIELDS_BY_TYPE = { + none: { required: [], forbidden: ['percent', 'amountCents'] }, + percent: { required: ['percent'], forbidden: ['amountCents'] }, + fixed: { required: ['amountCents'], forbidden: ['percent'] }, +} as const; + +// `| undefined` spelled out: the repo runs `exactOptionalPropertyTypes`, under +// which `percent?: number` refuses the explicit undefined zod hands a refinement. +interface DepositPolicyFields { + type: keyof typeof FIELDS_BY_TYPE; + percent?: number | undefined; + amountCents?: number | undefined; +} + +interface IssueSink { + addIssue: (issue: { code: 'custom'; path: (string | number)[]; message: string }) => void; +} + +function exactlyTheFieldsFor(v: DepositPolicyFields, ctx: IssueSink) { + const spec = FIELDS_BY_TYPE[v.type]; + for (const key of spec.required) { + if (v[key] === undefined) { + ctx.addIssue({ code: 'custom', path: [key], message: `${key} is required when type is "${v.type}".` }); + } + } + for (const key of spec.forbidden) { + if (v[key] !== undefined) { + ctx.addIssue({ + code: 'custom', path: [key], + message: `${key} is not meaningful when type is "${v.type}" — remove it, or change the type.`, + }); + } + } +} + +export const DepositPolicySchema = z.object({ + type: z.enum(['none', 'percent', 'fixed']) + .describe("none = charge nothing (as a per-service value, this OPTS OUT of the workspace default); percent = a share of the price; fixed = a flat amount."), + percent: z.number().min(0).max(100).optional() + .describe('Whole percent of the price, 0-100. Only on a percent policy.'), + amountCents: z.number().int().min(0).optional() + .describe('Flat amount in integer cents: 7500 = $75.00. Only on a fixed policy.'), +}) + .openapi('DepositPolicy') + .superRefine(exactlyTheFieldsFor) + .describe('Deposit asked for at booking. Never charges more than the price.'); diff --git a/server/lib/validations/inspection/crud.ts b/server/lib/validations/inspection/crud.ts index 6b63aca3a..2061f151d 100644 --- a/server/lib/validations/inspection/crud.ts +++ b/server/lib/validations/inspection/crud.ts @@ -189,6 +189,12 @@ export const UpdateInspectionSchema = z.object({ // 'tpl-e2e-trackA' are valid), so this is a plain string, not `.uuid()`. // null detaches the template; omitted leaves it unchanged. templateId: z.string().min(1).nullable().optional().openapi({ example: '550e8400-e29b-41d4-a716-446655440002' }).describe('Template assigned to this inspection (free-text template id).'), + // Tier 3 of the booking deposit — a human's number for THIS order, which is + // the only tier the resolver cannot produce. Sending it also raises + // `is_deposit_overridden` in the handler, so a later re-resolve cannot + // quietly replace what an operator agreed with a client; null clears both. + depositRequiredCents: z.number().int().min(0).nullable().optional().openapi({ example: 9000 }) + .describe('Deposit owed on this order, in integer cents. Setting it marks the order as operator-overridden; null clears the override.'), }).openapi('UpdateInspection'); export const InspectionCountsSchema = z.object({ diff --git a/server/lib/validations/service.schema.ts b/server/lib/validations/service.schema.ts index ef563cd8c..e44522b24 100644 --- a/server/lib/validations/service.schema.ts +++ b/server/lib/validations/service.schema.ts @@ -1,5 +1,6 @@ import { z } from '@hono/zod-openapi'; import { createApiResponseSchema } from './shared.schema'; +import { DepositPolicySchema } from './deposit-policy.schema'; const ServiceSchema = z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration'), @@ -13,6 +14,8 @@ const ServiceSchema = z.object({ active: z.boolean().describe('TODO describe active field for the OpenInspection MCP integration'), sortOrder: z.number().int().describe('TODO describe sortOrder field for the OpenInspection MCP integration'), createdAt: z.string().nullable().describe('TODO describe createdAt field for the OpenInspection MCP integration'), + depositPolicy: DepositPolicySchema.nullable().optional() + .describe("This service's deposit policy; null inherits the workspace default."), }).openapi('Service'); export const CreateServiceSchema = z.object({ @@ -23,6 +26,12 @@ export const CreateServiceSchema = z.object({ templateId: z.string().optional().describe('TODO describe templateId field for the OpenInspection MCP integration'), agreementId: z.string().optional().describe('TODO describe agreementId field for the OpenInspection MCP integration'), sortOrder: z.number().int().optional().describe('TODO describe sortOrder field for the OpenInspection MCP integration'), + // Tier 2 of the booking deposit. Omitted or null = inherit the workspace + // default; `{ type: 'none' }` = this service asks for no deposit even when + // the workspace does. Those are different answers, which is why the column + // is nullable rather than defaulted. + depositPolicy: DepositPolicySchema.nullable().optional() + .describe("This service's deposit; null inherits the workspace default, { type: 'none' } opts out of it."), }).openapi('CreateService'); export const UpdateServiceSchema = CreateServiceSchema.partial().extend({ diff --git a/server/services/service.service.ts b/server/services/service.service.ts index e3487d945..4bd581fe7 100644 --- a/server/services/service.service.ts +++ b/server/services/service.service.ts @@ -1,9 +1,10 @@ import { drizzle } from 'drizzle-orm/d1'; -import { eq, and, asc, inArray, sql } from 'drizzle-orm'; -import { services, inspectionServices, discountCodes, inspections, eventTypes, reports } from '../lib/db/schema'; +import { eq, and, asc, inArray } from 'drizzle-orm'; +import { services, inspectionServices, inspections, eventTypes, reports } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { getServiceInspectors, setServiceInspectors } from './service/qualification'; import { listPayRules, createPayRule, updatePayRule, deletePayRule } from './service/pay-rules'; +import * as discounts from './service/discount-codes'; import type { CreatePayRuleInput, UpdatePayRuleInput } from './service/pay-rules'; import { syncSplitsQuietly } from './pay-split.service'; import { nanoid } from 'nanoid'; @@ -70,6 +71,10 @@ export class ServiceService { active: true, sortOrder: data.sortOrder ?? 0, createdAt: now, + // NULL inherits the workspace deposit default; `{ type: 'none' }` + // opts this service out of it. `?? null` keeps those distinct — + // an omitted key must not read as "opted out". + depositPolicy: data.depositPolicy ?? null, }); const rows = await db.select().from(services).where(eq(services.id, id)); return rows[0]; @@ -272,56 +277,6 @@ export class ServiceService { await syncSplitsQuietly(db, tenantId, inspectionId); } - async listDiscountCodes(tenantId: string) { - const db = this.getDrizzle(); - return db.select().from(discountCodes) - .where(eq(discountCodes.tenantId, tenantId)); - } - - async updateDiscountCode( - tenantId: string, - id: string, - data: Partial> & { expiresAt?: string | null }, - ) { - const db = this.getDrizzle(); - const { expiresAt, ...rest } = data; - const patch: Partial = { ...rest }; - if (expiresAt !== undefined) patch.expiresAt = expiresAt ? new Date(expiresAt) : null; - const updated = await db.update(discountCodes) - .set(patch) - .where(and(eq(discountCodes.id, id), eq(discountCodes.tenantId, tenantId))) - .returning(); - if (updated.length === 0) throw Errors.NotFound('Discount code not found'); - return updated[0]; - } - - async deleteDiscountCode(tenantId: string, id: string) { - const db = this.getDrizzle(); - const result = await db.delete(discountCodes) - .where(and(eq(discountCodes.id, id), eq(discountCodes.tenantId, tenantId))) - .returning({ id: discountCodes.id }); - if (result.length === 0) throw Errors.NotFound('Discount code not found'); - } - - async createDiscountCode(tenantId: string, data: CreateDiscountData) { - const db = this.getDrizzle(); - const id = nanoid(); - await db.insert(discountCodes).values({ - id, - tenantId, - code: data.code.toUpperCase(), - type: data.type, - value: data.value, - maxUses: data.maxUses ?? null, - usesCount: 0, - expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, - active: true, - createdAt: new Date(), - }); - const rows = await db.select().from(discountCodes).where(eq(discountCodes.id, id)); - return rows[0]; - } - // IA-26 — per-service inspector qualification. The implementation lives in // service/qualification.ts; these delegates keep every caller unchanged. async getServiceInspectors(tenantId: string, serviceId: string): Promise { @@ -350,49 +305,29 @@ export class ServiceService { return deletePayRule(this.getDrizzle(), tenantId, serviceId, ruleId); } - async validateDiscountCode(tenantId: string, code: string, subtotal: number): Promise<{ - valid: boolean; - discountAmount: number; - discountCodeId: string | null; - message?: string; - }> { - const invalid = (message: string) => - ({ valid: false as const, discountAmount: 0, discountCodeId: null, message }); + /* Discount codes. Implementation in service/discount-codes.ts — the whole + * entity in one file, rather than interleaved with the service lines. */ + async listDiscountCodes(tenantId: string) { + return discounts.listDiscountCodes(this.getDrizzle(), tenantId); + } - const db = this.getDrizzle(); - const rows = await db.select().from(discountCodes) - .where(and(eq(discountCodes.tenantId, tenantId), eq(discountCodes.active, true))); - // JS-side filter instead of SQL UPPER() — intentional for D1 compatibility - const dc = rows.find(r => r.code.toUpperCase() === code.toUpperCase()); + async createDiscountCode(tenantId: string, data: CreateDiscountData) { + return discounts.createDiscountCode(this.getDrizzle(), tenantId, data); + } - if (!dc) return invalid('Code not found'); - if (dc.expiresAt && dc.expiresAt < new Date()) return invalid('Code expired'); - if (dc.maxUses !== null && dc.usesCount >= dc.maxUses) return invalid('Code usage limit reached'); + async updateDiscountCode(tenantId: string, id: string, data: discounts.DiscountCodePatch) { + return discounts.updateDiscountCode(this.getDrizzle(), tenantId, id, data); + } - const discountAmount = dc.type === 'fixed' - ? Math.min(dc.value, subtotal) - : Math.floor(subtotal * dc.value / 100); + async deleteDiscountCode(tenantId: string, id: string) { + return discounts.deleteDiscountCode(this.getDrizzle(), tenantId, id); + } - return { valid: true, discountAmount, discountCodeId: dc.id }; + async validateDiscountCode(tenantId: string, code: string, subtotal: number): Promise { + return discounts.validateDiscountCode(this.getDrizzle(), tenantId, code, subtotal); } - /** - * Atomically increments uses_count for a discount code, enforcing max_uses. - * Returns true if the redemption was accepted (a row changed), false if the - * cap blocked it (uses_count >= max_uses) or the code doesn't exist for - * this tenant. Tenant-scoped: the WHERE clause filters tenant_id so a - * cross-tenant id can never consume another tenant's quota. - */ async redeemDiscountCode(tenantId: string, discountCodeId: string): Promise { - const db = this.getDrizzle(); - const res = await db.update(discountCodes) - .set({ usesCount: sql`${discountCodes.usesCount} + 1` }) - .where(and( - eq(discountCodes.id, discountCodeId), - eq(discountCodes.tenantId, tenantId), - sql`(${discountCodes.maxUses} IS NULL OR ${discountCodes.usesCount} < ${discountCodes.maxUses})`, - )).run(); - const r = res as unknown as { meta?: { changes?: number }; changes?: number }; - return (r.meta?.changes ?? r.changes ?? 0) > 0; + return discounts.redeemDiscountCode(this.getDrizzle(), tenantId, discountCodeId); } } diff --git a/server/services/service/discount-codes.ts b/server/services/service/discount-codes.ts new file mode 100644 index 000000000..c803cd590 --- /dev/null +++ b/server/services/service/discount-codes.ts @@ -0,0 +1,124 @@ +/** + * Discount codes — the whole entity, in one place. + * + * They lived inside `ServiceService` because a code discounts a service, but + * they are their own thing with their own lifecycle (mint, validate, redeem, + * expire) and they were interleaved with the service-line methods rather than + * grouped, so "how does redemption work" meant reading past three unrelated + * concerns. Same move `service/qualification.ts` and `service/pay-rules.ts` + * already made; `ServiceService` keeps thin delegates so no caller changes. + */ +import { and, eq, sql } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { discountCodes } from '../../lib/db/schema'; +import { Errors } from '../../lib/errors'; +import { nanoid } from 'nanoid'; +import type { z } from 'zod'; +import type { CreateDiscountCodeSchema } from '../../lib/validations/service.schema'; + +type CreateDiscountData = z.infer; + +/** `expiresAt` arrives as an ISO string on the wire and a Date in the column. */ +export type DiscountCodePatch = + Partial> & { expiresAt?: string | null }; + +export interface DiscountValidation { + valid: boolean; + discountAmount: number; + discountCodeId: string | null; + message?: string; +} + +export async function listDiscountCodes(db: DrizzleD1Database, tenantId: string) { + return db.select().from(discountCodes).where(eq(discountCodes.tenantId, tenantId)); +} + +export async function createDiscountCode(db: DrizzleD1Database, tenantId: string, data: CreateDiscountData) { + const id = nanoid(); + await db.insert(discountCodes).values({ + id, + tenantId, + code: data.code.toUpperCase(), + type: data.type, + value: data.value, + maxUses: data.maxUses ?? null, + usesCount: 0, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + active: true, + createdAt: new Date(), + }); + const rows = await db.select().from(discountCodes).where(eq(discountCodes.id, id)); + return rows[0]; +} + +export async function updateDiscountCode( + db: DrizzleD1Database, + tenantId: string, + id: string, + data: DiscountCodePatch, +) { + const { expiresAt, ...rest } = data; + const patch: Partial = { ...rest }; + if (expiresAt !== undefined) patch.expiresAt = expiresAt ? new Date(expiresAt) : null; + const updated = await db.update(discountCodes) + .set(patch) + .where(and(eq(discountCodes.id, id), eq(discountCodes.tenantId, tenantId))) + .returning(); + if (updated.length === 0) throw Errors.NotFound('Discount code not found'); + return updated[0]; +} + +export async function deleteDiscountCode(db: DrizzleD1Database, tenantId: string, id: string): Promise { + const result = await db.delete(discountCodes) + .where(and(eq(discountCodes.id, id), eq(discountCodes.tenantId, tenantId))) + .returning({ id: discountCodes.id }); + if (result.length === 0) throw Errors.NotFound('Discount code not found'); +} + +export async function validateDiscountCode( + db: DrizzleD1Database, + tenantId: string, + code: string, + subtotal: number, +): Promise { + const invalid = (message: string) => + ({ valid: false as const, discountAmount: 0, discountCodeId: null, message }); + + const rows = await db.select().from(discountCodes) + .where(and(eq(discountCodes.tenantId, tenantId), eq(discountCodes.active, true))); + // JS-side filter instead of SQL UPPER() — intentional for D1 compatibility + const dc = rows.find(r => r.code.toUpperCase() === code.toUpperCase()); + + if (!dc) return invalid('Code not found'); + if (dc.expiresAt && dc.expiresAt < new Date()) return invalid('Code expired'); + if (dc.maxUses !== null && dc.usesCount >= dc.maxUses) return invalid('Code usage limit reached'); + + const discountAmount = dc.type === 'fixed' + ? Math.min(dc.value, subtotal) + : Math.floor(subtotal * dc.value / 100); + + return { valid: true, discountAmount, discountCodeId: dc.id }; +} + +/** + * Atomically increments uses_count for a discount code, enforcing max_uses. + * Returns true if the redemption was accepted (a row changed), false if the + * cap blocked it (uses_count >= max_uses) or the code doesn't exist for + * this tenant. Tenant-scoped: the WHERE clause filters tenant_id so a + * cross-tenant id can never consume another tenant's quota. + */ +export async function redeemDiscountCode( + db: DrizzleD1Database, + tenantId: string, + discountCodeId: string, +): Promise { + const res = await db.update(discountCodes) + .set({ usesCount: sql`${discountCodes.usesCount} + 1` }) + .where(and( + eq(discountCodes.id, discountCodeId), + eq(discountCodes.tenantId, tenantId), + sql`(${discountCodes.maxUses} IS NULL OR ${discountCodes.usesCount} < ${discountCodes.maxUses})`, + )).run(); + const r = res as unknown as { meta?: { changes?: number }; changes?: number }; + return (r.meta?.changes ?? r.changes ?? 0) > 0; +} diff --git a/tests/helpers/inline-ddl.ts b/tests/helpers/inline-ddl.ts index d16c97a4a..4747daafa 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\', booking_conflict_policy TEXT NOT NULL DEFAULT \'advisory\', cancellation_policy TEXT, cancellation_clause_agreement_id TEXT, cancellation_clause_version INTEGER, cancellation_clause_attested_at INTEGER, 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\', cancellation_policy TEXT, cancellation_clause_agreement_id TEXT, cancellation_clause_version INTEGER, cancellation_clause_attested_at INTEGER, deposit_policy TEXT, 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/unit/bookings/deposit-resolution.spec.ts b/tests/unit/bookings/deposit-resolution.spec.ts new file mode 100644 index 000000000..560f9e095 --- /dev/null +++ b/tests/unit/bookings/deposit-resolution.spec.ts @@ -0,0 +1,140 @@ +/** + * The three-tier deposit, resolved. + * + * These are the arithmetic cases a tenant will argue about, so they are here + * without a database in the room. What they are actually guarding: + * + * 1. `none` is a VALUE. A service must be able to say "not this one" against a + * workspace default that says otherwise, and NULL cannot express that — it + * already means "inherit". + * 2. A deposit never exceeds the price. A fixed $200 against a $150 add-on is + * $150; the alternative invents a receivable nobody agreed to. + * 3. The workspace default applies ONCE to an order, not once per service. A + * flat "$100 deposit" turning into $300 on a three-service booking is the + * bug this shape exists to prevent — and the mirror-image bug, applying the + * default across the whole order and so re-charging the lines that opted + * out, is guarded in the same block. + */ +import { describe, it, expect } from 'vitest'; +import { resolveDeposit, resolveOrderDeposit } from '../../../server/lib/billing/deposit-policy'; + +describe('resolveDeposit — one policy, one price', () => { + it('resolves the service policy over the tenant default', () => { + expect(resolveDeposit({ + tenant: { type: 'percent', percent: 20 }, + service: { type: 'fixed', amountCents: 7500 }, + priceCents: 45000, + })).toBe(7500); + }); + + it('lets a service opt out of the tenant default', () => { + expect(resolveDeposit({ + tenant: { type: 'percent', percent: 20 }, + service: { type: 'none' }, + priceCents: 45000, + })).toBe(0); + }); + + it('inherits when the service has no policy', () => { + expect(resolveDeposit({ + tenant: { type: 'percent', percent: 20 }, + service: null, + priceCents: 45000, + })).toBe(9000); + }); + + it('charges nothing when neither tier has a policy', () => { + expect(resolveDeposit({ tenant: null, service: null, priceCents: 45000 })).toBe(0); + }); + + it('never exceeds the price', () => { + // A fixed $200 deposit against a $150 add-on must not exceed it. + expect(resolveDeposit({ + tenant: null, + service: { type: 'fixed', amountCents: 20000 }, + priceCents: 15000, + })).toBe(15000); + }); + + it('rounds percentages to whole cents', () => { + expect(resolveDeposit({ + tenant: { type: 'percent', percent: 33 }, + service: null, + priceCents: 10000, + })).toBe(3300); + // 12.5% of $99.99 is 1249.875 cents. Money moves in whole cents. + expect(resolveDeposit({ + tenant: { type: 'percent', percent: 12.5 }, + service: null, + priceCents: 9999, + })).toBe(1250); + }); + + it('treats a negative or zero price as nothing owed', () => { + expect(resolveDeposit({ tenant: { type: 'percent', percent: 20 }, service: null, priceCents: 0 })).toBe(0); + expect(resolveDeposit({ tenant: { type: 'fixed', amountCents: 5000 }, service: null, priceCents: -1 })).toBe(0); + }); +}); + +describe('resolveOrderDeposit — one deposit for the whole order', () => { + const tenant20 = { type: 'percent' as const, percent: 20 }; + const tenantFlat = { type: 'fixed' as const, amountCents: 10000 }; + + it('applies a flat workspace default ONCE across a multi-service booking', () => { + // Three services, one deposit. Charging the flat amount per line would + // make this $300 — the client agreed to one deposit for one visit. + expect(resolveOrderDeposit({ + tenant: tenantFlat, + lines: [ + { priceCents: 45000, policy: null }, + { priceCents: 15000, policy: null }, + { priceCents: 9500, policy: null }, + ], + })).toBe(10000); + }); + + it('does not re-charge a line that opted out, even under a workspace default', () => { + // The radon add-on says `none`. 20% of the $450 inspection is $90 and + // that is the whole deposit; applying the default to the order total + // would quietly bill $109 and make tier 2 decorative. + expect(resolveOrderDeposit({ + tenant: tenant20, + lines: [ + { priceCents: 45000, policy: null }, + { priceCents: 9500, policy: { type: 'none' } }, + ], + })).toBe(9000); + }); + + it('adds a service that prices its own deposit to the default on the rest', () => { + // Sewer scope carries a flat $75; the workspace default covers what is + // left ($450 → $90). + expect(resolveOrderDeposit({ + tenant: tenant20, + lines: [ + { priceCents: 45000, policy: null }, + { priceCents: 25000, policy: { type: 'fixed', amountCents: 7500 } }, + ], + })).toBe(16500); + }); + + it('matches the single-line answer when only one service is selected', () => { + expect(resolveOrderDeposit({ tenant: tenant20, lines: [{ priceCents: 45000, policy: null }] })) + .toBe(resolveDeposit({ tenant: tenant20, service: null, priceCents: 45000 })); + }); + + it('caps the order deposit at the order total', () => { + expect(resolveOrderDeposit({ + tenant: null, + lines: [ + { priceCents: 5000, policy: { type: 'fixed', amountCents: 4000 } }, + { priceCents: 3000, policy: { type: 'fixed', amountCents: 9000 } }, + ], + })).toBe(7000); + }); + + it('charges nothing for an empty order or an unconfigured workspace', () => { + expect(resolveOrderDeposit({ tenant: tenant20, lines: [] })).toBe(0); + expect(resolveOrderDeposit({ tenant: null, lines: [{ priceCents: 45000, policy: null }] })).toBe(0); + }); +}); From cdb1534fd7d333c021c4b3f7bd3a001f09d2a069 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 21:17:24 +0800 Subject: [PATCH 56/77] feat(deposit): the webhook could not tell a deposit from a stray event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractSettledPayment` returned null for any intent without `metadata.invoiceId`, and a booking deposit has no invoice by definition. So a settled deposit made the handler log `received`, ACK, and stop: money in Stripe, nothing in the ledger, and nothing anywhere saying so. The fix is a discriminated `metadata.kind` — the id follows from the kind, rather than the kind being inferred from which id turned up — so a future purpose adds an arm and the webhook's branch stops compiling until it is handled. Three properties the tests pin, because each was a way to lose money: - The deposit row is written on WEBHOOK CONFIRMATION and nowhere else. `POST /book` makes no Stripe call at all; it freezes what is owed and returns it. A declined card therefore leaves a real appointment with an unpaid deposit the tenant can see — a decline that also loses the booking is worse than having no deposit feature. - A deposit does not call `markPaid` and does not call `markPaymentReceived`. $90 of a $450 job is not payment in full, and the second of those is the gate the public report reads. - `amount_received`, not `amount`. The ledger records what arrived. The new public route is new because it had to be: the existing pay-intent is gated on `resolveClientActor`, and an anonymous booker thirty seconds after submitting has no portal grant and no invoice to charge. What authorises it is the inspection id from their own booking response, and the header says so plainly along with what that does and does not permit. Every refusal is the same 404, so it cannot double as a probe for which ids exist. Retry safety is Stripe's own idempotency key rather than the mounted guard, which never engages: the caller is a payment panel that sends no `Idempotency-Key`. The key carries the OUTSTANDING amount, so a partial deposit gets a fresh intent for the remainder instead of replaying a stale one for money already collected. The deposit basis is the summed catalogue price, read at booking and snapshotted — NOT tier 2. The booking path writes no `inspection_services` rows, and wiring `writeInspectionServiceSnapshots` into it changes invoice totals for every booking-created order. That is a change for someone to make deliberately; the comment at the snapshot field says so, because the next reader will otherwise "fix" it. Two ratchets went red and neither baseline moved. The public booking PROFILE reads are now their own file: they answer "who are you and what do you sell" while everything left answers "what times are free", and they were the only two raw `.get()`s interleaved among the OpenAPI routes — a shape difference that reads as an accident when mixed in and as a fact about their age when grouped. The deposit route mounts through that same aggregator instead of `server/index.ts`, which needs no line at all for it and keeps the external path identical. --- server/api/bookings.ts | 130 +------- server/api/bookings/profile.ts | 155 ++++++++++ server/api/public/deposit-intent.ts | 121 ++++++++ server/api/stripe-webhook.ts | 61 +++- server/lib/mcp/openapi-snapshot.json | 25 ++ server/lib/stripe-helpers.ts | 125 +++++++- server/lib/validations/booking.schema.ts | 5 + server/services/booking/deposit.ts | 121 ++++++++ server/services/booking/fulfill-booking.ts | 29 ++ server/services/payment-ledger.service.ts | 38 ++- server/services/stripe.service.ts | 41 ++- tests/unit/billing/stripe-helpers.spec.ts | 78 ++++- .../billing/stripe-webhook-handler.spec.ts | 94 +++++- .../unit/bookings/deposit-collection.spec.ts | 281 ++++++++++++++++++ .../booking-deposit-intent-replay.spec.ts | 177 +++++++++++ 15 files changed, 1338 insertions(+), 143 deletions(-) create mode 100644 server/api/bookings/profile.ts create mode 100644 server/api/public/deposit-intent.ts create mode 100644 server/services/booking/deposit.ts create mode 100644 tests/unit/bookings/deposit-collection.spec.ts create mode 100644 tests/unit/idempotency/booking-deposit-intent-replay.spec.ts diff --git a/server/api/bookings.ts b/server/api/bookings.ts index 56d8eb455..97407fc57 100644 --- a/server/api/bookings.ts +++ b/server/api/bookings.ts @@ -16,8 +16,8 @@ // `/api/public` unchanged. import { createRoute, z } from '@hono/zod-openapi'; import { createApiRouter } from '../lib/openapi-router'; -import { eq, and, inArray } from 'drizzle-orm'; -import { users, services as servicesTable, tenants, availability, tenantConfigs } from '../lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { services as servicesTable, tenants } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { checkRateLimit } from '../lib/rate-limit'; import { logger } from '../lib/logger'; @@ -27,6 +27,11 @@ import { } from '../lib/validations/booking.schema'; import { withMcpMetadata } from "../lib/route-metadata-standards"; import createBookingRoutes from './bookings/create'; +import bookingProfileRoutes from './bookings/profile'; +// Mounted here rather than in server/index.ts: the deposit is part of the +// public booking surface, and `/api/public` is where this aggregator already +// lands, so the external path is identical either way. +import depositIntentRoutes from './public/deposit-intent'; import agreementRoutes from './bookings/agreement'; import { getDrizzle } from '../lib/route-helpers'; @@ -212,6 +217,8 @@ const getTenantSlotsRoute = createRoute(withMcpMetadata({ }, { scopes: ['read'], tier: 'extended' })); export const bookingsRoutes = createApiRouter() + .route('/', bookingProfileRoutes) + .route('/', depositIntentRoutes) .openapi(listInspectorsRoute, async (c) => { const tenantId = c.get('tenantId') || c.get('requestedTenantSlug'); if (!tenantId) throw Errors.Forbidden('Tenant context missing.'); @@ -352,125 +359,6 @@ export const bookingsRoutes = createApiRouter() ...(all.holidayAdvisory ? { holidayAdvisory: all.holidayAdvisory } : {}), }, }, 200); - }) - /** - * GET /api/public/book/:tenant — company-level booking profile (IA-26). - * The canonical public entry. bookingOpen is company-wide: true iff ANY - * qualified staff member has configured recurring hours. The inspectors - * list is only exposed when the tenant enabled allowInspectorChoice. - * - * Round-trip budget: tenant lookup (1) + 3 parallel (services, config, - * getQualifiedInspectorIds) + 1 availability scan shared by bookingOpen - * and the choice list + 1 conditional inspector fetch = 5 max. - * The previous implementation ran up to 6 serial round-trips by calling - * hasAnyHours (which itself called getQualifiedInspectorIds + availability) - * and then re-running both calls inside the allowChoice branch. - */ - .get('/book/:tenant', async (c) => { - await checkRateLimit(c, 'availability'); - const { tenant } = c.req.param(); - const db = getDrizzle(c); - - const tenantRow = await db.select({ id: tenants.id, name: tenants.name }) - .from(tenants).where(eq(tenants.slug, tenant)).get(); - if (!tenantRow) return c.json({ success: false, error: { code: 'not_found', message: 'Tenant not found' } }, 404); - - const booking = c.var.services.booking; - const [svcRows, config, qualified] = await Promise.all([ - db.select({ - id: servicesTable.id, name: servicesTable.name, price: servicesTable.price, - durationMinutes: servicesTable.durationMinutes, templateId: servicesTable.templateId, - active: servicesTable.active, - }).from(servicesTable).where(eq(servicesTable.tenantId, tenantRow.id)).all(), - db.select({ - allowInspectorChoice: tenantConfigs.allowInspectorChoice, - conciergeReviewRequired: tenantConfigs.conciergeReviewRequired, - }) - .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantRow.id)).get(), - booking.getQualifiedInspectorIds(tenantRow.id, []), - ]); - const visible = svcRows.filter(s => s.active && s.templateId); - const allowChoice = !!config?.allowInspectorChoice; - - // One availability scan serves BOTH bookingOpen and the choice list. - const withHours = qualified.length > 0 - ? await db.selectDistinct({ inspectorId: availability.inspectorId }) - .from(availability) - .where(and(eq(availability.tenantId, tenantRow.id), inArray(availability.inspectorId, qualified))) - .all() - : []; - const hourIds = withHours.map(r => r.inspectorId); - const bookingOpen = hourIds.length > 0; - - let inspectors: Array<{ id: string; name: string | null; photoUrl: string | null }> = []; - if (allowChoice && hourIds.length > 0) { - inspectors = await db.select({ id: users.id, name: users.name, photoUrl: users.photoUrl }) - .from(users).where(and(eq(users.tenantId, tenantRow.id), inArray(users.id, hourIds))).all(); - inspectors.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); - } - - return c.json({ - success: true, - data: { - company: tenantRow.name, - turnstileSiteKey: c.env.TURNSTILE_SITE_KEY || null, - bookingOpen, - allowInspectorChoice: allowChoice, - conciergeReviewRequired: !!config?.conciergeReviewRequired, - inspectors, - services: visible.map(s => ({ - id: s.id, name: s.name, price: Number(s.price || 0), duration: Number(s.durationMinutes || 60), - })), - }, - }); - }) - /** - * GET /api/public/book/:tenant/:slug — public booking profile - * Returns inspector name, services, and availability for the booking page. - */ - .get('/book/:tenant/:slug', async (c) => { - await checkRateLimit(c, 'availability'); - const { tenant, slug } = c.req.param(); - const db = getDrizzle(c); - - // Resolve tenant by slug - const tenantRow = await db.select({ id: tenants.id, name: tenants.name }) - .from(tenants).where(eq(tenants.slug, tenant)).get(); - if (!tenantRow) return c.json({ success: false, error: { code: 'not_found', message: 'Tenant not found' } }, 404); - - // Find inspector by slug within tenant - const inspector = await db.select({ - id: users.id, name: users.name, slug: users.slug, photoUrl: users.photoUrl, - }).from(users).where(and(eq(users.tenantId, tenantRow.id), eq(users.slug, slug))).get(); - if (!inspector) return c.json({ success: false, error: { code: 'not_found', message: 'Inspector not found' } }, 404); - - // Get active services - const svcRows = await db.select({ - id: servicesTable.id, name: servicesTable.name, price: servicesTable.price, - durationMinutes: servicesTable.durationMinutes, - }).from(servicesTable).where(and(eq(servicesTable.tenantId, tenantRow.id), eq(servicesTable.active, true))).all(); - - // B-16 — online booking is "open" only once the inspector has working - // hours configured; the page renders an honest not-open state otherwise. - const hasHours = await db.select({ id: availability.id }).from(availability) - .where(and(eq(availability.tenantId, tenantRow.id), eq(availability.inspectorId, inspector.id))) - .limit(1) - .get(); - - return c.json({ - success: true, - data: { - inspectorId: inspector.id, - name: inspector.name, - company: tenantRow.name, - avatar: inspector.photoUrl, - turnstileSiteKey: c.env.TURNSTILE_SITE_KEY || null, - bookingOpen: !!hasHours, - services: svcRows.map(s => ({ - id: s.id, name: s.name, price: Number(s.price || 0), duration: Number(s.durationMinutes || 60), - })), - }, - }); }); export type BookingsApi = typeof bookingsRoutes; diff --git a/server/api/bookings/profile.ts b/server/api/bookings/profile.ts new file mode 100644 index 000000000..cc3d08ff7 --- /dev/null +++ b/server/api/bookings/profile.ts @@ -0,0 +1,155 @@ +/** + * The two public booking PROFILE reads — what a company (or one inspector) + * offers, and whether they are taking bookings at all. + * + * Split out of `server/api/bookings.ts` on size, and they are the right ninety + * lines to move: everything else in that module answers "what times are free" + * or "here is a booking", while these two answer "who are you and what do you + * sell". They are also the only two routes in the public booking surface that + * are raw `.get()` rather than `createRoute` — a shape difference that reads as + * an accident when they sit interleaved with the OpenAPI ones and as a fact + * about their age when they sit together. + * + * Mounted at `/` by the aggregator, so the external paths are unchanged and + * Hono still merges the RPC types — `api.bookings.book[":tenant"].$get` keeps + * working in the app exactly as before. + */ +import { and, eq, inArray } from 'drizzle-orm'; +import { createApiRouter } from '../../lib/openapi-router'; +import { users, services as servicesTable, tenants, availability, tenantConfigs } from '../../lib/db/schema'; +import { checkRateLimit } from '../../lib/rate-limit'; +import { getDrizzle } from '../../lib/route-helpers'; + +const bookingProfileRoutes = createApiRouter() + /** + * GET /api/public/book/:tenant — company-level booking profile (IA-26). + * The canonical public entry. bookingOpen is company-wide: true iff ANY + * qualified staff member has configured recurring hours. The inspectors + * list is only exposed when the tenant enabled allowInspectorChoice. + * + * Round-trip budget: tenant lookup (1) + 3 parallel (services, config, + * getQualifiedInspectorIds) + 1 availability scan shared by bookingOpen + * and the choice list + 1 conditional inspector fetch = 5 max. + * The previous implementation ran up to 6 serial round-trips by calling + * hasAnyHours (which itself called getQualifiedInspectorIds + availability) + * and then re-running both calls inside the allowChoice branch. + */ + .get('/book/:tenant', async (c) => { + await checkRateLimit(c, 'availability'); + const { tenant } = c.req.param(); + const db = getDrizzle(c); + + const tenantRow = await db.select({ id: tenants.id, name: tenants.name }) + .from(tenants).where(eq(tenants.slug, tenant)).get(); + if (!tenantRow) return c.json({ success: false, error: { code: 'not_found', message: 'Tenant not found' } }, 404); + + const booking = c.var.services.booking; + const [svcRows, config, qualified] = await Promise.all([ + db.select({ + id: servicesTable.id, name: servicesTable.name, price: servicesTable.price, + durationMinutes: servicesTable.durationMinutes, templateId: servicesTable.templateId, + active: servicesTable.active, depositPolicy: servicesTable.depositPolicy, + }).from(servicesTable).where(eq(servicesTable.tenantId, tenantRow.id)).all(), + db.select({ + allowInspectorChoice: tenantConfigs.allowInspectorChoice, + conciergeReviewRequired: tenantConfigs.conciergeReviewRequired, + currency: tenantConfigs.currency, + depositPolicy: tenantConfigs.depositPolicy, + }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantRow.id)).get(), + booking.getQualifiedInspectorIds(tenantRow.id, []), + ]); + const visible = svcRows.filter(s => s.active && s.templateId); + const allowChoice = !!config?.allowInspectorChoice; + + // One availability scan serves BOTH bookingOpen and the choice list. + const withHours = qualified.length > 0 + ? await db.selectDistinct({ inspectorId: availability.inspectorId }) + .from(availability) + .where(and(eq(availability.tenantId, tenantRow.id), inArray(availability.inspectorId, qualified))) + .all() + : []; + const hourIds = withHours.map(r => r.inspectorId); + const bookingOpen = hourIds.length > 0; + + let inspectors: Array<{ id: string; name: string | null; photoUrl: string | null }> = []; + if (allowChoice && hourIds.length > 0) { + inspectors = await db.select({ id: users.id, name: users.name, photoUrl: users.photoUrl }) + .from(users).where(and(eq(users.tenantId, tenantRow.id), inArray(users.id, hourIds))).all(); + inspectors.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + } + + return c.json({ + success: true, + data: { + company: tenantRow.name, + turnstileSiteKey: c.env.TURNSTILE_SITE_KEY || null, + bookingOpen, + allowInspectorChoice: allowChoice, + conciergeReviewRequired: !!config?.conciergeReviewRequired, + inspectors, + // The deposit is QUOTED here and RESOLVED again on the server at + // booking time from the same catalogue rows. The client copy has + // to exist — a charge a client only discovers after clicking Book + // is a chargeback and a review — but it is a quote, never the + // authority: `inspections.deposit_required_cents` is what the + // server froze, and only the server writes it. + currency: config?.currency ?? 'USD', + depositPolicy: config?.depositPolicy ?? null, + services: visible.map(s => ({ + id: s.id, name: s.name, price: Number(s.price || 0), duration: Number(s.durationMinutes || 60), + depositPolicy: s.depositPolicy ?? null, + })), + }, + }); + }) + /** + * GET /api/public/book/:tenant/:slug — public booking profile + * Returns inspector name, services, and availability for the booking page. + */ + .get('/book/:tenant/:slug', async (c) => { + await checkRateLimit(c, 'availability'); + const { tenant, slug } = c.req.param(); + const db = getDrizzle(c); + + // Resolve tenant by slug + const tenantRow = await db.select({ id: tenants.id, name: tenants.name }) + .from(tenants).where(eq(tenants.slug, tenant)).get(); + if (!tenantRow) return c.json({ success: false, error: { code: 'not_found', message: 'Tenant not found' } }, 404); + + // Find inspector by slug within tenant + const inspector = await db.select({ + id: users.id, name: users.name, slug: users.slug, photoUrl: users.photoUrl, + }).from(users).where(and(eq(users.tenantId, tenantRow.id), eq(users.slug, slug))).get(); + if (!inspector) return c.json({ success: false, error: { code: 'not_found', message: 'Inspector not found' } }, 404); + + // Get active services + const svcRows = await db.select({ + id: servicesTable.id, name: servicesTable.name, price: servicesTable.price, + durationMinutes: servicesTable.durationMinutes, + }).from(servicesTable).where(and(eq(servicesTable.tenantId, tenantRow.id), eq(servicesTable.active, true))).all(); + + // B-16 — online booking is "open" only once the inspector has working + // hours configured; the page renders an honest not-open state otherwise. + const hasHours = await db.select({ id: availability.id }).from(availability) + .where(and(eq(availability.tenantId, tenantRow.id), eq(availability.inspectorId, inspector.id))) + .limit(1) + .get(); + + return c.json({ + success: true, + data: { + inspectorId: inspector.id, + name: inspector.name, + company: tenantRow.name, + avatar: inspector.photoUrl, + turnstileSiteKey: c.env.TURNSTILE_SITE_KEY || null, + bookingOpen: !!hasHours, + services: svcRows.map(s => ({ + id: s.id, name: s.name, price: Number(s.price || 0), duration: Number(s.durationMinutes || 60), + })), + }, + }); + }); + +export default bookingProfileRoutes; diff --git a/server/api/public/deposit-intent.ts b/server/api/public/deposit-intent.ts new file mode 100644 index 000000000..ac12c8c29 --- /dev/null +++ b/server/api/public/deposit-intent.ts @@ -0,0 +1,121 @@ +/** + * The one public route that can charge a stranger — the booking deposit. + * + * WHY IT IS NOT THE EXISTING PAY-INTENT ROUTE. `POST /api/public/inspections/ + * {id}/pay-intent` is gated on `resolveClientActor`: a live client/co_client + * portal grant, presented as a `?token=` or the portal session cookie. An + * anonymous booker who submitted the public form thirty seconds ago has + * neither — no contact grant is minted at booking — so every deposit would + * 401. It also charges the INVOICE, and a deposit exists precisely because + * there is no invoice yet. + * + * WHAT AUTHORISES THE CALL, stated plainly because it is a public money route + * and the honest answer is narrow: the inspection id, which was handed to this + * browser in the response to its own booking submission and exists nowhere + * else. That is the same capability posture as `/invoice/:id`. What it can be + * abused for is bounded and worth writing down — a stranger holding the id can + * learn what deposit is outstanding, and can PAY it. They cannot read the + * booking, move it, or take money out. Everything else is refused by the four + * conditions below, and 404 is the answer to all of them so the route cannot be + * used to probe which ids exist. + * + * The intent it mints carries `metadata.kind = 'deposit'`. Nothing is recorded + * here and nothing is recorded when the browser says the card cleared — the + * ledger row is written by the Stripe webhook, which is the only party that + * knows whether money actually moved. + */ +import { createRoute, z } from '@hono/zod-openapi'; +import { and, eq } from 'drizzle-orm'; +import { createApiRouter } from '../../lib/openapi-router'; +import { createApiResponseSchema } from '../../lib/validations/shared.schema'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; +import { getDrizzle } from '../../lib/route-helpers'; +import { checkRateLimit } from '../../lib/rate-limit'; +import { logger } from '../../lib/logger'; +import { inspections, tenantConfigs } from '../../lib/db/schema'; +import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; +import { outstandingDepositCents } from '../../services/booking/deposit'; +import { DepositNotPayableError } from '../../lib/stripe-helpers'; + +const DepositIntentSchema = z.object({ + clientSecret: z.string().describe('Stripe PaymentIntent client secret for mounting Elements in the browser.'), + publishableKey: z.string().describe("The tenant's own Stripe publishable key."), + amountCents: z.number().int().describe('What this intent will charge, in integer cents — the deposit still outstanding.'), + currency: z.string().describe('ISO 4217 currency the deposit is charged in.'), +}); + +const depositIntentRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/inspections/{id}/deposit-intent', + tags: ['bookings', 'public'], + summary: 'Start a card payment for a booking deposit', + request: { + params: z.object({ id: z.string().describe('Inspection id returned by the booking submission.') }), + }, + responses: { + 200: { content: { 'application/json': { schema: createApiResponseSchema(DepositIntentSchema) } }, description: 'PaymentIntent client secret + publishable key' }, + 404: { description: 'No deposit is outstanding on this booking (also the answer for an unknown or already-started inspection)' }, + 429: { description: 'Too many attempts from this address' }, + 503: { description: 'Stripe is not configured for this workspace, or the charge could not be started' }, + }, + operationId: 'createBookingDepositIntent', + description: "Mints a Stripe PaymentIntent for the deposit still outstanding on a booking, using the tenant's own Stripe keys. Public and unauthenticated: the inspection id returned by the booking submission is the capability. Records nothing — the ledger row is written when Stripe confirms the payment.", +}, { scopes: [], tier: 'extended' })); + +const notFound = { success: false as const, error: { code: 'NOT_FOUND', message: 'No deposit is outstanding on this booking.' } }; + +const depositIntentRoutes = createApiRouter() + .openapi(depositIntentRoute, async (c) => { + await checkRateLimit(c, 'deposit-intent'); + const { id } = c.req.valid('param'); + // Set by resolveByInspectionId for the `/api/public/inspections/` prefix + // — the same routing that puts this tenant's Stripe keys in c.env. + const tenantId = (c.get('resolvedTenantId') || c.get('tenantId')) as string | null; + if (!tenantId) return c.json(notFound, 404); + + const db = getDrizzle(c); + const order = await db.select({ status: inspections.status }) + .from(inspections) + .where(and(eq(inspections.id, id), eq(inspections.tenantId, tenantId))) + .get(); + if (!order) return c.json(notFound, 404); + // A deposit holds a slot. Once the visit is under way or done it is not + // a deposit any more, it is a payment, and payments go through the + // invoice — which has an actual authenticated surface. + if (order.status !== INSPECTION_STATUS.REQUESTED) return c.json(notFound, 404); + + const owed = await outstandingDepositCents(db, tenantId, id); + if (!owed || owed.outstandingCents <= 0) return c.json(notFound, 404); + + const secretKey = c.env.STRIPE_SECRET_KEY; + const publishableKey = c.env.STRIPE_PUBLISHABLE_KEY; + if (!secretKey || !publishableKey) { + return c.json({ success: false as const, error: { code: 'STRIPE_NOT_CONFIGURED', message: 'Online payment is not set up for this inspector.' } }, 503); + } + + const cfg = await db.select({ currency: tenantConfigs.currency }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const currency = cfg?.currency ?? 'USD'; + + try { + const { StripeService } = await import('../../services/stripe.service'); + const svc = new StripeService(secretKey); + const { clientSecret } = await svc.createDepositPaymentIntent( + { inspectionId: id, outstandingCents: owed.outstandingCents }, + { tenantId, currency }, + ); + return c.json({ + success: true as const, + data: { clientSecret, publishableKey, amountCents: owed.outstandingCents, currency }, + }, 200); + } catch (err) { + // Raced with the webhook: the deposit landed between our read and + // the mint. Nothing is owed, which is the 404 above's answer. + if (err instanceof DepositNotPayableError) return c.json(notFound, 404); + logger.error('Stripe deposit-intent failed', { inspectionId: id.slice(0, 8) }, err instanceof Error ? err : undefined); + return c.json({ success: false as const, error: { code: 'STRIPE_ERROR', message: 'Payment could not be started. Please try again.' } }, 503); + } + }); + +export type PublicDepositIntentApi = typeof depositIntentRoutes; +export default depositIntentRoutes; diff --git a/server/api/stripe-webhook.ts b/server/api/stripe-webhook.ts index 44100eef6..0796a61ad 100644 --- a/server/api/stripe-webhook.ts +++ b/server/api/stripe-webhook.ts @@ -5,6 +5,8 @@ import { extractSettledPayment } from '../lib/stripe-helpers'; import { appendWebhookLogEntry } from '../lib/stripe-webhook-log'; import { AppError } from '../lib/errors'; import { qboPaymentKey } from '../lib/qbo-payment-key'; +import { recordPayment } from '../services/payment-ledger.service'; +import { getDrizzle } from '../lib/route-helpers'; /** * Stripe webhook (bring-your-own-keys). Excluded from JWT middleware (see @@ -64,6 +66,16 @@ api.post('/', async (c) => { if (!settled) { // Verified but nothing to act on (includes Stripe dashboard "Send test // event" payloads) — the log row is the user's connectivity probe. + // + // An intent that carries a `kind` this build does not know is a + // DIFFERENT thing, and the difference matters: that is our own money + // going unrecorded, not a stray event. It still ACKs — a retry cannot + // teach this worker a kind it was not built with — but it says so + // where someone will see it. + const kind = (event.data?.object as { metadata?: Record | null } | undefined)?.metadata?.kind; + if (kind && kind !== 'invoice' && kind !== 'deposit') { + logger.error('Stripe webhook: unrecognised payment kind — money settled with no ledger row', { kind }); + } await appendWebhookLogEntry(c.env.TENANT_CACHE, tenantId, { eventType: event.type, result: 'received', }); @@ -82,16 +94,55 @@ api.post('/', async (c) => { return c.json({ success: true }); // ACK: a retry can never succeed } + // A DEPOSIT is money against the ORDER with no invoice behind it, so it + // takes neither of the two writes below: there is no invoice to mark paid, + // and marking the inspection "payment received" would unlock a report the + // client has paid a fraction of. The row is written HERE and nowhere else + // — the browser reporting success is not a payment authority, and a + // client-side write would record a declined card as collected. + if (settled.purpose.kind === 'deposit') { + try { + const appendedDeposit = await recordPayment(getDrizzle(c), tenantId, { + inspectionId: settled.purpose.inspectionId, + invoiceId: null, + kind: 'deposit', + amountCents: settled.amountCents, + method: 'card', + provider: 'stripe', + providerRef: settled.providerRef, + }); + await appendWebhookLogEntry(c.env.TENANT_CACHE, tenantId, { + eventType: event.type, result: 'processed', + }); + // Null is a redelivery of a row we already have — the unique index + // on (tenant, provider, provider_ref) is the guard, and it is doing + // its job. Not an error, and not a second deposit. + logger.info('Stripe webhook: booking deposit settled', { + inspectionId: settled.purpose.inspectionId.slice(0, 8), + appended: appendedDeposit !== null, + }); + // NOT pushed to QuickBooks. An unapplied deposit is a liability in + // the tenant's own chart of accounts, and which account is their + // accountant's decision — see the QBO Books health card, which + // counts these and says they are unsynced rather than pretending. + return c.json({ success: true }); + } catch (e) { + logger.error('Stripe webhook: deposit processing error', {}, e instanceof Error ? e : undefined); + return c.json({ success: false, error: { message: 'Processing failed' } }, 500); + } + } + + const invoiceId = settled.purpose.invoiceId; let appended: Awaited> = null; try { - appended = await c.var.services.invoice.markPaid(settled.invoiceId, tenantId, 'oi', 'card'); + appended = await c.var.services.invoice.markPaid(invoiceId, tenantId, 'oi', 'card'); if (settled.inspectionId) { await c.var.services.inspection.markPaymentReceived(tenantId, settled.inspectionId); } } catch (e) { if (e instanceof AppError && e.status === 404) { // Invoice purged/gone — retrying can never succeed; ack and move on. - logger.warn('Stripe webhook: invoice not found — acked', { invoiceId: settled.invoiceId.slice(0, 8) }); + logger.warn('Stripe webhook: invoice not found — acked', { invoiceId: invoiceId.slice(0, 8) }); return c.json({ success: true }); } logger.error('Stripe webhook processing error', {}, e instanceof Error ? e : undefined); @@ -116,12 +167,12 @@ api.post('/', async (c) => { c.executionCtx.waitUntil((async () => { try { await c.var.services.qbo.recordPayment( - tenantId, settled.invoiceId, push.amountCents / 100, qboPaymentKey(push.id), + tenantId, invoiceId, push.amountCents / 100, qboPaymentKey(push.id), push.occurredAt, ); } catch (e) { logger.error('Stripe webhook: QBO payment push failed', - { invoiceId: settled.invoiceId.slice(0, 8) }, e instanceof Error ? e : undefined); + { invoiceId: invoiceId.slice(0, 8) }, e instanceof Error ? e : undefined); } })()); } @@ -130,7 +181,7 @@ api.post('/', async (c) => { eventType: event.type, result: 'processed', }); logger.info('Stripe webhook: invoice settled', { - invoiceId: settled.invoiceId.slice(0, 8), + invoiceId: invoiceId.slice(0, 8), inspectionId: settled.inspectionId?.slice(0, 8), }); return c.json({ success: true }); diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index bae4c9e89..5531219fd 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -1402,6 +1402,31 @@ "summary": "Submit a new booking", "description": "Auto-generated placeholder for createBookingBook (POST /book, bookings domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "createBookingDepositIntent", + "method": "POST", + "pathTemplate": "/api/public/inspections/{id}/deposit-intent", + "scopes": [], + "tag": "bookings", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inspection id returned by the booking submission.", + "schema": { + "type": "string", + "description": "Inspection id returned by the booking submission." + } + } + ], + "body": null + }, + "summary": "Start a card payment for a booking deposit", + "description": "Mints a Stripe PaymentIntent for the deposit still outstanding on a booking, using the tenant's own Stripe keys. Public and unauthenticated: the inspection id returned by the booking submission is the capability. Records nothing — the ledger row is written when Stripe confirms the payment." + }, { "operationId": "createCalendarBlock", "method": "POST", diff --git a/server/lib/stripe-helpers.ts b/server/lib/stripe-helpers.ts index 5be07d5e9..d765a187a 100644 --- a/server/lib/stripe-helpers.ts +++ b/server/lib/stripe-helpers.ts @@ -24,6 +24,28 @@ export interface PaymentIntentParams { description: string; } +/** + * WHAT THE MONEY IS FOR, stamped on the intent and read back off the webhook. + * + * This exists because `metadata.invoiceId` was doing two jobs: naming the + * invoice AND being the signal that a settlement is ours to act on. A booking + * deposit is taken before any invoice exists, so under that rule its webhook + * read as "nothing to do" — the handler logged `received`, ACKed, and the + * deposit row was never written. Money in Stripe, nothing in the ledger, and + * no surface anywhere saying so. + * + * So the KIND is the discriminator and the id follows from it. A future intent + * that is neither adds an arm here, and the webhook's switch stops compiling + * until it is handled — which is the property `invoiceId`-or-nothing could not + * offer. + * + * Intents minted before this field existed carry no `kind`; they are read as + * `invoice`, which is what they all were. + */ +export type PaymentPurpose = + | { kind: 'invoice'; invoiceId: string } + | { kind: 'deposit'; inspectionId: string }; + /** Raised when an invoice cannot be charged (already paid, or no positive amount). */ export class InvoiceNotPayableError extends Error { constructor(message: string) { @@ -53,6 +75,7 @@ export function buildPaymentIntentParams( } const metadata: Record = { + kind: 'invoice', invoiceId: invoice.id, tenantId: ctx.tenantId, }; @@ -66,6 +89,46 @@ export function buildPaymentIntentParams( }; } +/** Raised when there is nothing to collect up front, or it is already collected. */ +export class DepositNotPayableError extends Error { + constructor(message: string) { + super(message); + this.name = 'DepositNotPayableError'; + } +} + +/** + * Builds the PaymentIntent params for a booking DEPOSIT — money against an + * order, before any invoice exists. + * + * Not a variant of `buildPaymentIntentParams`: that function's whole job is to + * refuse to charge without a payable invoice, and loosening it so a deposit + * could pass would remove the guard for every real invoice too. Two callers, + * two guards, one webhook that can tell them apart. + * + * `outstandingCents` is what is still owed of the deposit, not the deposit + * itself: a booker who abandoned the card form and came back must not be + * charged the whole amount twice. + */ +export function buildDepositIntentParams( + order: { inspectionId: string; outstandingCents: number }, + ctx: { tenantId: string; currency?: string; descriptionPrefix?: string }, +): PaymentIntentParams { + if (!Number.isInteger(order.outstandingCents) || order.outstandingCents <= 0) { + throw new DepositNotPayableError('No deposit is outstanding on this booking'); + } + return { + amount: order.outstandingCents, + currency: (ctx.currency ?? 'usd').toLowerCase(), + metadata: { + kind: 'deposit', + inspectionId: order.inspectionId, + tenantId: ctx.tenantId, + }, + description: `${ctx.descriptionPrefix ?? 'Booking deposit'} ${order.inspectionId}`, + }; +} + /** * The subset of a Stripe.Event we read in the webhook. `data.object` is typed * `unknown` because the real Stripe.Event is a wide discriminated union whose @@ -76,25 +139,69 @@ export interface StripeEventLike { data: { object: unknown }; } +/** + * A settlement we recognise. `tenantId` and the amount are common to both + * arms; `purpose` says which ledger row it becomes. + * + * `amountCents` is read off the EVENT, not off our own record: for a deposit + * there is no invoice to look the figure up on, and for a partial card payment + * the amount that settled is the amount Stripe says settled. `providerRef` is + * the intent id — the idempotency key a redelivery collides on. + */ export interface SettledPayment { - invoiceId: string; tenantId: string; + purpose: PaymentPurpose; + /** The intent id. Null only if Stripe sent an object without one. */ + providerRef: string | null; + /** What actually settled, in the smallest currency unit. */ + amountCents: number; + /** Present on both arms when known; the deposit arm always has it. */ inspectionId: string | null; } /** - * Extracts the settled invoice reference from a Stripe webhook event. - * Returns null for any event that is not a successful PaymentIntent or that - * is missing the invoiceId/tenantId metadata we stamped at creation time — - * the webhook handler treats null as "nothing to do" and acks the event. + * Extracts the settlement from a Stripe webhook event, or null when there is + * nothing for us to act on — a non-success event, an intent minted by + * something other than this app, or metadata we cannot make sense of. The + * handler treats null as "log it and ACK". + * + * NULL IS NOT A SAFE DEFAULT HERE, which is why the parsing is explicit rather + * than a chain of `??`. Returning null for a settlement that IS ours means the + * money moved and no row records it, and the only trace is a `received` line + * in a log nobody reads. That is exactly what happened to deposits before + * `kind` existed. */ export function extractSettledPayment(event: StripeEventLike): SettledPayment | null { if (event.type !== 'payment_intent.succeeded') return null; - const obj = event.data?.object as { metadata?: Record | null } | undefined; + const obj = event.data?.object as { + id?: string; + amount_received?: number; + amount?: number; + metadata?: Record | null; + } | undefined; const md = obj?.metadata ?? null; if (!md) return null; - const invoiceId = md.invoiceId; const tenantId = md.tenantId; - if (!invoiceId || !tenantId) return null; - return { invoiceId, tenantId, inspectionId: md.inspectionId ?? null }; + if (!tenantId) return null; + + // Absent `kind` means an intent minted before the field existed, and every + // one of those was an invoice payment. + const kind = md.kind ?? 'invoice'; + const inspectionId = md.inspectionId ?? null; + // `amount_received` is what settled; `amount` is what was asked for. They + // differ on a partial capture, and the ledger records what arrived. + const amountCents = Number(obj?.amount_received ?? obj?.amount ?? 0); + const providerRef = obj?.id ?? null; + + if (kind === 'deposit') { + if (!inspectionId) return null; + return { tenantId, purpose: { kind: 'deposit', inspectionId }, providerRef, amountCents, inspectionId }; + } + if (kind === 'invoice') { + if (!md.invoiceId) return null; + return { tenantId, purpose: { kind: 'invoice', invoiceId: md.invoiceId }, providerRef, amountCents, inspectionId }; + } + // A kind this build does not know about. Refusing it is right — guessing + // would post money against the wrong thing — but it must not be silent. + return null; } diff --git a/server/lib/validations/booking.schema.ts b/server/lib/validations/booking.schema.ts index f963275fb..0f412fe4c 100644 --- a/server/lib/validations/booking.schema.ts +++ b/server/lib/validations/booking.schema.ts @@ -103,6 +103,11 @@ export const BookingResponseSchema = createApiResponseSchema(z.object({ // Sprint 2 S2-2 — request grouping is always present, even for single-service bookings. requestId: z.string().optional().openapi({ example: 'req-abc12345' }).describe('TODO describe requestId field for the OpenInspection MCP integration'), inspectionIds: z.array(z.string().trim().min(1)).optional().openapi({ description: 'All inspection ids in the request' }), + // What the booking OWES up front, frozen at this moment. 0 (the default for + // every workspace) means no payment step is shown. Never what was paid — + // nothing has been at this point, and only the Stripe webhook says otherwise. + depositRequiredCents: z.number().int().optional().openapi({ example: 9000 }) + .describe('Deposit owed on this booking in integer cents; 0 when the workspace asks for none.'), })).openapi('BookingResponse'); export const AvailabilityListResponseSchema = createApiResponseSchema(z.array(z.object({ diff --git a/server/services/booking/deposit.ts b/server/services/booking/deposit.ts new file mode 100644 index 000000000..b736330c1 --- /dev/null +++ b/server/services/booking/deposit.ts @@ -0,0 +1,121 @@ +/** + * The deposit, from catalogue prices to a number frozen on the order. + * + * ON THE MONEY BASIS, because the next reader will want to "fix" it. The + * deposit resolves against the summed `services.price_cents` of what was + * selected, read straight from the catalogue at booking time, and NOT against + * tier 2 of the money-authority chain. That is not an oversight: + * + * - The public booking path does not write `inspection_services` rows + * (`writeInspectionServiceSnapshots` exists and only the dashboard wizard + * calls it), so at the moment the deposit is computed there is no tier 2 + * to read. Wiring that writer into booking is a real behaviour change — it + * turns tier-2 authority on for every booking-created order and moves + * their invoice totals off `inspections.price`/0 — and it belongs to + * whoever does it deliberately, with the invoice-total change in the same + * change. It is NOT a prerequisite for this. + * - Even once it is wired, the deposit would still snapshot. A percentage + * resolves against the price on the day; the client owes what they agreed + * to, not what the catalogue says next week. + * + * So this reads the catalogue, and `inspections.deposit_required_cents` holds + * the answer. Nothing downstream re-derives it. + */ +import { and, eq, inArray } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { inspections, services as servicesTable, tenantConfigs } from '../../lib/db/schema'; +import { resolveOrderDeposit, type DepositLine } from '../../lib/billing/deposit-policy'; +import { getHeldDepositCents } from '../payment-ledger.service'; + +/** + * What this order should be asked for up front. Zero when the workspace has + * configured nothing, which is how every workspace ships and therefore the + * answer for almost every call — the tenant-config read is the only query that + * always happens, and the catalogue read is skipped when there is nothing to + * resolve. + */ +export async function resolveBookingDepositCents( + db: DrizzleD1Database, + tenantId: string, + serviceIds: string[], +): Promise { + const cfg = await db.select({ depositPolicy: tenantConfigs.depositPolicy }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const tenantPolicy = cfg?.depositPolicy ?? null; + if (serviceIds.length === 0) return 0; + + const rows = await db.select({ + price: servicesTable.price, + depositPolicy: servicesTable.depositPolicy, + }) + .from(servicesTable) + .where(and(eq(servicesTable.tenantId, tenantId), inArray(servicesTable.id, serviceIds))) + .all(); + if (rows.length === 0) return 0; + + // A service with no policy of its own still needs the workspace default + // applied, so the "nothing configured anywhere" shortcut is only safe once + // we know no service overrides it. + const lines: DepositLine[] = rows.map(r => ({ + priceCents: r.price ?? 0, + policy: r.depositPolicy ?? null, + })); + if (!tenantPolicy && lines.every(l => l.policy === null)) return 0; + + return resolveOrderDeposit({ tenant: tenantPolicy, lines }); +} + +/** + * Freeze the amount on the order. + * + * ONE deposit per booking, on the PRIMARY inspection; the siblings a + * multi-service booking creates are explicitly set to 0 rather than left NULL. + * NULL and 0 would read the same to arithmetic and differently to a human — + * "no deposit configured" versus "this one is covered by the order's" — and it + * is the second that is true. + * + * Never overwrites a figure an operator set: `deposit_overridden` exists for + * exactly this, and a booking-time resolve is the re-resolve it guards against. + * Non-fatal by construction at the call site: the inspection rows are already + * committed and a failure here must not lose an appointment the client believes + * they made. + */ +export async function snapshotOrderDeposit( + db: DrizzleD1Database, + tenantId: string, + primaryInspectionId: string, + allInspectionIds: string[], + depositCents: number, +): Promise { + const siblings = allInspectionIds.filter(id => id !== primaryInspectionId); + if (siblings.length > 0) { + await db.update(inspections).set({ depositRequiredCents: 0 }) + .where(and( + eq(inspections.tenantId, tenantId), + inArray(inspections.id, siblings), + eq(inspections.depositOverridden, false), + )); + } + await db.update(inspections).set({ depositRequiredCents: depositCents }) + .where(and( + eq(inspections.tenantId, tenantId), + eq(inspections.id, primaryInspectionId), + eq(inspections.depositOverridden, false), + )); +} + +/** What is still owed of an order's deposit: asked for, minus what has landed. */ +export async function outstandingDepositCents( + db: DrizzleD1Database, + tenantId: string, + inspectionId: string, +): Promise<{ requiredCents: number; heldCents: number; outstandingCents: number } | null> { + const row = await db.select({ requiredCents: inspections.depositRequiredCents }) + .from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) + .get(); + if (!row) return null; + const requiredCents = row.requiredCents ?? 0; + const heldCents = await getHeldDepositCents(db, tenantId, inspectionId); + return { requiredCents, heldCents, outstandingCents: Math.max(0, requiredCents - heldCents) }; +} diff --git a/server/services/booking/fulfill-booking.ts b/server/services/booking/fulfill-booking.ts index 84fb3cfc5..a7e3224a2 100644 --- a/server/services/booking/fulfill-booking.ts +++ b/server/services/booking/fulfill-booking.ts @@ -11,6 +11,7 @@ import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; import { admitBooking } from './booking-admission'; import { resolveBookingAgentReferral, attachBookingPeople } from './booking-people'; import { dispatchBookingConfirmation } from './booking-confirmation'; +import { resolveBookingDepositCents, snapshotOrderDeposit } from './deposit'; import type { HonoConfig } from '../../types/hono'; import type { PublicBookingSchema } from '../../lib/validations/booking.schema'; import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; @@ -210,6 +211,30 @@ export async function fulfillBooking( }); } + // The deposit is resolved and FROZEN here, after the booking has survived + // arbitration and before anyone is asked for a card. Nothing is charged in + // this request: the amount comes back in the response, the client pays it + // on the confirmation step, and the ledger row is written by the Stripe + // webhook. A declined card therefore leaves a real appointment with an + // unpaid deposit the tenant can see and chase — which is the whole point, + // because a decline that also loses the booking is worse than no deposit + // feature at all. + // + // Non-fatal, deliberately. The inspection rows are committed; a failure to + // stamp a number must not 500 an anonymous booker. + let depositRequiredCents = 0; + try { + depositRequiredCents = await resolveBookingDepositCents( + db, tenantId, (body.services ?? []).map(s => s.serviceId), + ); + if (depositRequiredCents > 0) { + await snapshotOrderDeposit(db, tenantId, inspectionId, allInspectionIds, depositRequiredCents); + } + } catch (e) { + depositRequiredCents = 0; + logger.error('booking.deposit.snapshot.failed', { inspectionId }, e instanceof Error ? e : undefined); + } + const bookingClientContactId = await attachBookingPeople(c, db, tenantId, body, { allInspectionIds, directInsertInspectionId, @@ -248,6 +273,10 @@ export async function fulfillBooking( inspectionId, requestId: createdRequestId, inspectionIds: allInspectionIds, + // What is OWED, not what has been collected — nothing has, at this + // point. The confirmation step reads this to decide whether to ask + // for a card at all; 0 means no payment step is rendered. + depositRequiredCents, } }, 200); } diff --git a/server/services/payment-ledger.service.ts b/server/services/payment-ledger.service.ts index 654d7ff49..a74b2f45c 100644 --- a/server/services/payment-ledger.service.ts +++ b/server/services/payment-ledger.service.ts @@ -11,7 +11,7 @@ * * See spec 2026-08-01 payment/deposit flow §3. */ -import { and, eq, isNotNull } from 'drizzle-orm'; +import { and, eq, isNotNull, isNull } 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'; @@ -200,6 +200,42 @@ export async function getNetReceivedCents( return (await getLedgerOpinion(rawDb, tenantId, invoiceId)).netCents; } +/** + * Money HELD against an order — collected, with no invoice behind it yet. + * + * This is the number `heldDepositCount` counts one row at a time on the QBO + * health card, and the one every "what has this client paid" question has to + * include before an invoice exists. Receipts minus refunds, same arithmetic as + * everywhere else, scoped by `invoice_id IS NULL` rather than by `kind`: + * whatever refunds a held deposit is a `refund` row that is also invoice-less, + * and counting only deposits would report money back out as still held. + * + * Goes to ZERO for a deposit the moment its invoice is raised — the backfill + * sets `invoice_id`, and from then on the invoice's own total is the answer. + * That is what stops it being counted twice, and it is the reason this reads + * the link rather than the kind. + */ +export async function getHeldDepositCents( + rawDb: AnyDb, + tenantId: string, + inspectionId: string, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = rawDb as any; + const rows: Array<{ kind: PaymentKind; amountCents: number }> = await db.select({ + kind: orderPayments.kind, + amountCents: orderPayments.amountCents, + }) + .from(orderPayments) + .where(and( + eq(orderPayments.tenantId, tenantId), + eq(orderPayments.inspectionId, inspectionId), + isNull(orderPayments.invoiceId), + )) + .all(); + 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 diff --git a/server/services/stripe.service.ts b/server/services/stripe.service.ts index 568849452..bd766e775 100644 --- a/server/services/stripe.service.ts +++ b/server/services/stripe.service.ts @@ -12,7 +12,7 @@ * that runs on the V8-isolate runtime. */ import Stripe from 'stripe'; -import { buildPaymentIntentParams, type PayableInvoice } from '../lib/stripe-helpers'; +import { buildPaymentIntentParams, buildDepositIntentParams, type PayableInvoice } from '../lib/stripe-helpers'; export class StripeService { private stripe: Stripe; @@ -49,6 +49,45 @@ export class StripeService { return { id: intent.id, clientSecret: intent.client_secret }; } + /** + * Creates a PaymentIntent for a booking DEPOSIT — money against an order + * with no invoice behind it. Throws DepositNotPayableError when nothing is + * outstanding, so a double-submit or a race with the webhook cannot charge + * a second deposit. + * + * RETRY SAFETY LIVES HERE, not in the middleware. The route that calls this + * is public and its caller is a payment panel that sends no + * `Idempotency-Key`, so `idempotencyGuard` never engages — and the route + * writes no row of ours, which means the thing a retry could duplicate is + * a PAYMENT INTENT. Two live intents for one deposit is two chargeable + * client secrets. Stripe's own idempotency key removes that: the same key + * returns the SAME intent for 24 hours. + * + * The key includes the OUTSTANDING amount deliberately. Once a partial + * deposit lands, the remainder is a different charge and must get a + * different intent — pinning the key to the order alone would replay a stale + * intent for money already collected. + */ + async createDepositPaymentIntent( + order: { inspectionId: string; outstandingCents: number }, + ctx: { tenantId: string; currency?: string; descriptionPrefix?: string }, + ): Promise<{ id: string; clientSecret: string }> { + const params = buildDepositIntentParams(order, ctx); + const intent = await this.stripe.paymentIntents.create({ + amount: params.amount, + currency: params.currency, + automatic_payment_methods: { enabled: true }, + description: params.description, + metadata: params.metadata, + }, { + idempotencyKey: `oi-deposit:${ctx.tenantId}:${order.inspectionId}:${order.outstandingCents}`, + }); + if (!intent.client_secret) { + throw new Error('Stripe did not return a client secret'); + } + return { id: intent.id, clientSecret: intent.client_secret }; + } + /** * Verifies and parses a Stripe webhook payload against the tenant's * webhook signing secret. Uses the async SubtleCrypto verifier required diff --git a/tests/unit/billing/stripe-helpers.spec.ts b/tests/unit/billing/stripe-helpers.spec.ts index 82b88d4ab..d6d6d2b5f 100644 --- a/tests/unit/billing/stripe-helpers.spec.ts +++ b/tests/unit/billing/stripe-helpers.spec.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest'; import { buildPaymentIntentParams, + buildDepositIntentParams, extractSettledPayment, InvoiceNotPayableError, + DepositNotPayableError, } from '../../../server/lib/stripe-helpers'; describe('buildPaymentIntentParams', () => { @@ -14,9 +16,9 @@ describe('buildPaymentIntentParams', () => { expect(p.currency).toBe('usd'); }); - it('carries invoiceId, tenantId and inspectionId in metadata', () => { + it('carries kind, invoiceId, tenantId and inspectionId in metadata', () => { const p = buildPaymentIntentParams(base, { tenantId: 't_1' }); - expect(p.metadata).toEqual({ invoiceId: 'inv_1', tenantId: 't_1', inspectionId: 'insp_9' }); + expect(p.metadata).toEqual({ kind: 'invoice', invoiceId: 'inv_1', tenantId: 't_1', inspectionId: 'insp_9' }); }); it('omits inspectionId from metadata when not linked', () => { @@ -56,12 +58,54 @@ describe('extractSettledPayment', () => { it('returns the settled ref for a successful payment intent', () => { const out = extractSettledPayment(succeeded({ invoiceId: 'inv_1', tenantId: 't_1', inspectionId: 'insp_9' })); - expect(out).toEqual({ invoiceId: 'inv_1', tenantId: 't_1', inspectionId: 'insp_9' }); + expect(out).toMatchObject({ + tenantId: 't_1', inspectionId: 'insp_9', + purpose: { kind: 'invoice', invoiceId: 'inv_1' }, + }); }); it('returns null inspectionId when absent', () => { const out = extractSettledPayment(succeeded({ invoiceId: 'inv_1', tenantId: 't_1' })); - expect(out).toEqual({ invoiceId: 'inv_1', tenantId: 't_1', inspectionId: null }); + expect(out).toMatchObject({ tenantId: 't_1', inspectionId: null, purpose: { kind: 'invoice', invoiceId: 'inv_1' } }); + }); + + // The blocker this whole shape exists to remove. A deposit intent carries no + // invoiceId — there is no invoice — and under the old rule that made it + // indistinguishable from a stray event: the handler logged `received`, ACKed, + // and the money was in Stripe with nothing in the ledger and nothing saying so. + it('recognises a deposit intent, which carries no invoice at all', () => { + const out = extractSettledPayment({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_dep', amount_received: 9000, metadata: { kind: 'deposit', inspectionId: 'insp_9', tenantId: 't_1' } } }, + }); + expect(out).toEqual({ + tenantId: 't_1', + purpose: { kind: 'deposit', inspectionId: 'insp_9' }, + providerRef: 'pi_dep', + amountCents: 9000, + inspectionId: 'insp_9', + }); + }); + + it('reads an intent minted before `kind` existed as an invoice payment', () => { + const out = extractSettledPayment(succeeded({ invoiceId: 'inv_1', tenantId: 't_1' })); + expect(out?.purpose.kind).toBe('invoice'); + }); + + it('records what SETTLED, not what was asked for', () => { + const out = extractSettledPayment({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_1', amount: 45000, amount_received: 9000, metadata: { kind: 'invoice', invoiceId: 'inv_1', tenantId: 't_1' } } }, + }); + expect(out?.amountCents).toBe(9000); + }); + + it('refuses a kind this build does not know rather than guessing where the money goes', () => { + expect(extractSettledPayment(succeeded({ kind: 'retainer', tenantId: 't_1', invoiceId: 'inv_1' }))).toBeNull(); + }); + + it('refuses a deposit with no inspection to hold it against', () => { + expect(extractSettledPayment(succeeded({ kind: 'deposit', tenantId: 't_1' }))).toBeNull(); }); it('ignores unrelated event types', () => { @@ -74,3 +118,29 @@ describe('extractSettledPayment', () => { expect(extractSettledPayment(succeeded(null))).toBeNull(); }); }); + +describe('buildDepositIntentParams', () => { + const ctx = { tenantId: 't_1' }; + + it('stamps kind + inspectionId, and no invoiceId', () => { + const params = buildDepositIntentParams({ inspectionId: 'insp_9', outstandingCents: 9000 }, ctx); + expect(params.amount).toBe(9000); + expect(params.metadata).toEqual({ kind: 'deposit', inspectionId: 'insp_9', tenantId: 't_1' }); + expect(params.metadata.invoiceId).toBeUndefined(); + }); + + it('charges the OUTSTANDING amount, so an abandoned form is not billed twice', () => { + expect(buildDepositIntentParams({ inspectionId: 'insp_9', outstandingCents: 4000 }, ctx).amount).toBe(4000); + }); + + it('refuses when nothing is outstanding', () => { + expect(() => buildDepositIntentParams({ inspectionId: 'insp_9', outstandingCents: 0 }, ctx)) + .toThrow(DepositNotPayableError); + expect(() => buildDepositIntentParams({ inspectionId: 'insp_9', outstandingCents: -1 }, ctx)) + .toThrow(DepositNotPayableError); + }); + + it('lowercases the currency Stripe expects', () => { + expect(buildDepositIntentParams({ inspectionId: 'i', outstandingCents: 1 }, { ...ctx, currency: 'CAD' }).currency).toBe('cad'); + }); +}); diff --git a/tests/unit/billing/stripe-webhook-handler.spec.ts b/tests/unit/billing/stripe-webhook-handler.spec.ts index 998067efd..e6a52851a 100644 --- a/tests/unit/billing/stripe-webhook-handler.spec.ts +++ b/tests/unit/billing/stripe-webhook-handler.spec.ts @@ -6,6 +6,15 @@ vi.mock('../../../server/services/stripe.service', () => ({ StripeService: class { constructor(_k: string) { void _k; } verifyWebhook = verifyWebhook; }, })); +// The ledger is exercised for real in tests/unit/bookings/deposit-collection.spec.ts. +// Here the question is only whether the handler ROUTES a deposit there at all — +// which it did not before `metadata.kind` existed, and no test noticed. +// `vi.hoisted` because vi.mock factories are lifted above every const in the +// file, and a plain `const` referenced inside one is a TDZ error at import time. +const { recordPayment } = vi.hoisted(() => ({ recordPayment: vi.fn() })); +vi.mock('../../../server/services/payment-ledger.service', () => ({ recordPayment })); +vi.mock('../../../server/lib/route-helpers', () => ({ getDrizzle: () => ({}) })); + import stripeWebhookApi from '../../../server/api/stripe-webhook'; function makeApp(opts: { @@ -43,7 +52,7 @@ const KEYS = { STRIPE_SECRET_KEY: 'sk_test_1', STRIPE_WEBHOOK_SECRET: 'whsec_1' // implicitly returns the mock, which Vitest 4 then surfaces a later thrown/ // rejected result from as a spurious test error even when the handler catches // it. Returning undefined avoids that false failure; semantics are unchanged. -beforeEach(() => { verifyWebhook.mockReset(); }); +beforeEach(() => { verifyWebhook.mockReset(); recordPayment.mockReset(); recordPayment.mockResolvedValue({ id: 'op1' }); }); describe('stripe webhook handler', () => { it('no tenant / no keys → 200 ACK no-op', async () => { @@ -107,4 +116,85 @@ describe('stripe webhook handler', () => { const res = await makeApp({ tenantId: 'tA', env: KEYS, markPaid }).request('/', { method: 'POST', headers: SIG, body: '{}' }); expect(res.status).toBe(200); }); -}); + + /* ---------------------------------------------------------------- * + * Booking deposits — money against an ORDER, with no invoice. * + * ---------------------------------------------------------------- */ + + it('writes a deposit ledger row instead of ACKing it as nothing to do', async () => { + // The regression: `extractSettledPayment` used to return null for any + // intent without `metadata.invoiceId`, so a settled deposit logged + // "received" and vanished. Money in Stripe, no row, no surface. + verifyWebhook.mockResolvedValue({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_dep_1', amount_received: 9000, metadata: { kind: 'deposit', inspectionId: 'insp1', tenantId: 'tA' } } }, + }); + const markPaid = vi.fn(); const markPaymentReceived = vi.fn(); const kvPut = vi.fn(); + const res = await makeApp({ tenantId: 'tA', env: KEYS, markPaid, markPaymentReceived, kvPut }) + .request('/', { method: 'POST', headers: SIG, body: '{}' }); + + expect(res.status).toBe(200); + expect(recordPayment).toHaveBeenCalledWith({}, 'tA', { + inspectionId: 'insp1', + invoiceId: null, + kind: 'deposit', + amountCents: 9000, + method: 'card', + provider: 'stripe', + providerRef: 'pi_dep_1', + }); + expect(String(kvPut.mock.calls[0][1])).toContain('"processed"'); + }); + + it('a deposit does not mark any invoice paid and does not unlock the report', async () => { + // $90 of a $450 job is not payment in full, and `markPaymentReceived` + // is the gate the public report reads. Calling either here would + // release a report for a fifth of the money. + verifyWebhook.mockResolvedValue({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_dep_2', amount_received: 9000, metadata: { kind: 'deposit', inspectionId: 'insp1', tenantId: 'tA' } } }, + }); + const markPaid = vi.fn(); const markPaymentReceived = vi.fn(); + await makeApp({ tenantId: 'tA', env: KEYS, markPaid, markPaymentReceived }) + .request('/', { method: 'POST', headers: SIG, body: '{}' }); + expect(markPaid).not.toHaveBeenCalled(); + expect(markPaymentReceived).not.toHaveBeenCalled(); + }); + + it('a deposit for another tenant is discarded before any write', async () => { + verifyWebhook.mockResolvedValue({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_dep_3', amount_received: 9000, metadata: { kind: 'deposit', inspectionId: 'insp1', tenantId: 'tB' } } }, + }); + const kvPut = vi.fn(); + const res = await makeApp({ tenantId: 'tA', env: KEYS, kvPut }).request('/', { method: 'POST', headers: SIG, body: '{}' }); + expect(res.status).toBe(200); + expect(recordPayment).not.toHaveBeenCalled(); + expect(String(kvPut.mock.calls[0][1])).toContain('tenant_mismatch'); + }); + + it('a deposit write failure is a 500, so Stripe retries rather than losing it', async () => { + verifyWebhook.mockResolvedValue({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_dep_4', amount_received: 9000, metadata: { kind: 'deposit', inspectionId: 'insp1', tenantId: 'tA' } } }, + }); + recordPayment.mockRejectedValue(new Error('D1 down')); + const kvPut = vi.fn(); + const res = await makeApp({ tenantId: 'tA', env: KEYS, kvPut }).request('/', { method: 'POST', headers: SIG, body: '{}' }); + expect(res.status).toBe(500); + expect(kvPut.mock.calls.map(c2 => String(c2[1])).some(p => p.includes('"processed"'))).toBe(false); + }); + + it('a redelivered deposit is a no-op, not an error', async () => { + // recordPayment answers null when the provider ref is already on file. + verifyWebhook.mockResolvedValue({ + type: 'payment_intent.succeeded', + data: { object: { id: 'pi_dep_1', amount_received: 9000, metadata: { kind: 'deposit', inspectionId: 'insp1', tenantId: 'tA' } } }, + }); + recordPayment.mockResolvedValue(null); + const kvPut = vi.fn(); + const res = await makeApp({ tenantId: 'tA', env: KEYS, kvPut }).request('/', { method: 'POST', headers: SIG, body: '{}' }); + expect(res.status).toBe(200); + expect(String(kvPut.mock.calls[0][1])).toContain('"processed"'); + }); +}); \ No newline at end of file diff --git a/tests/unit/bookings/deposit-collection.spec.ts b/tests/unit/bookings/deposit-collection.spec.ts new file mode 100644 index 000000000..508488e2e --- /dev/null +++ b/tests/unit/bookings/deposit-collection.spec.ts @@ -0,0 +1,281 @@ +/** + * Collecting a deposit at booking, against a real database. + * + * The one that matters commercially is the FIRST one. A declined card that + * also loses the appointment is worse than having no deposit feature at all, + * so `POST /book` never charges anything: it creates the booking, freezes what + * is owed, and hands the amount back. Everything about payment happens + * afterwards, and the only thing that can write a deposit into the ledger is + * the Stripe webhook. + * + * The rest guard the numbers around it: nothing is owed when nothing is + * configured, the frozen amount survives a later reprice, and a multi-service + * booking has ONE deposit rather than N. + */ +import { describe, it, expect, beforeEach, afterEach, 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 { + tenants, users, availability, tenantConfigs, inspections, services, orderPayments, +} from '../../../server/lib/db/schema'; +import { BookingService } from '../../../server/services/booking.service'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; +import { recordPayment, getHeldDepositCents } from '../../../server/services/payment-ledger.service'; +import { outstandingDepositCents } from '../../../server/services/booking/deposit'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order +import { bookingsRoutes } from '../../../server/api/bookings'; + +vi.mock('../../../server/lib/rate-limit', () => ({ + checkRateLimit: vi.fn().mockResolvedValue(undefined), +})); + +const TENANT_ID = 'aaaaaaaa-0000-0000-0000-00000000dep1'; +const TENANT_SLUG = 'deposit-co'; +/** A Friday, not a US federal holiday. */ +const FRIDAY = '2026-07-17'; +const SVC_MAIN = 'svc-main'; +const SVC_RADON = 'svc-radon'; + +const FAKE_ENV = { DB: {} } as HonoConfig['Bindings']; +const FAKE_EXEC_CTX = { + waitUntil: (p: Promise) => { void p.catch(() => {}); }, + passThroughOnException: () => {}, +} as ExecutionContext; + +let db: BetterSQLite3Database; +let sqlite: ReturnType['sqlite']; +let svc: BookingService; + +beforeEach(async () => { + const setup = createTestDb(); + db = setup.db as BetterSQLite3Database; + sqlite = setup.sqlite; + await setupSchema(sqlite); + (mockDrizzle as ReturnType).mockReturnValue(db); + svc = new BookingService({} as D1Database); + + await db.insert(tenants).values({ + id: TENANT_ID, name: 'Deposit Co', slug: TENANT_SLUG, + tier: 'pro', status: 'active', maxUsers: 5, + deploymentMode: 'shared', createdAt: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + await db.insert(users).values({ + id: 'insp-1', tenantId: TENANT_ID, email: 'insp1@x.com', + passwordHash: 'h', role: 'inspector', name: 'Solo', createdAt: new Date(), + }); + await db.insert(availability).values({ + id: 'av-1', tenantId: TENANT_ID, inspectorId: 'insp-1', + dayOfWeek: 5, startTime: '08:00', endTime: '12:00', createdAt: new Date(), + }); + // The multi-service branch refuses a service with no template, so both + // carry one — this spec is about money, not about that guard. + await db.insert(schema.templates).values({ + id: 'tpl-1', tenantId: TENANT_ID, name: 'Standard', version: 1, + schema: { sections: [] }, createdAt: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + await db.insert(services).values([ + { id: SVC_MAIN, tenantId: TENANT_ID, name: 'Home inspection', price: 45000, templateId: 'tpl-1', createdAt: new Date() }, + { id: SVC_RADON, tenantId: TENANT_ID, name: 'Radon', price: 9500, templateId: 'tpl-1', createdAt: new Date() }, + ]); +}); + +afterEach(() => sqlite.close()); + +async function setTenantDeposit(policy: schema.TenantConfig['depositPolicy'] | null) { + await db.insert(tenantConfigs) + .values({ tenantId: TENANT_ID, updatedAt: new Date(), defaultTimezone: 'UTC', depositPolicy: policy }) + .onConflictDoUpdate({ target: tenantConfigs.tenantId, set: { depositPolicy: policy } }); +} + +/** + * The multi-service branch delegates to InspectionRequestService, which is + * stubbed here — but it must produce REAL inspection rows, or the deposit + * snapshot would silently update nothing and the test would pass on air. + */ +function buildApp(createdInspectionIds: string[]) { + 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 as 400); + } + return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500); + }); + app.use('*', async (c, next) => { + c.set('services', { + booking: svc, + widget: { isOriginAllowed: vi.fn().mockResolvedValue(true), recordEvent: vi.fn().mockResolvedValue(undefined) }, + email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, + automation: { trigger: vi.fn().mockResolvedValue(undefined) }, + contact: { upsertClientContact: vi.fn().mockResolvedValue({ id: 'c1' }) }, + inspectionRequest: { + create: vi.fn(async () => { + await db.insert(schema.inspectionRequests).values({ + id: 'req-x', tenantId: TENANT_ID, clientName: 'Client', + propertyAddress: '1 Oak St', scheduledAt: new Date(`${FRIDAY}T08:00:00Z`), + createdAt: new Date(), updatedAt: new Date(), + }); + await db.insert(inspections).values(createdInspectionIds.map((id, i) => ({ + id, tenantId: TENANT_ID, inspectorId: 'insp-1', + propertyAddress: '1 Oak St', date: `${FRIDAY}T08:00:00Z`, + status: 'requested' as const, paymentStatus: 'unpaid' as const, + price: i === 0 ? 45000 : 9500, requestId: 'req-x', createdAt: new Date(), + }))); + return { id: 'req-x', inspections: createdInspectionIds.map(id => ({ id })) }; + }), + }, + } as unknown as HonoConfig['Variables']['services']); + await next(); + }); + app.route('/', bookingsRoutes); + return app; +} + +function book(app: ReturnType, serviceIds: string[]) { + return app.request('/book', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tenant: TENANT_SLUG, + address: '1 Oak St, City, ST 12345', + clientName: 'Client', clientEmail: 'c@example.com', + date: FRIDAY, timeSlot: 'morning', + services: serviceIds.map(serviceId => ({ serviceId })), + }), + }, FAKE_ENV, FAKE_EXEC_CTX); +} + +const bodyOf = async (res: Response) => + (await res.json()) as { data: { success: boolean; inspectionId: string; depositRequiredCents?: number } }; + +describe('POST /book with a deposit configured', () => { + it('books the appointment and owes the deposit — no charge, no ledger row', async () => { + // The commercially load-bearing case. Nothing in this request talks to + // Stripe, so there is no card to decline: whatever happens next, the + // client has the appointment they believe they made, and the tenant can + // see exactly what is unpaid. + await setTenantDeposit({ type: 'percent', percent: 20 }); + const res = await book(buildApp(['insp-a']), [SVC_MAIN]); + if (res.status !== 200) throw new Error(await res.text()); + expect(res.status).toBe(200); + + const body = await bodyOf(res); + expect(body.data.success).toBe(true); + expect(body.data.depositRequiredCents).toBe(9000); + + const row = await db.select().from(inspections).where(eq(inspections.id, 'insp-a')).get(); + expect(row!.depositRequiredCents).toBe(9000); + expect(row!.depositOverridden).toBe(false); + + // Owed, not collected. Nothing may write this row but the webhook. + expect(await getHeldDepositCents(db, TENANT_ID, 'insp-a')).toBe(0); + const ledger = await db.select().from(orderPayments).where(eq(orderPayments.tenantId, TENANT_ID)).all(); + expect(ledger).toHaveLength(0); + }); + + it('freezes the amount, so repricing the service later does not move it', async () => { + await setTenantDeposit({ type: 'percent', percent: 20 }); + await book(buildApp(['insp-a']), [SVC_MAIN]); + + await db.update(services).set({ price: 90000 }).where(eq(services.id, SVC_MAIN)); + + const row = await db.select().from(inspections).where(eq(inspections.id, 'insp-a')).get(); + expect(row!.depositRequiredCents).toBe(9000); + const owed = await outstandingDepositCents(db, TENANT_ID, 'insp-a'); + expect(owed!.outstandingCents).toBe(9000); + }); + + it('puts ONE deposit on the order, on the primary, with the siblings at zero', async () => { + // Not NULL on the siblings: NULL reads as "no deposit configured", and + // what is true is "covered by the order's". + await setTenantDeposit({ type: 'percent', percent: 20 }); + const res = await book(buildApp(['insp-a', 'insp-b']), [SVC_MAIN, SVC_RADON]); + expect((await bodyOf(res)).data.depositRequiredCents).toBe(10900); + + const rows = await db.select().from(inspections).where(eq(inspections.tenantId, TENANT_ID)).all(); + expect(rows.map(r => [r.id, r.depositRequiredCents]).sort()).toEqual([ + ['insp-a', 10900], + ['insp-b', 0], + ]); + }); + + it('honours a service that opted out of the workspace default', async () => { + await setTenantDeposit({ type: 'percent', percent: 20 }); + await db.update(services).set({ depositPolicy: { type: 'none' } }).where(eq(services.id, SVC_RADON)); + const res = await book(buildApp(['insp-a', 'insp-b']), [SVC_MAIN, SVC_RADON]); + expect((await bodyOf(res)).data.depositRequiredCents).toBe(9000); + }); +}); + +describe('POST /book with no deposit configured', () => { + it('asks for nothing and leaves the column untouched', async () => { + await setTenantDeposit(null); + const res = await book(buildApp(['insp-a']), [SVC_MAIN]); + expect(res.status).toBe(200); + expect((await bodyOf(res)).data.depositRequiredCents).toBe(0); + + const row = await db.select().from(inspections).where(eq(inspections.id, 'insp-a')).get(); + // NULL, not 0: this workspace has no deposit at all, which is different + // from "this order's deposit is zero". The booking form reads the + // response, and 0 there is what makes it render no payment step. + expect(row!.depositRequiredCents).toBeNull(); + }); + + it('books normally when the workspace has no config row at all', async () => { + const res = await book(buildApp(['insp-a']), [SVC_MAIN]); + expect(res.status).toBe(200); + expect((await bodyOf(res)).data.depositRequiredCents).toBe(0); + }); +}); + +describe('the deposit becomes real only when Stripe says so', () => { + beforeEach(async () => { + await setTenantDeposit({ type: 'percent', percent: 20 }); + await book(buildApp(['insp-a']), [SVC_MAIN]); + }); + + const settle = (providerRef: string, amountCents = 9000) => + recordPayment(db, TENANT_ID, { + inspectionId: 'insp-a', invoiceId: null, kind: 'deposit', + amountCents, method: 'card', provider: 'stripe', providerRef, + }); + + it('records it on webhook confirmation, held against the order with no invoice', async () => { + expect(await getHeldDepositCents(db, TENANT_ID, 'insp-a')).toBe(0); + await settle('pi_1'); + expect(await getHeldDepositCents(db, TENANT_ID, 'insp-a')).toBe(9000); + + const row = await db.select().from(orderPayments).where(eq(orderPayments.providerRef, 'pi_1')).get(); + expect(row!.invoiceId).toBeNull(); + expect(row!.kind).toBe('deposit'); + }); + + it('is idempotent across redelivery — the second call appends nothing', async () => { + expect(await settle('pi_1')).not.toBeNull(); + // Null is the contract for "already recorded", and it is what the + // handler keys its "did anything happen" decision on. + expect(await settle('pi_1')).toBeNull(); + expect(await getHeldDepositCents(db, TENANT_ID, 'insp-a')).toBe(9000); + }); + + it('nets what has landed against what is owed', async () => { + await settle('pi_partial', 4000); + const owed = await outstandingDepositCents(db, TENANT_ID, 'insp-a'); + expect(owed).toEqual({ requiredCents: 9000, heldCents: 4000, outstandingCents: 5000 }); + }); + + it('does not go negative when more lands than was asked for', async () => { + await settle('pi_big', 15000); + expect((await outstandingDepositCents(db, TENANT_ID, 'insp-a'))!.outstandingCents).toBe(0); + }); +}); diff --git a/tests/unit/idempotency/booking-deposit-intent-replay.spec.ts b/tests/unit/idempotency/booking-deposit-intent-replay.spec.ts new file mode 100644 index 000000000..cb690ae4b --- /dev/null +++ b/tests/unit/idempotency/booking-deposit-intent-replay.spec.ts @@ -0,0 +1,177 @@ +/** + * `POST /api/public/inspections/{id}/deposit-intent` — a retry must not create + * a second chargeable client secret. + * + * This route is the awkward one for the mounted guard, and the reason is worth + * stating rather than discovering. `idempotencyGuard` engages only when a + * tenant is on the context AND the client sends an `Idempotency-Key`. A tenant + * IS on the context here (path-param routing resolves it from the inspection + * id), but the caller is a Stripe Elements panel in an anonymous booker's + * browser, which sends no such header and never will. So the guard is not the + * story. + * + * The story is two things, and both are asserted below: + * + * 1. THE ROUTE WRITES NOTHING OF OURS. No ledger row, no column. What a + * retry could duplicate is a Stripe PaymentIntent, and two live intents + * for one deposit are two ways to be charged. + * 2. STRIPE'S OWN IDEMPOTENCY KEY closes that. The same key returns the same + * intent for 24 hours, and the key carries the OUTSTANDING amount so a + * partial deposit correctly gets a fresh intent for the remainder rather + * than replaying a stale one. + * + * And the backstop, also asserted: once the deposit has actually landed, the + * route refuses outright — a replay after settlement cannot charge again even + * if Stripe's window has expired. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; + +const { createDepositPaymentIntent } = vi.hoisted(() => ({ createDepositPaymentIntent: vi.fn() })); +vi.mock('../../../server/services/stripe.service', () => ({ + StripeService: class { + constructor(_k: string) { void _k; } + createDepositPaymentIntent = createDepositPaymentIntent; + }, +})); +vi.mock('../../../server/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockResolvedValue(undefined) })); +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order +import depositIntentRoutes from '../../../server/api/public/deposit-intent'; +// eslint-disable-next-line import/order +import { recordPayment } from '../../../server/services/payment-ledger.service'; +// eslint-disable-next-line import/order +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +// eslint-disable-next-line import/order +import { AppError } from '../../../server/lib/errors'; +// eslint-disable-next-line import/order +import type { HonoConfig } from '../../../server/types/hono'; + +const TENANT = '00000000-0000-0000-0000-0000000000d1'; +const INSPECTION = 'insp-0000-0000-0000-000000000d01'; + +let db: BetterSQLite3Database; + +const ENV = { + DB: {}, + STRIPE_SECRET_KEY: 'sk_test_1', + STRIPE_PUBLISHABLE_KEY: 'pk_test_1', +} as never; +const CTX = { waitUntil: (p: Promise) => void p, passThroughOnException: () => {} } as never; + +/** The mounted shape: tenant resolved by path-param routing, then the guard. */ +function buildApp() { + const app = new OpenAPIHono(); + app.use('*', async (c, next) => { + c.set('resolvedTenantId', TENANT as never); + c.set('tenantId', TENANT); + await next(); + }); + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/public', depositIntentRoutes); + 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; +} + +function startDeposit() { + const req = new Request(`https://acme.example.com/api/public/inspections/${INSPECTION}/deposit-intent`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', + }); + return buildApp().fetch(req, ENV, CTX); +} + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + createDepositPaymentIntent.mockReset(); + createDepositPaymentIntent.mockResolvedValue({ id: 'pi_1', clientSecret: 'pi_1_secret' }); + + 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, updatedAt: new Date() }); + await db.insert(schema.inspections).values({ + id: INSPECTION, tenantId: TENANT, propertyAddress: '1 Oak St', + date: '2026-07-17', status: 'requested', createdAt: new Date(), + depositRequiredCents: 9000, + }); +}); + +const ledgerRows = () => + db.select().from(schema.orderPayments).where(eq(schema.orderPayments.tenantId, TENANT)).all(); + +describe("POST '/api/public/inspections/{id}/deposit-intent' — replay does not create a second chargeable intent", () => { + it('writes nothing of ours, on the first call or the second', async () => { + expect((await startDeposit()).status).toBe(200); + expect((await startDeposit()).status).toBe(200); + expect(await ledgerRows()).toHaveLength(0); + }); + + it("hands Stripe the SAME idempotency key both times, so it returns one intent", async () => { + await startDeposit(); + await startDeposit(); + expect(createDepositPaymentIntent).toHaveBeenCalledTimes(2); + // The service builds the key; asserting on the arguments it was given is + // what keeps this test honest about WHICH intent is being asked for. + const [firstOrder] = createDepositPaymentIntent.mock.calls[0] as [{ inspectionId: string; outstandingCents: number }]; + const [secondOrder] = createDepositPaymentIntent.mock.calls[1] as [{ inspectionId: string; outstandingCents: number }]; + expect(secondOrder).toEqual(firstOrder); + expect(firstOrder).toEqual({ inspectionId: INSPECTION, outstandingCents: 9000 }); + }); + + it('asks for the REMAINDER after a partial deposit, not the original amount', async () => { + await recordPayment(db, TENANT, { + inspectionId: INSPECTION, invoiceId: null, kind: 'deposit', + amountCents: 4000, method: 'card', provider: 'stripe', providerRef: 'pi_partial', + }); + await startDeposit(); + const [order] = createDepositPaymentIntent.mock.calls[0] as [{ outstandingCents: number }]; + // A key pinned to the order alone would replay the $90 intent here and + // charge for money already collected. + expect(order.outstandingCents).toBe(5000); + }); + + it('refuses once the deposit has landed, whatever Stripe would replay', async () => { + await recordPayment(db, TENANT, { + inspectionId: INSPECTION, invoiceId: null, kind: 'deposit', + amountCents: 9000, method: 'card', provider: 'stripe', providerRef: 'pi_1', + }); + const res = await startDeposit(); + expect(res.status).toBe(404); + expect(createDepositPaymentIntent).not.toHaveBeenCalled(); + }); + + it('refuses an order with no deposit configured, and an unknown id, identically', async () => { + await db.update(schema.inspections).set({ depositRequiredCents: null }) + .where(eq(schema.inspections.id, INSPECTION)); + expect((await startDeposit()).status).toBe(404); + + await db.delete(schema.inspections).where(eq(schema.inspections.id, INSPECTION)); + // Same status and same body: the route must not double as a probe for + // which inspection ids exist. + expect((await startDeposit()).status).toBe(404); + expect(createDepositPaymentIntent).not.toHaveBeenCalled(); + }); + + it('refuses once the visit is under way — a deposit only holds a slot', async () => { + await db.update(schema.inspections).set({ status: 'in_progress' }) + .where(eq(schema.inspections.id, INSPECTION)); + expect((await startDeposit()).status).toBe(404); + expect(createDepositPaymentIntent).not.toHaveBeenCalled(); + }); +}); From bbbc2144361c75931b57186ad78fdbb1a45a5a40 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 21:25:45 +0800 Subject: [PATCH 57/77] feat(deposit): the deposit lands on the invoice, and the report stays shut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising an invoice is where a held deposit stops being a liability and becomes money against that invoice. It is the ONE exception to the ledger's append-only rule — `invoice_id` written once onto rows that predate the invoice — and nothing else about the row moves. The re-sync of the report gate is the line that looks wrong and is not. This function can only ever ADD money, so downgrading a gate reads as backwards until you notice what the gate actually caches: "some unvoided invoice on this order is paid". Applying a deposit moves an order from "no invoice" to "an invoice with a partial payment", and the re-sync is what asserts IN CODE that $90 against $450 leaves the report locked. The test sets `payment_status = 'paid'` first so it fails loudly if the call ever goes away — deleting it turns green to red with 'paid' to be 'unpaid', which is the sentence a reader needs to see. Idempotent by construction rather than by a flag: it claims only rows whose `invoice_id` IS NULL, so a second invoice on the same order finds nothing to claim and the client is not credited twice. `createInvoice` now returns `amountPaidCents` / `partialPaidAt`. The hand-built row it answered with carried neither — they are written by `recomputeInvoicePaymentState` — so a caller reading a deposit-bearing invoice got `undefined` and reported it as having received nothing. Also asserted: the held-deposit count on the QBO Books health card falls to zero when the deposit is applied. That figure is the tenant's "record these manually" list, and a list that only ever grows is one nobody reads. --- server/services/invoice.service.ts | 28 ++- .../services/invoice/deposit-application.ts | 68 +++++++ .../unit/invoices/deposit-application.spec.ts | 186 ++++++++++++++++++ 3 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 server/services/invoice/deposit-application.ts create mode 100644 tests/unit/invoices/deposit-application.spec.ts diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts index 42f26b67c..9ce6b9188 100644 --- a/server/services/invoice.service.ts +++ b/server/services/invoice.service.ts @@ -12,6 +12,7 @@ import { syncInspectionPaymentGate } from './invoice-payment-gate'; import * as ledger from './invoice-payments.service'; import type { OfflinePaymentInput, PaymentCorrectionInput } from './invoice-payments.service'; import * as refunds from './invoice/refund'; +import { applyHeldDepositsToInvoice } from './invoice/deposit-application'; import type { PartialRefundInput } from './invoice/refund'; function getStatus(inv: { sentAt: Date | null; paidAt: Date | null; partialPaidAt?: Date | null; voidedAt?: Date | null }): 'draft' | 'sent' | 'paid' | 'partial' | 'void' { @@ -127,12 +128,37 @@ export class InvoiceService { currency: cfg?.currency ?? 'USD', }; await db.insert(invoices).values(row); + // A deposit taken at booking has been sitting against the ORDER with no + // invoice to belong to. This is where it becomes money against this + // invoice — and where the client stops being shown the full total with + // no sign their deposit landed. Awaited, not fired: `amountPaidCents` + // below has to reflect it, and a client who pays twice calls. + let amountPaidCents = 0; + let partialPaidAt: Date | null = null; if (data.inspectionId) { + const applied = await applyHeldDepositsToInvoice(db, tenantId, row.id, data.inspectionId); + if (applied > 0) { + const fresh = await db.select({ + amountPaidCents: invoices.amountPaidCents, + partialPaidAt: invoices.partialPaidAt, + }) + .from(invoices).where(eq(invoices.id, row.id)).get(); + amountPaidCents = fresh?.amountPaidCents ?? 0; + partialPaidAt = fresh?.partialPaidAt ?? null; + } new AutomationService(this.db) .trigger({ tenantId, inspectionId: data.inspectionId, triggerEvent: 'invoice.created', companyName: '', reportBaseUrl: '' }) .catch(err => logger.error('automation trigger failed', { event: 'invoice.created' }, err instanceof Error ? err : undefined)); } - return { ...row, status: 'draft' as const, createdAt: safeISODate(row.createdAt), sentAt: null, paidAt: null }; + // `status` stays 'draft': a deposit does not send an invoice. The two + // payment figures are returned because the hand-built `row` above does + // not carry them — they are written by `recomputeInvoicePaymentState`, + // and a caller reading `undefined` here would report a deposit-bearing + // invoice as having received nothing. + return { + ...row, status: 'draft' as const, createdAt: safeISODate(row.createdAt), sentAt: null, paidAt: null, + amountPaidCents, partialPaidAt, + }; } async markSent(id: string, tenantId: string) { diff --git a/server/services/invoice/deposit-application.ts b/server/services/invoice/deposit-application.ts new file mode 100644 index 000000000..f84434681 --- /dev/null +++ b/server/services/invoice/deposit-application.ts @@ -0,0 +1,68 @@ +/** + * The moment a held deposit becomes an invoice payment. + * + * Under GAAP a customer deposit is a LIABILITY — unearned revenue — not + * income, until the work is delivered. So the deposit is held against the + * ORDER (`order_payments` with a null `invoice_id`), and only when an invoice + * exists does it become money against that invoice. This is that transition, + * and it is the ONE exception to the ledger's append-only rule: `invoice_id` + * is written once onto rows that predate the invoice. Nothing else about the + * row changes — not the amount, not the kind, not when the money moved. + * + * TWO THINGS THAT LOOK OPTIONAL AND ARE NOT: + * + * - `recomputeInvoicePaymentState` is the only writer of the invoice's cached + * `amount_paid_cents` / `partial_paid_at` / `paid_at`, and backfilling rows + * under it without calling it leaves the cache disagreeing with the money. + * - `syncInspectionPaymentGate` runs even though this can only ever ADD money. + * That reads backwards until you notice what it actually guards: the gate is + * a cache of "some unvoided invoice on this order is paid", and this + * function moves an order from "no invoice" to "an invoice with a partial + * payment". The re-sync is what asserts, in code rather than in a comment, + * that a $90 deposit against a $450 invoice leaves the report LOCKED. A + * deposit is a scheduling instrument; paid-in-full is what releases a report. + * + * IDEMPOTENT BY CONSTRUCTION. It claims only rows whose `invoice_id` IS NULL, + * so a second invoice on the same order finds nothing left to claim — the + * deposit is applied once, to the first invoice raised, and a later invoice + * starts from zero rather than double-crediting the client. + */ +import { and, eq, isNull } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { orderPayments } from '../../lib/db/schema/order-payment'; +import { recomputeInvoicePaymentState } from '../payment-ledger.service'; +import { syncInspectionPaymentGate } from '../invoice-payment-gate'; + +/** + * Attach every payment held against this order to the invoice just raised. + * Returns how many rows moved — zero is the overwhelmingly common answer, and + * costs one indexed read. + */ +export async function applyHeldDepositsToInvoice( + db: DrizzleD1Database, + tenantId: string, + invoiceId: string, + inspectionId: string, +): Promise { + const held = await db.select({ id: orderPayments.id }) + .from(orderPayments) + .where(and( + eq(orderPayments.tenantId, tenantId), + eq(orderPayments.inspectionId, inspectionId), + isNull(orderPayments.invoiceId), + )) + .all(); + if (held.length === 0) return 0; + + await db.update(orderPayments) + .set({ invoiceId }) + .where(and( + eq(orderPayments.tenantId, tenantId), + eq(orderPayments.inspectionId, inspectionId), + isNull(orderPayments.invoiceId), + )); + + await recomputeInvoicePaymentState(db, tenantId, invoiceId); + await syncInspectionPaymentGate(db, tenantId, inspectionId); + return held.length; +} diff --git a/tests/unit/invoices/deposit-application.spec.ts b/tests/unit/invoices/deposit-application.spec.ts new file mode 100644 index 000000000..34f756615 --- /dev/null +++ b/tests/unit/invoices/deposit-application.spec.ts @@ -0,0 +1,186 @@ +/** + * A held deposit becoming an invoice payment — and, just as load-bearing, NOT + * becoming one when it should stay held. + * + * Under GAAP a customer deposit is a liability until the work is delivered, so + * it lives against the ORDER with a null `invoice_id`. This is the one moment + * it moves, and three things have to be true afterwards or money appears in one + * place and not another: + * + * 1. The invoice's cached `amount_paid_cents` reflects it. That column has + * exactly one writer, and backfilling rows underneath it without calling + * that writer is how a cache and a ledger drift apart for three weeks. + * 2. The REPORT STAYS LOCKED. $90 against $450 is partial, and a deposit is a + * scheduling instrument, not the thing that releases a report. This is the + * assertion the whole feature is one bug away from failing. + * 3. The held total goes to ZERO — which is also what the QBO Books health + * card counts, so the "not yet synced to QuickBooks" figure must fall by + * one when a deposit is applied. A count that only ever grows is a count + * nobody trusts. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { and, eq, isNull } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import { recordPayment, getHeldDepositCents } from '../../../server/services/payment-ledger.service'; +import { applyHeldDepositsToInvoice } from '../../../server/services/invoice/deposit-application'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +// eslint-disable-next-line import/order +import { InvoiceService } from '../../../server/services/invoice.service'; + +const TENANT = '00000000-0000-0000-0000-0000000000a1'; +const INSPECTION = 'insp-0000-0000-0000-0000000000a1'; + +let db: BetterSQLite3Database; +let invoices: InvoiceService; + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + invoices = 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.tenantConfigs).values({ tenantId: TENANT, updatedAt: new Date() }); + await db.insert(schema.inspections).values({ + id: INSPECTION, tenantId: TENANT, propertyAddress: '1 Oak St', + date: '2026-07-17', createdAt: new Date(), + depositRequiredCents: 9000, + }); +}); + +const payDeposit = (amountCents = 9000, providerRef = 'pi_dep') => + recordPayment(db, TENANT, { + inspectionId: INSPECTION, invoiceId: null, kind: 'deposit', + amountCents, method: 'card', provider: 'stripe', providerRef, + }); + +const createInvoiceFor = (amountCents: number) => + invoices.createInvoice(TENANT, { + inspectionId: INSPECTION, + clientName: 'Dana Buyer', + amountCents, + lineItems: [{ description: 'Inspection', amountCents }], + }); + +const invoiceRow = (id: string) => + db.select().from(schema.invoices).where(eq(schema.invoices.id, id)).get(); + +const rowsFor = (invoiceId: string) => + db.select().from(schema.orderPayments) + .where(and(eq(schema.orderPayments.tenantId, TENANT), eq(schema.orderPayments.invoiceId, invoiceId))) + .all(); + +/** + * What the QBO Books health card counts, by the same predicate it uses: + * `order_payments` rows with a null `invoice_id` (qbo/connection.ts). + */ +const heldDepositCount = async () => + (await db.select().from(schema.orderPayments) + .where(and(eq(schema.orderPayments.tenantId, TENANT), isNull(schema.orderPayments.invoiceId))) + .all()).length; + +describe('applying a held deposit when the invoice is created', () => { + it('moves the row onto the invoice and refreshes the invoice cache', async () => { + await payDeposit(); + const inv = await createInvoiceFor(45000); + + const attached = await rowsFor(inv.id); + expect(attached).toHaveLength(1); + expect(attached[0]).toMatchObject({ kind: 'deposit', amountCents: 9000 }); + + // Returned by createInvoice — the hand-built row it used to answer with + // carried neither of these, so a caller read `undefined` and reported a + // deposit-bearing invoice as having received nothing. + expect(inv.amountPaidCents).toBe(9000); + expect(inv.partialPaidAt).not.toBeNull(); + + const stored = await invoiceRow(inv.id); + expect(stored!.amountPaidCents).toBe(9000); + expect(stored!.partialPaidAt).not.toBeNull(); + expect(stored!.paidAt).toBeNull(); + }); + + it('LEAVES THE REPORT GATED — a deposit is not payment in full', async () => { + await db.update(schema.inspections).set({ paymentStatus: 'paid' }) + .where(eq(schema.inspections.id, INSPECTION)); + await payDeposit(); + await createInvoiceFor(45000); + + const insp = await db.select().from(schema.inspections) + .where(eq(schema.inspections.id, INSPECTION)).get(); + // $90 against $450. If this ever reads 'paid', the deposit has become a + // way to read a report for a fifth of the money. + expect(insp!.paymentStatus).toBe('unpaid'); + }); + + it('decrements the held-deposit count the QBO health card reports', async () => { + await payDeposit(); + expect(await heldDepositCount()).toBe(1); + expect(await getHeldDepositCents(db, TENANT, INSPECTION)).toBe(9000); + + await createInvoiceFor(45000); + + expect(await heldDepositCount()).toBe(0); + expect(await getHeldDepositCents(db, TENANT, INSPECTION)).toBe(0); + }); + + it('does not double-apply to a second invoice on the same order', async () => { + await payDeposit(); + const first = await createInvoiceFor(45000); + const second = await createInvoiceFor(5000); + + expect(await rowsFor(first.id)).toHaveLength(1); + expect(await rowsFor(second.id)).toHaveLength(0); + expect(second.amountPaidCents).toBe(0); + }); + + it('marks the invoice paid when the deposit covers it outright', async () => { + // A $90 deposit against a $90 invoice IS paid in full, and refusing to + // say so would leave a settled job looking outstanding forever. + await payDeposit(); + const inv = await createInvoiceFor(9000); + const stored = await invoiceRow(inv.id); + expect(stored!.paidAt).not.toBeNull(); + expect(stored!.amountPaidCents).toBe(9000); + }); + + it('costs one read and changes nothing when no deposit was taken', async () => { + const inv = await createInvoiceFor(45000); + expect(await rowsFor(inv.id)).toHaveLength(0); + expect(inv.amountPaidCents).toBe(0); + expect(inv.partialPaidAt).toBeNull(); + const stored = await invoiceRow(inv.id); + // NULL in the column, 0 in the return, and the difference is deliberate: + // the ledger has NO OPINION about this invoice, which is not the same as + // "nothing was paid" — that distinction is what stops the cache writer + // zeroing an invoice paid before the ledger existed. A brand-new invoice + // has genuinely received nothing, so 0 is the honest answer to a caller. + expect(stored!.amountPaidCents).toBeNull(); + }); + + it('leaves a standalone invoice alone — there is no order to sweep', async () => { + await payDeposit(); + const inv = await invoices.createInvoice(TENANT, { + inspectionId: null, clientName: 'Walk-in', amountCents: 20000, + lineItems: [{ description: 'Consultation', amountCents: 20000 }], + }); + expect(await rowsFor(inv.id)).toHaveLength(0); + expect(await getHeldDepositCents(db, TENANT, INSPECTION)).toBe(9000); + }); + + it('is idempotent called twice against the same invoice', async () => { + await payDeposit(); + const inv = await createInvoiceFor(45000); + const moved = await applyHeldDepositsToInvoice(db, TENANT, inv.id, INSPECTION); + expect(moved).toBe(0); + expect(await rowsFor(inv.id)).toHaveLength(1); + }); +}); From 624aa97e8afb35ab5402a88f6587843560d084ec Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 21:28:31 +0800 Subject: [PATCH 58/77] feat(cancellation): a held deposit is collected money, and needs its own writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam O5's ladder and this plan meet at. `resolveCancellation` caps every fee at `paidCents`, and a booking deposit sat outside that number because it has no invoice. So the exact case the deposit exists for — a no-show on a job nobody invoiced — quoted zero collected, charged nothing, refunded nothing, and left the money held with no surface saying so. The feature was inert precisely where it was supposed to bite. It counts now. Counting it alone would have been WORSE than leaving it out, and that is why the second writer is in the same commit. `applyCancellationRefund` needed an invoice; a quote that promised $90 back against a write that returned null is a promise the product does not keep. Deleting the routing turns the spec red with "expected null not to be null", which is the sentence describing that failure exactly. `refundPartial` was NOT bent to take a null invoice. Its body is "load the invoice, seed its ledger from its own record, check what IT received, append against it, recompute ITS cache" — four of those five steps mean nothing without an invoice, and a flag would have put two functions behind one name with the guard-skipping one handling money nobody has billed for yet. `refundHeldDeposit` is a sibling in the same file, and its header states what is absent and why. The two pools are normally disjoint — raising an invoice backfills `invoice_id` onto the deposit rows — and overlap only when a webhook lands after the invoice was raised. That case drains the INVOICE first: its `amount_paid_cents` is a figure a human reads off a screen, and leaving it overstated while the refund came out of an invisible pool is the same "cash in one place and not the other" failure in miniature. The retained-Stripe-fee quote moved to order scope too. A deposit is the likeliest card payment on a job cancelled before invoicing, so the invoice-scoped lookup quoted a $0 processing loss on exactly the cancellation where Stripe has kept its fee. --- .../inspection/cancellation.service.ts | 96 ++++++-- server/services/invoice/refund.ts | 65 ++++++ .../billing/cancellation-held-deposit.spec.ts | 220 ++++++++++++++++++ 3 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 tests/unit/billing/cancellation-held-deposit.spec.ts diff --git a/server/services/inspection/cancellation.service.ts b/server/services/inspection/cancellation.service.ts index 4af07e637..204339cae 100644 --- a/server/services/inspection/cancellation.service.ts +++ b/server/services/inspection/cancellation.service.ts @@ -7,12 +7,20 @@ * not two that drift. `applyCancellationRefund` takes a quote and appends the * ledger row. * - * The quote is scoped to the inspection's invoice. A booking deposit taken - * before any invoice exists is representable in the ledger (`order_payments` - * allows a null `invoice_id`) but nothing writes one today, and there would be - * no invoice to append the reversal against — so an order with no invoice - * quotes zero collected, which charges nothing and refunds nothing. When the - * deposit path lands, that is the line to revisit. + * WHAT WAS COLLECTED IS NOT THE SAME AS WHAT THE INVOICE RECEIVED. A booking + * deposit is money the client has actually paid, sitting against the ORDER with + * a null `invoice_id` because no invoice exists yet. It counts toward + * `paidCents`, and the reason is the whole point of the deposit feature: a + * no-show on a booking that was never invoiced is EXACTLY the case a deposit is + * for, and excluding it would hand the resolver `paidCents: 0`, cap the no-show + * fee at nothing, and leave the money held forever with nobody told. The + * feature would be inert precisely where it was supposed to bite. + * + * THE TWO POOLS ARE NORMALLY DISJOINT, which is what makes the refund routing + * simple: raising an invoice backfills `invoice_id` onto the deposit rows, so + * the held total drops to zero and the invoice's own total picks it up. They + * overlap only when a webhook lands after the invoice was raised, and the apply + * step below handles that case rather than assuming it away. */ import { and, desc, eq } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; @@ -25,18 +33,26 @@ import { getEffectivePriceCents } from '../../lib/effective-price'; import { classifyCancellationReason } from '../../lib/cancellation-reason'; import { resolveCancellation, type CancellationOutcome } from '../../lib/billing/cancellation-outcome'; import { estimateRetainedProcessingFeeCents } from '../../lib/billing/processing-fee'; -import { getNetReceivedCents } from '../payment-ledger.service'; -import { refundPartial } from '../invoice/refund'; +import { getNetReceivedCents, getHeldDepositCents } from '../payment-ledger.service'; +import { refundPartial, refundHeldDeposit } from '../invoice/refund'; import type { AppendedPayment } from '../payment-ledger.service'; export interface CancellationQuote { outcome: CancellationOutcome; /** The authoritative price, via the money-authority chain. */ priceCents: number; - /** Net received against the invoice — receipts minus refunds. */ + /** Everything collected on this order — the invoice's receipts PLUS anything still held. */ paidCents: number; - /** Null when the order has no invoice; then nothing can be refunded. */ + /** + * The part of `paidCents` that has no invoice behind it. Carried because the + * two pools need different writers to send money back, and the caller must + * not have to re-derive which is which. + */ + heldDepositCents: number; + /** Null when the order has no invoice. Money can still be held against it. */ invoiceId: string | null; + /** The order the quote is about — the refund writers need it, invoice or not. */ + inspectionId: string; currency: string; /** * What the tenant does NOT get back if they refund. Stripe keeps its @@ -102,7 +118,9 @@ export async function quoteCancellation( serviceLines, inspectionPriceCents: inspection.priceCents, }); - const paidCents = invoice ? await getNetReceivedCents(db, tenantId, invoice.id) : 0; + const invoiceReceivedCents = invoice ? await getNetReceivedCents(db, tenantId, invoice.id) : 0; + const heldDepositCents = await getHeldDepositCents(db, tenantId, inspectionId); + const paidCents = invoiceReceivedCents + heldDepositCents; const { initiator, event } = classifyCancellationReason(reason); const policy = config?.cancellationPolicy ?? null; @@ -116,21 +134,27 @@ export async function quoteCancellation( event, }); - const paidThroughStripe = invoice - ? Boolean(await db.select({ id: orderPayments.id }).from(orderPayments) + // Scoped to the ORDER, not the invoice: a booking deposit is the most likely + // card payment on a job cancelled before invoicing, and looking only at + // invoice-attached rows would quote a zero processing loss on exactly the + // cancellation where Stripe has kept its fee. + const paidThroughStripe = paidCents > 0 && Boolean( + await db.select({ id: orderPayments.id }).from(orderPayments) .where(and( eq(orderPayments.tenantId, tenantId), - eq(orderPayments.invoiceId, invoice.id), + eq(orderPayments.inspectionId, inspectionId), eq(orderPayments.provider, 'stripe'), )) - .limit(1).get()) - : false; + .limit(1).get(), + ); return { outcome, priceCents, paidCents, + heldDepositCents, invoiceId: invoice?.id ?? null, + inspectionId, currency: config?.currency ?? 'USD', retainedProcessingFeeCents: outcome.refundCents > 0 && paidThroughStripe ? estimateRetainedProcessingFeeCents(paidCents) : 0, @@ -142,6 +166,24 @@ export async function quoteCancellation( * Append the refund a quote calls for. Returns the ledger row so the caller can * hand its id to an external book of record; null when there is nothing to * refund, which is the common case. + * + * TWO WRITERS, because the money can sit in two places and only one of them has + * an invoice to reverse against. Invoice-attached money goes back through + * `refundPartial`, which recomputes that invoice's cached totals; a held deposit + * goes back through `refundHeldDeposit`, which has no invoice to recompute. + * Neither was bent to cover the other's case — see the header of + * `../invoice/refund` for why that would be one name over two functions. + * + * The invoice is drained FIRST when both hold money. Not arbitrary: the + * invoice's `amount_paid_cents` is a cached figure a human reads off the invoice + * screen, and leaving it overstated while the refund came out of an invisible + * held pool is the "cash in one place and not the other" failure this task + * exists to avoid. + * + * Returns the invoice row when both fire. An external book of record keys its + * credit memo on a row id, and a held-deposit refund is one it cannot post + * anyway — an unapplied deposit was never pushed to QuickBooks in the first + * place, which is what the Books health card says out loud. */ export async function applyCancellationRefund( db: DrizzleD1Database, @@ -149,10 +191,20 @@ export async function applyCancellationRefund( quote: CancellationQuote, recordedBy: string | null, ): Promise { - if (quote.outcome.refundCents <= 0 || !quote.invoiceId) return null; - return refundPartial(db, tenantId, quote.invoiceId, { - amountCents: quote.outcome.refundCents, - reason: `Cancellation refund (${quote.outcome.reason})`, - recordedBy, - }); + const owed = quote.outcome.refundCents; + if (owed <= 0) return null; + + const reason = `Cancellation refund (${quote.outcome.reason})`; + const invoiceReceivedCents = quote.paidCents - quote.heldDepositCents; + const fromInvoice = quote.invoiceId ? Math.min(owed, invoiceReceivedCents) : 0; + const fromHeld = owed - fromInvoice; + + const invoiceRow = fromInvoice > 0 && quote.invoiceId + ? await refundPartial(db, tenantId, quote.invoiceId, { amountCents: fromInvoice, reason, recordedBy }) + : null; + const heldRow = fromHeld > 0 + ? await refundHeldDeposit(db, tenantId, quote.inspectionId, { amountCents: fromHeld, reason, recordedBy }) + : null; + + return invoiceRow ?? heldRow; } diff --git a/server/services/invoice/refund.ts b/server/services/invoice/refund.ts index 1dc4b7b3a..5e9cff148 100644 --- a/server/services/invoice/refund.ts +++ b/server/services/invoice/refund.ts @@ -32,6 +32,7 @@ import { recordPayment, recomputeInvoicePaymentState, getNetReceivedCents, + getHeldDepositCents, seedLedgerFromInvoiceRecord, } from '../payment-ledger.service'; import type { AppendedPayment } from '../payment-ledger.service'; @@ -112,6 +113,70 @@ export async function refundPartial( return appended; } +/** + * Send back money held against an ORDER that has no invoice — a booking + * deposit, cancelled before anyone was billed. + * + * A THIRD writer, and the reason it is not a flag on `refundPartial`: that + * function's body is "load the invoice, seed its ledger from its own record, + * check what IT received, append against it, recompute ITS cache". Four of + * those five steps have no meaning without an invoice. Passing `invoiceId: + * null` through it would put two different functions behind one name, and the + * one that skipped the guards would be the one handling money nobody has + * billed for yet. + * + * WHAT IS ABSENT HERE, deliberately: + * + * - No `recomputeInvoicePaymentState`. There is no invoice cache to refresh; + * the held total is derived from the rows every time it is read. + * - No `seedLedgerFromInvoiceRecord`. A held deposit exists only as ledger + * rows — there is no older column-shaped record it could be reconstructed + * from, so there is nothing to seed. + * + * The gate re-sync IS kept, even though a held deposit never set the gate: + * `payment_status = 'paid'` flips only on a paid invoice, so this cannot + * falsify it. Keeping the call means the house rule at the top of this file + * holds for every writer without exception, which is cheaper to trust than an + * exception someone has to re-derive. + */ +export async function refundHeldDeposit( + db: DrizzleD1Database, + tenantId: string, + inspectionId: string, + input: PartialRefundInput, +): Promise { + if (!Number.isInteger(input.amountCents) || input.amountCents <= 0) { + throw Errors.UnprocessableEntity('A refund must be a positive whole number of cents.'); + } + + const held = await getHeldDepositCents(db, tenantId, inspectionId); + if (input.amountCents > held) { + throw Errors.UnprocessableEntity( + 'This refund is larger than the deposit still held on this booking.', + ); + } + + const appended = await recordPayment(db, tenantId, { + inspectionId, + // NULL, and it must stay null. Attaching this row to an invoice is what + // `applyHeldDepositsToInvoice` does when one is raised; doing it here + // would make a refund look like an invoice payment. + invoiceId: null, + kind: 'refund', + amountCents: input.amountCents, + method: 'card', + provider: null, + providerRef: null, + recordedBy: input.recordedBy ?? null, + note: input.reason, + ...(input.occurredAt ? { occurredAt: input.occurredAt } : {}), + }); + if (!appended) throw Errors.Conflict('This refund was already recorded.'); + + await syncInspectionPaymentGate(db, tenantId, inspectionId); + return appended; +} + /** * Refund an invoice in full: appends a `refund` row reversing everything * received, rather than nulling the columns. A fully refunded invoice therefore diff --git a/tests/unit/billing/cancellation-held-deposit.spec.ts b/tests/unit/billing/cancellation-held-deposit.spec.ts new file mode 100644 index 000000000..0025c342d --- /dev/null +++ b/tests/unit/billing/cancellation-held-deposit.spec.ts @@ -0,0 +1,220 @@ +/** + * Cancelling a booking whose only money is a HELD DEPOSIT. + * + * This is the seam between the cancellation ladder and the deposit, and it is + * the case the deposit feature exists for: a client no-shows on a job that was + * never invoiced. Two things have to be true, and they fail in opposite + * directions: + * + * 1. The deposit COUNTS as collected. `resolveCancellation` caps every fee at + * `paidCents`, so quoting zero would make a 100% no-show fee charge nothing + * and refund nothing — the deposit sits held forever and the feature is + * inert exactly where it was supposed to bite. + * 2. The refund it calls for actually gets WRITTEN. Counting the deposit while + * `applyCancellationRefund` still needs an invoice would be worse than + * today: the quote would promise a refund and the write would silently do + * nothing. `refundHeldDeposit` is the second writer, and these tests are + * what stop the pair from shipping half-done. + * + * The mixed case — a webhook landing after the invoice was raised, so both + * pools hold money — drains the INVOICE first. Its `amount_paid_cents` is a + * number a human reads off a screen, and leaving it overstated while the money + * came back out of an invisible pool is the same "cash in one place and not the + * other" failure in miniature. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { and, eq, isNull } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import { recordPayment, getHeldDepositCents, getNetReceivedCents } from '../../../server/services/payment-ledger.service'; +import { quoteCancellation, applyCancellationRefund } from '../../../server/services/inspection/cancellation.service'; +import { refundHeldDeposit } from '../../../server/services/invoice/refund'; + +const TENANT = '00000000-0000-0000-0000-0000000000c1'; +const INSPECTION = 'insp-0000-0000-0000-0000000000c1'; +const INVOICE = 'inv-0000-0000-0000-0000000000c1'; + +/** The appointment is 2026-08-20 09:00Z. `NOW` is two hours before it. */ +const SCHEDULED = new Date('2026-08-20T09:00:00Z'); +const LATE = new Date('2026-08-20T07:00:00Z'); +const EARLY = new Date('2026-08-14T09:00:00Z'); + +/** 24h notice; late cancellation keeps half, a no-show keeps everything. */ +const POLICY = { + noticeHours: 24, + lateFee: { type: 'percent' as const, percent: 50 }, + noShowFee: { type: 'percent' as const, percent: 100 }, + remedy: 'refund' as const, +}; + +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.tenantConfigs).values({ + tenantId: TENANT, updatedAt: new Date(), cancellationPolicy: POLICY, + }); + await db.insert(schema.inspections).values({ + id: INSPECTION, tenantId: TENANT, propertyAddress: '1 Oak St', + date: '2026-08-20', createdAt: new Date(), + price: 45000, scheduledStartMs: SCHEDULED, + depositRequiredCents: 9000, + }); +}); + +const payDeposit = (amountCents = 9000, providerRef = 'pi_dep') => + recordPayment(db, TENANT, { + inspectionId: INSPECTION, invoiceId: null, kind: 'deposit', + amountCents, method: 'card', provider: 'stripe', providerRef, + }); + +async function seedInvoice(amountCents = 45000, receivedCents = 0) { + await db.insert(schema.invoices).values({ + id: INVOICE, tenantId: TENANT, inspectionId: INSPECTION, amountCents, + lineItems: [{ description: 'Inspection', amountCents }], + createdAt: new Date(), currency: 'USD', + }); + if (receivedCents > 0) { + await recordPayment(db, TENANT, { + inspectionId: INSPECTION, invoiceId: INVOICE, kind: 'balance', + amountCents: receivedCents, method: 'card', provider: 'stripe', providerRef: 'pi_bal', + }); + } +} + +const heldRefundRows = () => + db.select().from(schema.orderPayments) + .where(and( + eq(schema.orderPayments.tenantId, TENANT), + eq(schema.orderPayments.kind, 'refund'), + isNull(schema.orderPayments.invoiceId), + )) + .all(); + +describe('a held deposit is money collected, and the ladder must see it', () => { + it('counts toward paidCents when the order has no invoice at all', async () => { + await payDeposit(); + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'client_cancelled', LATE); + expect(quote.invoiceId).toBeNull(); + expect(quote.paidCents).toBe(9000); + expect(quote.heldDepositCents).toBe(9000); + }); + + it('lets a no-show actually cost the client the deposit', async () => { + // The reason the feature exists. With paidCents at 0 this would charge + // nothing, refund nothing, and leave the money held with nobody told. + await payDeposit(); + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'no_show', LATE); + expect(quote.outcome.feeCents).toBe(9000); + expect(quote.outcome.refundCents).toBe(0); + // The ladder wanted 100% of $450 and could only keep the $90 collected. + // Surfacing that is the point: the tenant is owed less than their policy + // says and should learn it here, not in a reconciliation. + expect(quote.outcome.cappedAtCollected).toBe(true); + }); + + it('gives the deposit back in full when the client cancels in time', async () => { + await payDeposit(); + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'client_cancelled', EARLY); + expect(quote.outcome.reason).toBe('sufficient_notice'); + expect(quote.outcome.refundCents).toBe(9000); + }); + + it('quotes the retained Stripe fee even though no invoice was ever raised', async () => { + // Scoped to the ORDER: the deposit is the most likely card payment on a + // job cancelled before invoicing, and an invoice-scoped lookup would + // quote a zero processing loss on exactly that cancellation. + await payDeposit(); + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'client_cancelled', EARLY); + expect(quote.retainedProcessingFeeCents).toBeGreaterThan(0); + }); +}); + +describe('the refund is actually written, with no invoice to write it against', () => { + it('appends an invoice-less refund row and empties the held pool', async () => { + await payDeposit(); + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'client_cancelled', EARLY); + const row = await applyCancellationRefund(db, TENANT, quote, 'user-1'); + + expect(row).not.toBeNull(); + expect(row!.kind).toBe('refund'); + expect(row!.amountCents).toBe(9000); + + const refunds = await heldRefundRows(); + expect(refunds).toHaveLength(1); + expect(refunds[0].inspectionId).toBe(INSPECTION); + expect(refunds[0].invoiceId).toBeNull(); + + // Receipts minus refunds: nothing is held any more. + expect(await getHeldDepositCents(db, TENANT, INSPECTION)).toBe(0); + }); + + it('keeps the retained fee and refunds only the rest on a late cancellation', async () => { + await payDeposit(); + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'client_cancelled', LATE); + expect(quote.outcome.feeCents).toBe(9000); // 50% of $450, capped at the $90 collected + expect(quote.outcome.refundCents).toBe(0); + expect(await applyCancellationRefund(db, TENANT, quote, null)).toBeNull(); + expect(await getHeldDepositCents(db, TENANT, INSPECTION)).toBe(9000); + }); + + it('refuses to send back more than is held', async () => { + await payDeposit(9000); + await expect( + refundHeldDeposit(db, TENANT, INSPECTION, { amountCents: 15000, reason: 'oops' }), + ).rejects.toThrow(/larger than the deposit still held/); + }); + + it('refuses a zero or negative refund', async () => { + await payDeposit(); + await expect( + refundHeldDeposit(db, TENANT, INSPECTION, { amountCents: 0, reason: 'oops' }), + ).rejects.toThrow(/positive whole number/); + }); +}); + +describe('when both pools hold money, the invoice is drained first', () => { + it('splits the refund across the two writers', async () => { + // A deposit webhook that landed after the invoice was raised: $90 held, + // $200 received on the invoice, and an inspector-initiated cancellation + // that refunds everything. + await seedInvoice(45000, 20000); + await payDeposit(); + + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'inspector_cancelled', LATE); + expect(quote.paidCents).toBe(29000); + expect(quote.heldDepositCents).toBe(9000); + expect(quote.outcome.refundCents).toBe(29000); + + await applyCancellationRefund(db, TENANT, quote, null); + + // The invoice's own cache is square — that is the number a human reads. + expect(await getNetReceivedCents(db, TENANT, INVOICE)).toBe(0); + // And the invisible pool is square too. + expect(await getHeldDepositCents(db, TENANT, INSPECTION)).toBe(0); + expect(await heldRefundRows()).toHaveLength(1); + }); + + it('takes nothing from the held pool when the invoice covers the refund', async () => { + await seedInvoice(45000, 20000); + await payDeposit(); + + const quote = await quoteCancellation(db, TENANT, INSPECTION, 'client_cancelled', LATE); + // 50% of $450 = $225 fee, capped at the $290 collected → $65 back, all + // of which the invoice can cover on its own. + expect(quote.outcome.refundCents).toBe(6500); + await applyCancellationRefund(db, TENANT, quote, null); + + expect(await getNetReceivedCents(db, TENANT, INVOICE)).toBe(13500); + expect(await getHeldDepositCents(db, TENANT, INSPECTION)).toBe(9000); + expect(await heldRefundRows()).toHaveLength(0); + }); +}); From 11ed3797fefff2132d21c30d331d19a1b0e729d4 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 21:44:07 +0800 Subject: [PATCH 59/77] feat(deposit): say the number before they commit, and ask for it after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate jobs, and putting them in the wrong order is how a deposit becomes a chargeback. BEFORE: the services step and the confirm summary QUOTE the amount, from the same pure resolver the server runs, and both say it comes off the total rather than sitting on top of it. A client who discovers a charge after clicking Book writes a review about it. AFTER: the payment panel renders only once the booking exists and only when the SERVER says money is owed. The client-side quote can never conjure one — the panel charges `depositRequiredCents` off the booking response, which is what was frozen on the order. A test pins that by having the server answer $50 where the form guessed $90. The panel's most important line is the small grey one: "Your appointment is already booked. Paying the deposit secures the slot." A decline shows "Your appointment is still booked", and a 503 from an unconfigured workspace says the same thing in the same place. Nowhere does this surface imply payment is what confirms the booking, because it is not. Money renders through `formatCurrency`, never `toLocale*`. Walked in Chrome against a real local workspace on 20%, both themes, which is where two things showed up. A stale paraglide module in the dev server crashed `ServicesStep` outright ("m.booking_deposit_quote_note is not a function") — a hard reload, not a code defect, but the crash is what a stale build looks like and it took the whole page. And the opt-out arithmetic held in the browser exactly as the unit test claims: adding Radon (`{ type: 'none' }`) moved the total $250 → $400 and left the deposit at $50.00. Submitting wrote 5000 on the primary inspection, 0 on the sibling, and nothing at all in `order_payments` — owed, not collected, which is the whole invariant. The embed widget gets NO deposit and now says why at the call site: it collects no service, so there is no price for a percentage to resolve against and the order it creates carries `price: 0`. Giving it a deposit means giving it service selection first. --- .../booking/BookingDepositPanel.tsx | 167 ++++++++++++++++++ app/components/booking/BookingSteps.tsx | 54 ++++++ app/components/booking/BookingWizard.tsx | 11 ++ app/components/booking/booking-constants.ts | 12 +- app/components/booking/useBookingFormState.ts | 30 ++++ app/routes/public/booking-deposit.test.tsx | 133 ++++++++++++++ app/routes/public/booking-embed-widget.tsx | 8 + messages/en/booking.json | 15 +- messages/es-419/booking.json | 15 +- 9 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 app/components/booking/BookingDepositPanel.tsx create mode 100644 app/routes/public/booking-deposit.test.tsx diff --git a/app/components/booking/BookingDepositPanel.tsx b/app/components/booking/BookingDepositPanel.tsx new file mode 100644 index 000000000..5ff859a05 --- /dev/null +++ b/app/components/booking/BookingDepositPanel.tsx @@ -0,0 +1,167 @@ +/** + * The deposit step, shown AFTER the booking exists. + * + * The ordering is the design, not an implementation detail. The appointment is + * already saved by the time this renders, so a declined card leaves a real + * booking with an unpaid deposit that the tenant can see and chase — never a + * silent drop. That is why there is no "pay to confirm" wording anywhere here. + * + * Nothing this component observes is trusted as payment either. Stripe redirects + * back to the booking page on success, and the ledger row is written by the + * webhook; the confirmation this shows is about the CARD, and it says so. + * + * Modelled on `portal/sections/StripePayPanel` — same lazy `loadStripe` after a + * click, same Elements-in-a-card shape — but not shared with it: that one is + * keyed on an invoice and gated on a portal grant, and this one exists + * precisely for the case where neither is true. + * lint:ds — only `ih-*` tokens. + */ +import { useState } from "react"; +import { loadStripe, type Stripe as StripeJs } from "@stripe/stripe-js"; +import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; +import { formatCurrency } from "~/lib/format"; +import { useDisplayLocale } from "~/hooks/useSessionContext"; +import { buildStripeElementsOptions } from "~/lib/stripe-elements-options"; +import { m } from "~/paraglide/messages"; + +type Phase = "idle" | "loading" | "ready" | "settled" | "unavailable"; + +export function BookingDepositPanel({ + inspectionId, + depositCents, + currency, + companyName, +}: { + inspectionId: string; + depositCents: number; + currency: string; + companyName: string; +}) { + // No session on a public booking page, so this resolves to the default — + // which is correct here: the visitor is anonymous and we have no preference + // of theirs to honour. + const locale = useDisplayLocale(); + const [phase, setPhase] = useState("idle"); + const [clientSecret, setClientSecret] = useState(null); + const [stripePromise, setStripePromise] = useState | null>(null); + const [returnUrl, setReturnUrl] = useState(""); + + const amount = formatCurrency(depositCents, { locale, currency }); + + async function startPayment() { + setReturnUrl(typeof window !== "undefined" ? window.location.href : ""); + setPhase("loading"); + try { + const res = await fetch(`/api/public/inspections/${inspectionId}/deposit-intent`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const body = (await res.json().catch(() => ({}))) as { + data?: { clientSecret?: string; publishableKey?: string }; + }; + if (res.ok && body.data?.clientSecret && body.data?.publishableKey) { + setStripePromise(loadStripe(body.data.publishableKey)); + setClientSecret(body.data.clientSecret); + setPhase("ready"); + return; + } + // 404 here means the deposit is already settled — most often because the + // webhook landed while this page was open. Nothing is owed, and saying + // "unavailable" would be a lie the client would phone about. + setPhase(res.status === 404 ? "settled" : "unavailable"); + } catch { + setPhase("unavailable"); + } + } + + return ( +
+
+ {m.booking_deposit_pay_heading()} + {amount} +
+

+ {m.booking_deposit_pay_body({ company: companyName })} +

+ + {(phase === "idle" || phase === "loading") && ( + <> + + {/* The appointment is already made. Say so beside the button, or the + client reads the deposit as the thing that confirms it. */} +

{m.booking_deposit_already_booked()}

+ + )} + + {phase === "ready" && clientSecret && stripePromise && ( + + + + )} + + {phase === "settled" && ( +

{m.booking_deposit_already_paid()}

+ )} + + {phase === "unavailable" && ( +

+ {m.booking_deposit_unavailable({ company: companyName })} +

+ )} +
+ ); +} + +function DepositForm({ amount, returnUrl }: { amount: string; returnUrl: string }) { + const stripe = useStripe(); + const elements = useElements(); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!stripe || !elements) return; + setSubmitting(true); + setError(null); + const { error: payErr } = await stripe.confirmPayment({ + elements, + confirmParams: { return_url: returnUrl || (typeof window !== "undefined" ? window.location.href : "") }, + }); + // On success Stripe redirects; we only reach here on error. A decline is + // NOT a failed booking — the copy has to keep those apart. + if (payErr) { + setError(payErr.message ?? m.booking_deposit_error_generic()); + setSubmitting(false); + } + } + + return ( + + + + {error && ( +
+

{error}

+

{m.booking_deposit_decline_keeps_booking()}

+
+ )} + + ); +} diff --git a/app/components/booking/BookingSteps.tsx b/app/components/booking/BookingSteps.tsx index 16f0cde3e..f278b5579 100644 --- a/app/components/booking/BookingSteps.tsx +++ b/app/components/booking/BookingSteps.tsx @@ -1,4 +1,7 @@ import { timeWindows, type CompanyProfile } from "./booking-constants"; +import { BookingDepositPanel } from "./BookingDepositPanel"; +import { formatCurrency } from "~/lib/format"; +import { useDisplayLocale } from "~/hooks/useSessionContext"; import { m } from "~/paraglide/messages"; export function PropertyStep({ @@ -35,12 +38,18 @@ export function ServicesStep({ selectedServices, toggleService, totalPrice, + depositQuoteCents, + currency, }: { profile: CompanyProfile; selectedServices: Set; toggleService: (id: string) => void; totalPrice: number; + /** Quoted, not charged. 0 renders nothing at all. */ + depositQuoteCents: number; + currency: string; }) { + const locale = useDisplayLocale(); return (
@@ -92,6 +101,13 @@ export function ServicesStep({
)} + {selectedServices.size > 0 && depositQuoteCents > 0 && ( +

+ {m.booking_deposit_quote_note({ + amount: formatCurrency(depositQuoteCents, { locale, currency }), + })} +

+ )}
); } @@ -109,6 +125,11 @@ export function ConfirmStep({ totalPrice, clientName, clientEmail, + depositQuoteCents, + depositDueCents, + bookedInspectionId, + currency, + companyName, }: { message: { text: string; ok: boolean } | null; address: string; @@ -121,7 +142,15 @@ export function ConfirmStep({ totalPrice: number; clientName: string; clientEmail: string; + /** What the form expects to be asked for, before submitting. */ + depositQuoteCents: number; + /** What the SERVER froze, once the booking exists. Null before then. */ + depositDueCents: number | null; + bookedInspectionId: string | null; + currency: string; + companyName: string; }) { + const locale = useDisplayLocale(); return (
{message?.ok ? ( @@ -133,6 +162,16 @@ export function ConfirmStep({

{m.booking_confirm_submitted_heading()}

{message.text}

+ {/* Only once the server has said what it froze, and only if it froze + anything. A workspace with no deposit sees no payment step. */} + {bookedInspectionId && depositDueCents != null && depositDueCents > 0 && ( + + )}
) : ( <> @@ -169,6 +208,14 @@ export function ConfirmStep({ {m.booking_confirm_row_total()} ${totalPrice.toFixed(2)}
+ {depositQuoteCents > 0 && ( +
+ {m.booking_confirm_row_deposit()} + + {formatCurrency(depositQuoteCents, { locale, currency })} + +
+ )}
{m.booking_confirm_row_name()} {clientName} @@ -178,6 +225,13 @@ export function ConfirmStep({ {clientEmail}
+ {depositQuoteCents > 0 && ( +

+ {m.booking_deposit_confirm_note({ + amount: formatCurrency(depositQuoteCents, { locale, currency }), + })} +

+ )} )} diff --git a/app/components/booking/BookingWizard.tsx b/app/components/booking/BookingWizard.tsx index 2bdb66f31..1704b7a49 100644 --- a/app/components/booking/BookingWizard.tsx +++ b/app/components/booking/BookingWizard.tsx @@ -38,6 +38,10 @@ export function BookingWizard({ turnstileRef, toggleService, totalPrice, + depositQuoteCents, + depositDueCents, + bookedInspectionId, + currency, needsTurnstile, canNext, inspectorOptions, @@ -107,6 +111,8 @@ export function BookingWizard({ selectedServices={selectedServices} toggleService={toggleService} totalPrice={totalPrice} + depositQuoteCents={depositQuoteCents} + currency={currency} /> )} @@ -157,6 +163,11 @@ export function BookingWizard({ totalPrice={totalPrice} clientName={clientName} clientEmail={clientEmail} + depositQuoteCents={depositQuoteCents} + depositDueCents={depositDueCents} + bookedInspectionId={bookedInspectionId} + currency={currency} + companyName={profile.company} /> )} diff --git a/app/components/booking/booking-constants.ts b/app/components/booking/booking-constants.ts index a6a24b317..3dc1cc678 100644 --- a/app/components/booking/booking-constants.ts +++ b/app/components/booking/booking-constants.ts @@ -1,4 +1,5 @@ import { m } from "~/paraglide/messages"; +import type { DepositPolicy } from "../../../server/lib/billing/deposit-policy"; // Functions (not module consts) so the labels resolve in the active locale at // call time, never frozen at import. The `id`s are the API timeSlot enum and @@ -29,5 +30,14 @@ export interface CompanyProfile { allowInspectorChoice?: boolean; conciergeReviewRequired?: boolean; inspectors: { id: string; name: string | null; photoUrl: string | null }[]; - services: { id: string; name: string; price: number; duration: number }[]; + services: { id: string; name: string; price: number; duration: number; depositPolicy?: DepositPolicy | null }[]; + /** ISO 4217 the company bills in. 'USD' when the workspace has not said. */ + currency?: string; + /** + * The workspace deposit default, shipped so the form can QUOTE the amount + * before the client commits. It is a quote and never the authority — the + * server resolves it again from the same catalogue rows at booking time and + * freezes its own answer on the order. + */ + depositPolicy?: DepositPolicy | null; } diff --git a/app/components/booking/useBookingFormState.ts b/app/components/booking/useBookingFormState.ts index cb3be2e59..7c6846b94 100644 --- a/app/components/booking/useBookingFormState.ts +++ b/app/components/booking/useBookingFormState.ts @@ -1,6 +1,7 @@ import { useState, useMemo, useRef, useEffect } from "react"; import { useFetcher } from "react-router"; import type { CompanyProfile } from "./booking-constants"; +import { resolveOrderDeposit } from "../../../server/lib/billing/deposit-policy"; import { m } from "~/paraglide/messages"; /** Where a returning visitor's own contact details are remembered (this device only). */ @@ -36,6 +37,8 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null); const [turnstileToken, setTurnstileToken] = useState(null); + /** Set once the booking exists — the capability the deposit-intent route keys on. */ + const [bookedInspectionId, setBookedInspectionId] = useState(null); const turnstileRef = useRef(null); // Repeat visitors re-typed their own name and email on every booking. Their @@ -94,6 +97,24 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug .reduce((sum, s) => sum + s.price / 100, 0); }, [selectedServices, profile]); + // A QUOTE of what will be asked for up front, from the same arithmetic the + // server runs. Shown before the client commits, because a charge discovered + // after clicking Book is a chargeback and a review. The authoritative figure + // comes back in the booking response and is what the payment step charges. + const depositQuoteCents = useMemo(() => { + if (!profile) return 0; + return resolveOrderDeposit({ + tenant: profile.depositPolicy ?? null, + lines: profile.services + .filter((s) => selectedServices.has(s.id)) + .map((s) => ({ priceCents: s.price, policy: s.depositPolicy ?? null })), + }); + }, [selectedServices, profile]); + + // What the SERVER froze, once the booking exists. Null until then; 0 means it + // asked for nothing, and the payment step is not rendered at all. + const [depositDueCents, setDepositDueCents] = useState(null); + // An authenticated agent is not an anonymous visitor, so the bot challenge // does not apply to them; every anonymous submit still faces it. const needsTurnstile = !!profile?.turnstileSiteKey && !agentBooking; @@ -176,6 +197,11 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug }); if (res.ok) { saveRememberedContact(); + const created = (await res.json().catch(() => ({}))) as { + data?: { inspectionId?: string; depositRequiredCents?: number }; + }; + setBookedInspectionId(created.data?.inspectionId ?? null); + setDepositDueCents(created.data?.depositRequiredCents ?? 0); setMessage({ text: m.helper_booking_submit_success(), ok: true }); setStep(3); } else { @@ -207,6 +233,10 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug turnstileRef, toggleService, totalPrice, + depositQuoteCents, + depositDueCents, + bookedInspectionId, + currency: profile?.currency ?? "USD", needsTurnstile, canNext, inspectorOptions, diff --git a/app/routes/public/booking-deposit.test.tsx b/app/routes/public/booking-deposit.test.tsx new file mode 100644 index 000000000..905cb0bf2 --- /dev/null +++ b/app/routes/public/booking-deposit.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom +/** + * What the client is told about the deposit, and when. + * + * Two rules, and both of them are about not surprising anyone: + * + * 1. The amount is stated BEFORE the client commits. A charge discovered + * after clicking Book is a chargeback and a review, so the services step + * and the confirm summary both quote it, and both say it comes off the + * total rather than on top. + * 2. The payment step only exists AFTER the booking does, and only when the + * SERVER says money is owed. A workspace with no deposit configured sees + * no payment step at all — the client-side quote can never conjure one, + * because the figure the panel charges is the one the server froze. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent, screen, waitFor } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; + +import BookingPage from "~/routes/public/booking"; + +const BASE_PROFILE = { + company: "Acme Inspections", + services: [ + { id: "svc-1", name: "Full Inspection", price: 45000, duration: 180, depositPolicy: null }, + { id: "svc-2", name: "Radon", price: 9500, duration: 60, depositPolicy: null }, + ], + inspectors: [], + allowInspectorChoice: false, + bookingOpen: true, + turnstileSiteKey: null, + conciergeReviewRequired: false, + currency: "USD", + depositPolicy: null as { type: "none" | "percent" | "fixed"; percent?: number; amountCents?: number } | null, +}; + +function renderBooking(profile: Partial = {}) { + const Stub = createRoutesStub([ + { + path: "/book/:tenant", + Component: BookingPage, + loader: () => ({ + profile: { ...BASE_PROFILE, ...profile }, + preselected: null, error: null, tenant: "acme", agentRefSlug: null, + brand: {}, privacyUrl: null, termsUrl: null, agentBooking: null, + }), + action: async () => ({ ok: true }), + }, + ]); + return render(); +} + +/** Walk the wizard to the step named, filling only what each gate requires. */ +async function walkTo(step: "services" | "confirm") { + fireEvent.change(await screen.findByPlaceholderText(/123 Main St/i), { target: { value: "123 Main St" } }); + fireEvent.click(await screen.findByText("Continue")); + fireEvent.click(await screen.findByText("Full Inspection")); + if (step === "services") return; + fireEvent.click(await screen.findByText("Continue")); + fireEvent.change(screen.getByPlaceholderText("Jane Doe"), { target: { value: "Jane Doe" } }); + fireEvent.change(screen.getByPlaceholderText("jane@example.com"), { target: { value: "jane@example.com" } }); + const date = document.querySelector("input[type='date']") as HTMLInputElement; + fireEvent.change(date, { target: { value: "2026-09-10" } }); + fireEvent.click(await screen.findByText("Continue")); +} + +describe("the deposit is quoted before the client commits", () => { + it("states the amount on the services step, and that it comes off the total", async () => { + renderBooking({ depositPolicy: { type: "percent", percent: 20 } }); + await walkTo("services"); + expect(await screen.findByText(/\$90\.00 is collected when you book/)).toBeTruthy(); + expect(screen.getByText(/comes off the total, not on top of it/)).toBeTruthy(); + }); + + it("repeats it as a line on the confirm summary", async () => { + renderBooking({ depositPolicy: { type: "percent", percent: 20 } }); + await walkTo("confirm"); + expect(await screen.findByText("Deposit due today")).toBeTruthy(); + expect(screen.getByText(/asked for a \$90\.00 deposit to hold this slot/)).toBeTruthy(); + }); + + it("says nothing at all when the workspace asks for no deposit", async () => { + renderBooking({ depositPolicy: null }); + await walkTo("confirm"); + expect(screen.queryByText("Deposit due today")).toBeNull(); + expect(screen.queryByText(/deposit/i)).toBeNull(); + }); + + it("honours a service that opted out, so the quote matches what will be charged", async () => { + renderBooking({ + depositPolicy: { type: "percent", percent: 20 }, + services: [{ id: "svc-1", name: "Full Inspection", price: 45000, duration: 180, depositPolicy: { type: "none" } }], + }); + await walkTo("services"); + // Nothing is owed, so nothing is said — the alternative is quoting a figure + // the server will not charge. + expect(screen.queryByText(/is collected when you book/)).toBeNull(); + }); +}); + +describe("the payment step appears only after the booking exists", () => { + const submitBooking = async (response: unknown) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(response), { status: 200, headers: { "content-type": "application/json" } }), + ); + renderBooking({ depositPolicy: { type: "percent", percent: 20 } }); + await walkTo("confirm"); + fireEvent.click(await screen.findByText("Request Inspection")); + }; + + it("shows it when the server froze an amount, and says the appointment is already booked", async () => { + await submitBooking({ success: true, data: { success: true, inspectionId: "insp-a", depositRequiredCents: 9000 } }); + expect(await screen.findByText("Deposit to hold your slot")).toBeTruthy(); + expect(await screen.findByText("Pay $90.00 deposit")).toBeTruthy(); + // The single most important sentence on this panel. + expect(screen.getByText(/Your appointment is already booked/)).toBeTruthy(); + }); + + it("shows no payment step when the server froze nothing", async () => { + await submitBooking({ success: true, data: { success: true, inspectionId: "insp-a", depositRequiredCents: 0 } }); + await waitFor(() => expect(screen.getByText("Request Submitted")).toBeTruthy()); + expect(screen.queryByText("Deposit to hold your slot")).toBeNull(); + }); + + it("trusts the SERVER amount, not the client-side quote", async () => { + // An operator overrode the deposit to $50 between the page load and the + // submit. The panel must charge what the order says, not what the form + // guessed from the catalogue. + await submitBooking({ success: true, data: { success: true, inspectionId: "insp-a", depositRequiredCents: 5000 } }); + expect(await screen.findByText("Pay $50.00 deposit")).toBeTruthy(); + expect(screen.queryByText("Pay $90.00 deposit")).toBeNull(); + }); +}); diff --git a/app/routes/public/booking-embed-widget.tsx b/app/routes/public/booking-embed-widget.tsx index 1062c97a6..ae977745d 100644 --- a/app/routes/public/booking-embed-widget.tsx +++ b/app/routes/public/booking-embed-widget.tsx @@ -144,6 +144,14 @@ 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", + // NO `services`, and therefore NO DEPOSIT from this surface, even for + // a workspace that requires one. Not an oversight and not a quick fix: + // a deposit resolves against the price of what was selected, and this + // form selects nothing — the order it creates carries `price: 0`. A + // percentage of zero is zero, and a flat amount against an order with + // no priced work is a charge with nothing behind it. Giving the embed + // a deposit means giving it service selection first. Tracked as its + // own issue; see the booking-deposit plan, Risk 3. ...(locale ? { locale } : {}), turnstileToken: fd.get("cf-turnstile-response") || undefined, }), diff --git a/messages/en/booking.json b/messages/en/booking.json index c122dc079..3b9a34582 100644 --- a/messages/en/booking.json +++ b/messages/en/booking.json @@ -73,5 +73,18 @@ "booking_confirm_row_total": "Total", "booking_confirm_row_name": "Name", "booking_holiday_advisory_concierge": "Office may be closed — {name}. Request received — office will confirm.", - "booking_holiday_advisory_default": "Office may be closed — {name}. We'll confirm availability." + "booking_holiday_advisory_default": "Office may be closed — {name}. We'll confirm availability.", + "booking_confirm_row_deposit": "Deposit due today", + "booking_deposit_quote_note": "A deposit of {amount} is collected when you book. It comes off the total, not on top of it.", + "booking_deposit_confirm_note": "After you book, you will be asked for a {amount} deposit to hold this slot. It is deducted from your total.", + "booking_deposit_pay_heading": "Deposit to hold your slot", + "booking_deposit_pay_body": "{company} takes a deposit to hold the appointment. It comes off your final total.", + "booking_deposit_pay_button": "Pay {amount} deposit", + "booking_deposit_starting": "Starting secure payment…", + "booking_deposit_processing": "Processing…", + "booking_deposit_already_booked": "Your appointment is already booked. Paying the deposit secures the slot.", + "booking_deposit_already_paid": "Your deposit has already been received. Nothing further is needed.", + "booking_deposit_unavailable": "Card payment could not be started. Your appointment is booked — {company} will be in touch about the deposit.", + "booking_deposit_error_generic": "That card could not be charged.", + "booking_deposit_decline_keeps_booking": "Your appointment is still booked. You can try another card, or wait to be contacted." } diff --git a/messages/es-419/booking.json b/messages/es-419/booking.json index 93c0a3eef..cda34ff1b 100644 --- a/messages/es-419/booking.json +++ b/messages/es-419/booking.json @@ -73,5 +73,18 @@ "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." + "booking_holiday_advisory_default": "Es posible que la oficina esté cerrada — {name}. Confirmaremos la disponibilidad.", + "booking_confirm_row_deposit": "Depósito a pagar hoy", + "booking_deposit_quote_note": "Se cobra un depósito de {amount} al reservar. Se descuenta del total, no se suma.", + "booking_deposit_confirm_note": "Después de reservar, se le pedirá un depósito de {amount} para asegurar este horario. Se descuenta de su total.", + "booking_deposit_pay_heading": "Depósito para asegurar su horario", + "booking_deposit_pay_body": "{company} cobra un depósito para reservar la cita. Se descuenta de su total final.", + "booking_deposit_pay_button": "Pagar depósito de {amount}", + "booking_deposit_starting": "Iniciando el pago seguro…", + "booking_deposit_processing": "Procesando…", + "booking_deposit_already_booked": "Su cita ya está reservada. Pagar el depósito asegura el horario.", + "booking_deposit_already_paid": "Ya recibimos su depósito. No se requiere nada más.", + "booking_deposit_unavailable": "No se pudo iniciar el pago con tarjeta. Su cita está reservada; {company} se comunicará con usted sobre el depósito.", + "booking_deposit_error_generic": "No se pudo cobrar esa tarjeta.", + "booking_deposit_decline_keeps_booking": "Su cita sigue reservada. Puede intentar con otra tarjeta o esperar a que lo contacten." } From 2603b06f9d4442c7f3fc19c95c8763c80225705e Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 22:01:37 +0800 Subject: [PATCH 60/77] chore(gate): three gates caught the deposit work, and all three were right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The append-at-end ratchet on `inspections` fired on `deposit_required_cents` + `is_deposit_overridden`. Exactly its job: the columns ARE at the tail, so the list grows by two and the comment says which change put them there. A mid-list insert would have made db:generate emit a table rebuild instead of ALTER ADD COLUMN, and on remote D1 that loses tables without db:check saying anything. Tenant-scope flagged two post-insert read-backs — the new deposit-aware `createInvoice` re-read, and the discount-code read-back that moved file in the extraction. Both are provably safe (a primary key this function just generated inside a tenant-scoped insert), which is precisely the case the gate lets you baseline. Scoped them instead: the filter is free, and a baseline entry is a judgement someone has to re-derive later. That also retired a stale entry, so the ratchet tightened by one rather than staying put. Knip flagged two exported types nothing imports. `PublicDepositIntentApi` was cargo-culted from the sibling router that IS consumed by name — this one mounts inside the bookings aggregator, so `BookingsApi` already carries its RPC shape. `PaymentPurpose` is narrowed structurally off `settled.purpose.kind` and never named. Deleted both rather than baselining; a name nothing imports is surface a reader has to account for. --- scripts/tenant-scoping-baseline.json | 1 - server/api/public/deposit-intent.ts | 3 ++- server/lib/stripe-helpers.ts | 4 +++- server/services/invoice.service.ts | 4 +++- server/services/service/discount-codes.ts | 3 ++- tests/unit/reports/pca-foundation-schema.spec.ts | 7 +++++-- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/scripts/tenant-scoping-baseline.json b/scripts/tenant-scoping-baseline.json index 5510956eb..dd973b7b8 100644 --- a/scripts/tenant-scoping-baseline.json +++ b/scripts/tenant-scoping-baseline.json @@ -64,7 +64,6 @@ "server/services/qbo/invoice-sync.ts::voidInvoice::}).where(eq(qboEntityMap.id, mapped.id));", "server/services/recommendation.service.ts::create::const c = await db.select().from(comments).where(eq(comments.id, row.id)).get();", "server/services/report-pdf.service.ts::markQueued::.where(eq(reportPdfs.id, existing.id));", - "server/services/service.service.ts::createDiscountCode::const rows = await db.select().from(discountCodes).where(eq(discountCodes.id, id));", "server/services/service.service.ts::createService::const rows = await db.select().from(services).where(eq(services.id, id));", "server/services/service.service.ts::updateService::const rows = await db.select().from(services).where(eq(services.id, id));", "server/services/template-migration.service.ts::preview::.where(eq(inspectionResults.id, row.id as string))", diff --git a/server/api/public/deposit-intent.ts b/server/api/public/deposit-intent.ts index ac12c8c29..f6361a164 100644 --- a/server/api/public/deposit-intent.ts +++ b/server/api/public/deposit-intent.ts @@ -117,5 +117,6 @@ const depositIntentRoutes = createApiRouter() } }); -export type PublicDepositIntentApi = typeof depositIntentRoutes; +// No exported router type: this mounts inside the bookings aggregator, so +// `BookingsApi` already carries its RPC shape and a second name is dead surface. export default depositIntentRoutes; diff --git a/server/lib/stripe-helpers.ts b/server/lib/stripe-helpers.ts index d765a187a..8b325d533 100644 --- a/server/lib/stripe-helpers.ts +++ b/server/lib/stripe-helpers.ts @@ -42,7 +42,9 @@ export interface PaymentIntentParams { * Intents minted before this field existed carry no `kind`; they are read as * `invoice`, which is what they all were. */ -export type PaymentPurpose = +// Not exported: consumers narrow on `settled.purpose.kind` structurally, and an +// exported name nothing imports is dead surface the knip gate is right to flag. +type PaymentPurpose = | { kind: 'invoice'; invoiceId: string } | { kind: 'deposit'; inspectionId: string }; diff --git a/server/services/invoice.service.ts b/server/services/invoice.service.ts index 9ce6b9188..7688d33e9 100644 --- a/server/services/invoice.service.ts +++ b/server/services/invoice.service.ts @@ -142,7 +142,9 @@ export class InvoiceService { amountPaidCents: invoices.amountPaidCents, partialPaidAt: invoices.partialPaidAt, }) - .from(invoices).where(eq(invoices.id, row.id)).get(); + .from(invoices) + .where(and(eq(invoices.id, row.id), eq(invoices.tenantId, tenantId))) + .get(); amountPaidCents = fresh?.amountPaidCents ?? 0; partialPaidAt = fresh?.partialPaidAt ?? null; } diff --git a/server/services/service/discount-codes.ts b/server/services/service/discount-codes.ts index c803cd590..0b03f333a 100644 --- a/server/services/service/discount-codes.ts +++ b/server/services/service/discount-codes.ts @@ -47,7 +47,8 @@ export async function createDiscountCode(db: DrizzleD1Database, tenantId: string active: true, createdAt: new Date(), }); - const rows = await db.select().from(discountCodes).where(eq(discountCodes.id, id)); + const rows = await db.select().from(discountCodes) + .where(and(eq(discountCodes.id, id), eq(discountCodes.tenantId, tenantId))); return rows[0]; } diff --git a/tests/unit/reports/pca-foundation-schema.spec.ts b/tests/unit/reports/pca-foundation-schema.spec.ts index ac4fa1aaa..1c34d9704 100644 --- a/tests/unit/reports/pca-foundation-schema.spec.ts +++ b/tests/unit/reports/pca-foundation-schema.spec.ts @@ -22,11 +22,12 @@ describe('Commercial PCA Phase F foundation columns', () => { // trio; Report Style Presets (Plan 1a) then appended the badge_layout_override // + report_photo_columns tweak pair; the two-layer role model then appended // referred_by_contact_id; the report-gate unlock then appended its trio; - // per-deliverable reports then appended the generation latch. + // per-deliverable reports then appended the generation latch; the booking + // deposit then appended the snapshot + its operator-override marker. // All appended at the tail (never mid-list) so db:generate emits // ALTER ADD COLUMN, not a rebuild. const names = getTableConfig(inspections).columns.map((c) => c.name); - const tail = names.slice(-16); + const tail = names.slice(-18); expect(tail).toEqual([ 'unit_inspection_mode', 'location_options', @@ -44,6 +45,8 @@ describe('Commercial PCA Phase F foundation columns', () => { 'unlocked_by', 'unlock_reason', 'reports_generated_at', + 'deposit_required_cents', + 'is_deposit_overridden', ]); }); From 15d10d0e3b532ce9dc07ec979b7957bd4e9ab7f1 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 23:08:51 +0800 Subject: [PATCH 61/77] feat(deposit): the company default was settable only by hand The booking deposit shipped with an API that accepts a policy at all three tiers and a booking flow that quotes and charges it, and no control anywhere -- so turning it on meant writing the PATCH yourself. This is tier 1: the company-wide default, in the panel that already holds the other booking policies. A deposit is not a checkbox, so it is not a fourth row of them; it is a segmented control under the same Save, because "clients must sign" and "clients must pay something up front" are the same kind of decision and splitting them across two panels is how an admin ends up looking for a page that does not exist. Three things here are load-bearing. OFF IS A STATE. `deposit_policy` is NULL for every existing company, and NULL means no deposit. The control renders that as a selected "No deposit" with a sentence saying so, not as an empty box that reads as half-saved. Turning the deposit off clears the column rather than storing an opt-out of itself: at company scope there is no third answer. THE UNIT IS NOT THE PAY-RULE UNIT. A pay rate goes on the wire as basis points and PayRuleWidget multiplies by 100 to get there. A deposit percent is a whole percent (`z.number().min(0).max(100)`), so nothing multiplies it; the only x100 in this path is dollars -> cents inside the shared MoneyInput, beside the "$". Sending 2000 for 20% would ask a client for twenty times the price, and only the schema's max(100) would notice. ZERO IS REFUSED. The API accepts `{ percent: 0 }`. A policy that reads as configured and charges nothing is the state the control exists to prevent, so the panel refuses it and says to choose No deposit instead. Two seams worth knowing about. The default is written through branding (`POST /api/admin/branding`) while the rest of the panel writes through tenant-config, so one Save touches two endpoints; the deposit half is sent only when the form carried it, because an absent key must leave a configured deposit alone. And the read comes from branding too -- `GET /api/admin/tenant-config` does not project the column, though both live on the same row. The SegmentedControl carries no hidden input. Its value reaches the server only because handleSave sends the state it owns; the test asserts the submitted body rather than the DOM for that reason. Verified in Chrome, light and dark: set 25%, saw `{"type":"percent","percent":25}` land in D1, and saw the public booking page quote it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- .../settings/BookingPoliciesPanel.test.tsx | 110 ++++++++++++++++++ .../settings/BookingPoliciesPanel.tsx | 102 ++++++++++++++++ app/lib/deposit-policy-form.ts | 97 +++++++++++++++ app/routes/settings-booking.tsx | 40 ++++++- messages/en/settings-components.json | 14 ++- messages/es-419/settings-components.json | 14 ++- 6 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 app/components/settings/BookingPoliciesPanel.test.tsx create mode 100644 app/lib/deposit-policy-form.ts diff --git a/app/components/settings/BookingPoliciesPanel.test.tsx b/app/components/settings/BookingPoliciesPanel.test.tsx new file mode 100644 index 000000000..cd3beed53 --- /dev/null +++ b/app/components/settings/BookingPoliciesPanel.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +/** + * The company-wide booking deposit (tier 1). + * + * The feature shipped with an API that accepted a deposit at all three tiers + * and a booking flow that charged it, and no control anywhere — so the only way + * to turn it on was a hand-written request. What is pinned here is not "the + * panel renders": it is the two things that would move the wrong amount of + * money if they drifted. + * + * - the UNIT. A deposit percent is a whole percent, unlike the pay rule next + * door, which is basis points. 20 must reach the wire as 20. + * - the DEFAULT. NULL means no deposit, and every existing company is NULL. + * The control has to render that as an answer, not as a blank. + * + * Both assertions read the submitted body rather than the DOM, because the + * SegmentedControl carries no hidden input: its value reaches the server only + * because the component sends the state it owns. + */ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; + +import { BookingPoliciesPanel } from "./BookingPoliciesPanel"; +import type { DepositPolicy } from "../../../server/lib/billing/deposit-policy"; + +function renderPanel(depositPolicy: DepositPolicy | null) { + const calls: Record[] = []; + const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + + ), + action: async ({ request }) => { + const form = await request.formData(); + calls.push(Object.fromEntries(form) as Record); + return { ok: true, intent: "policies-save" }; + }, + }, + ]); + render(); + return { calls }; +} + +const save = () => fireEvent.click(screen.getByRole("button", { name: /save policies/i })); + +describe("BookingPoliciesPanel — deposit", () => { + it("shows an unconfigured company a deliberate no-deposit, not an empty field", () => { + renderPanel(null); + + expect(screen.getByRole("radio", { name: "No deposit" }).getAttribute("aria-checked")).toBe("true"); + expect(screen.getByText(/not asked for anything up front/i)).toBeTruthy(); + // Nothing to fill in, so nothing that could look half-filled. + expect(screen.queryByLabelText(/deposit percent/i)).toBeNull(); + expect(screen.queryByLabelText(/deposit amount/i)).toBeNull(); + }); + + it("sends a whole percent, not basis points", async () => { + const { calls } = renderPanel(null); + + fireEvent.click(screen.getByRole("radio", { name: "Percent of the price" })); + fireEvent.change(screen.getByLabelText(/deposit percent/i), { target: { value: "20" } }); + save(); + + await waitFor(() => expect(calls).toHaveLength(1)); + // 2000 here would ask a client for twenty times the price. + expect(calls[0].depositPolicy).toBe(JSON.stringify({ type: "percent", percent: 20 })); + }); + + it("converts a typed dollar amount to integer cents", async () => { + const { calls } = renderPanel(null); + + fireEvent.click(screen.getByRole("radio", { name: "Fixed amount" })); + fireEvent.change(screen.getByLabelText(/deposit amount/i), { target: { value: "125" } }); + save(); + + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].depositPolicy).toBe(JSON.stringify({ type: "fixed", amountCents: 12500 })); + }); + + it("refuses a zero percent rather than storing a policy that charges nothing", async () => { + const { calls } = renderPanel(null); + + fireEvent.click(screen.getByRole("radio", { name: "Percent of the price" })); + fireEvent.change(screen.getByLabelText(/deposit percent/i), { target: { value: "0" } }); + save(); + + expect(await screen.findByText(/percent between 1 and 100/i)).toBeTruthy(); + expect(calls).toHaveLength(0); + }); + + it("clears the stored default when the company turns the deposit off", async () => { + const { calls } = renderPanel({ type: "percent", percent: 20 }); + + expect(screen.getByRole("radio", { name: "Percent of the price" }).getAttribute("aria-checked")).toBe("true"); + fireEvent.click(screen.getByRole("radio", { name: "No deposit" })); + save(); + + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].depositPolicy).toBe("null"); + }); +}); diff --git a/app/components/settings/BookingPoliciesPanel.tsx b/app/components/settings/BookingPoliciesPanel.tsx index 5ad26dbda..c8fca16aa 100644 --- a/app/components/settings/BookingPoliciesPanel.tsx +++ b/app/components/settings/BookingPoliciesPanel.tsx @@ -1,5 +1,13 @@ import { useState } from "react"; import { useFetcher } from "react-router"; +import { SegmentedControl } from "@core/shared-ui"; +import { MoneyInput } from "~/components/MoneyInput"; +import { + depositChoiceOf, + depositPolicyFromChoice, + type DepositChoice, +} from "~/lib/deposit-policy-form"; +import type { DepositPolicy } from "../../../server/lib/billing/deposit-policy"; import type { action } from "~/routes/settings-booking"; import { m } from "~/paraglide/messages"; @@ -7,6 +15,8 @@ interface TenantConfig { conciergeReviewRequired: boolean; blockUnsignedAgreement: boolean; allowInspectorChoice: boolean; + /** The company-wide deposit. NULL is where every company starts: no deposit. */ + depositPolicy: DepositPolicy | null; } export function BookingPoliciesPanel({ initialConfig }: { initialConfig: TenantConfig }) { @@ -16,6 +26,28 @@ export function BookingPoliciesPanel({ initialConfig }: { initialConfig: TenantC const [allowChoice, setAllowChoice] = useState(initialConfig.allowInspectorChoice); const [dirty, setDirty] = useState(false); + // A deposit is not a checkbox, but it IS a booking policy, so it lives in this + // panel under the same Save. The company default has three answers only — + // `inherit` belongs to a service, which has something to inherit FROM. + const stored = initialConfig.depositPolicy; + const [depositChoice, setDepositChoice] = useState( + stored ? depositChoiceOf(stored) : "none", + ); + const [percentText, setPercentText] = useState( + stored?.type === "percent" ? String(stored.percent ?? "") : "", + ); + const [amountCents, setAmountCents] = useState( + stored?.type === "fixed" ? (stored.amountCents ?? null) : null, + ); + // Eager-after-error: silent until a save is attempted, live from then on. + const [attempted, setAttempted] = useState(false); + const depositResult = depositPolicyFromChoice({ choice: depositChoice, percentText, amountCents }); + const depositError = attempted && !depositResult.ok + ? depositResult.field === "percent" + ? m.settings_deposit_error_percent() + : m.settings_deposit_error_amount() + : null; + const saving = fetcher.state !== "idle"; const saved = fetcher.state === "idle" && @@ -30,6 +62,14 @@ export function BookingPoliciesPanel({ initialConfig }: { initialConfig: TenantC !dirty; function handleSave() { + setAttempted(true); + // Refused here rather than sent: the API accepts a 0% deposit, and a policy + // that reads as configured while charging nothing is the half-saved state + // this control exists to make impossible. + if (!depositResult.ok) return; + // The company default has no "opted out of itself" state: its No deposit + // clears the column, which is also where every company already is. + const policy = depositResult.policy?.type === "none" ? null : depositResult.policy; setDirty(false); fetcher.submit( { @@ -37,6 +77,7 @@ export function BookingPoliciesPanel({ initialConfig }: { initialConfig: TenantC conciergeReviewRequired: String(concierge), blockUnsignedAgreement: String(blockUnsigned), allowInspectorChoice: String(allowChoice), + depositPolicy: JSON.stringify(policy), }, { method: "post" }, ); @@ -99,6 +140,67 @@ export function BookingPoliciesPanel({ initialConfig }: { initialConfig: TenantC + {/* Not a checkbox, so it cannot be a fourth row of them — but it is a + booking policy, and a panel of policies that omits the one that takes + money would send an admin looking for a page that does not exist. */} +
+ {m.settings_policies_deposit_label()} + {m.settings_policies_deposit_desc()} + +
+ {/* Buttons, not inputs: this control carries no hidden field, so its + value reaches the server only because handleSave sends the state + it owns. Never put it in a native form and expect a submission. */} + { setDepositChoice(v as DepositChoice); setDirty(true); }} + /> + + {depositChoice === "percent" && ( +
+ { setPercentText(e.target.value); setDirty(true); }} + aria-label={m.settings_deposit_percent_aria()} + /> + % +
+ )} + + {depositChoice === "fixed" && ( + { setAmountCents(c); setDirty(true); }} + className="h-8 w-28 px-2 rounded-md border border-ih-border bg-ih-bg-card text-[13px] text-ih-fg-1 focus:outline-none focus:ring-2 focus:ring-ih-primary" + ariaLabel={m.settings_deposit_amount_aria()} + /> + )} +
+ + {/* Off is a state, not an empty field. Every company starts here, and + saying so is the difference between "nothing is charged" and "this + looks unfinished". */} + {depositChoice === "none" && ( +

{m.settings_policies_deposit_off()}

+ )} + {depositChoice !== "none" && ( +

{m.settings_policies_deposit_service_note()}

+ )} + {depositError &&

{depositError}

} +
+
+
+ ) : ( +
+

+ {m.settings_deposit_heading()} +

+

{m.settings_deposit_explain()}

+ +
+
+ + + + {choice === "percent" && ( +
+ setPercentText(e.target.value)} + aria-label={m.settings_deposit_percent_aria()} + /> + % +
+ )} + + {choice === "fixed" && ( + + )} + + + +
+ + {/* Inheriting is a real answer, so it says what it means + rather than leaving the row looking unfinished. */} + {choice === "inherit" && ( +

+ {m.settings_deposit_summary_inherit({ value: describePolicy(companyDefault, money) })} +

+ )} + {(localError || serverError) && ( +

{localError ?? serverError}

+ )} +
+
+ )} +
+ ); +} diff --git a/app/components/settings/services/ServicesCatalogPanel.tsx b/app/components/settings/services/ServicesCatalogPanel.tsx index 7fd6dc45b..6fbe93869 100644 --- a/app/components/settings/services/ServicesCatalogPanel.tsx +++ b/app/components/settings/services/ServicesCatalogPanel.tsx @@ -3,7 +3,9 @@ import { Table } from "@core/shared-ui"; import { QualificationWidget } from "./QualificationWidget"; import { PayRuleWidget } from "./PayRuleWidget"; import type { PayRule } from "./PayRuleWidget"; +import { DepositWidget } from "./DepositWidget"; import { splitDurationMinutes, serviceIsBookable } from "~/lib/settings-services"; +import type { DepositPolicy } from "../../../../server/lib/billing/deposit-policy"; import { m } from "~/paraglide/messages"; interface Service { @@ -14,6 +16,8 @@ interface Service { active: boolean; durationMinutes: number | null; templateId: string | null; + /** This service's own deposit. NULL inherits the company default. */ + depositPolicy?: DepositPolicy | null; } interface Member { @@ -31,6 +35,8 @@ interface ServicesCatalogPanelProps { members: Member[]; /** templateId → template name, for naming the template each service builds from. */ templateNames: Record; + /** The company-wide deposit, so a row that inherits can say what it inherits. */ + companyDepositPolicy?: DepositPolicy | null; /** The row whose edit form is open, so its own Edit reads as the way back. */ editingId?: string | null; onEdit?: (id: string | null) => void; @@ -52,6 +58,7 @@ export function ServicesCatalogPanel({ payRuleMap, members, templateNames, + companyDepositPolicy = null, editingId = null, onEdit, }: ServicesCatalogPanelProps) { @@ -99,6 +106,14 @@ export function ServicesCatalogPanel({ rules={payRuleMap[svc.id] ?? []} members={members} /> + {/* The third adjacent question about one service: who may run + it, what they earn running it, what the client pays up + front to book it. */} + ), }, diff --git a/app/routes/settings-services.tsx b/app/routes/settings-services.tsx index eb21c52aa..42c1aff32 100644 --- a/app/routes/settings-services.tsx +++ b/app/routes/settings-services.tsx @@ -17,6 +17,8 @@ import { ServiceEditForm } from "~/components/settings/services/ServiceEditForm" import { DiscountCodesPanel } from "~/components/settings/services/DiscountCodesPanel"; import { loadPayRuleMap, savePayRule, deletePayRule } from "~/lib/settings/pay-rules.server"; import type { PayRule } from "~/components/settings/services/PayRuleWidget"; +import { parseDepositPolicy } from "~/lib/deposit-policy-form"; +import type { DepositPolicy } from "../../server/lib/billing/deposit-policy"; import { m } from "~/paraglide/messages"; export function meta() { @@ -33,6 +35,8 @@ interface Service { durationMinutes: number | null; /** The template a booking builds this service's inspection from. */ templateId: string | null; + /** This service's own deposit; NULL inherits the company default. */ + depositPolicy?: DepositPolicy | null; } /** Template choices for the service's report-template picker. */ @@ -61,7 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { if (forbidden) return { forbidden: true as const }; try { const api = createApi(context, { token }); - const [svcRes, discountRes, membersRes, templatesRes] = await Promise.all([ + const [svcRes, discountRes, membersRes, templatesRes, brandingRes] = await Promise.all([ api.services.index.$get({}), api.services["discount-codes"].$get().catch(() => null), api.admin.members.$get().catch(() => null), @@ -69,6 +73,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { // inspection from — without it the create form cannot set templateId and // multi-service booking fails at request time. api.inspections.templates.$get({ query: { page: "1", pageSize: "100" } }).catch(() => null), + // The company deposit default, so a service that inherits it can say what + // it inherits. Read from branding, which is where the column is written. + api.adminBranding.branding.$get({}).catch(() => null), ]); // GET /api/services returns { success, data: Service[] } — data IS the // array (the pre-C-10 admin endpoint wrapped it in { services, discounts }, @@ -114,6 +121,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { templates = (tb.data ?? []).map((t) => ({ id: t.id, name: t.name })); } + let companyDepositPolicy: DepositPolicy | null = null; + if (brandingRes?.ok) { + const bb = (await brandingRes.json()) as { data?: { branding?: Record } }; + companyDepositPolicy = parseDepositPolicy(bb.data?.branding?.depositPolicy); + } + return { services: rawServices, discounts: rawDiscounts, @@ -121,6 +134,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { payRuleMap, members, templates, + companyDepositPolicy, }; } catch { return { @@ -130,6 +144,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { payRuleMap: {} as Record, members: [] as Member[], templates: [] as TemplateOption[], + companyDepositPolicy: null as DepositPolicy | null, }; } } @@ -212,6 +227,32 @@ export async function action({ request, context }: Route.ActionArgs) { return await savePayRule(api, form); } else if (intent === "pay-rule-delete") { return await deletePayRule(api, form); + } else if (intent === "deposit-policy-save") { + // Tier 2 of the booking deposit. `null` is "inherit the company default" + // and `{ type: 'none' }` is "charge nothing for this service"; both are + // forwarded as themselves, because the column's whole purpose is that the + // two are different answers. + const id = String(form.get("serviceId") ?? ""); + let raw: unknown = null; + try { + raw = JSON.parse(String(form.get("depositPolicy") ?? "null")); + } catch { + return { ok: false, intent: "deposit-policy-save", serviceId: id, message: m.settings_deposit_error_save() }; + } + const res = await api.services[":id"].$put({ + param: { id }, + json: { depositPolicy: parseDepositPolicy(raw) }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { + ok: false, + intent: "deposit-policy-save", + serviceId: id, + message: (err as Record)?.message as string | undefined ?? m.settings_deposit_error_save(), + }; + } + return { ok: true, intent: "deposit-policy-save", serviceId: id }; } else if (intent === "qualification-save") { const id = String(form.get("serviceId") ?? ""); let userIds: string[]; @@ -274,7 +315,7 @@ export default function SettingsServices() { }); if ("forbidden" in data) return ; - const { services, discounts, restrictionMap, payRuleMap, members, templates } = data; + const { services, discounts, restrictionMap, payRuleMap, members, templates, companyDepositPolicy } = data; const editingService = services.find((s) => s.id === editingId) ?? null; return ( @@ -339,6 +380,7 @@ export default function SettingsServices() { payRuleMap={payRuleMap} members={members} templateNames={Object.fromEntries(templates.map((t) => [t.id, t.name]))} + companyDepositPolicy={companyDepositPolicy} editingId={editingId} onEdit={setEditingId} /> diff --git a/messages/en/settings-components.json b/messages/en/settings-components.json index 84b2fac18..935e38439 100644 --- a/messages/en/settings-components.json +++ b/messages/en/settings-components.json @@ -524,5 +524,19 @@ "settings_pay_rule_error_save": "Could not save the pay rule.", "settings_pay_rule_error_remove": "Could not remove the pay rule.", "settings_deposit_error_percent": "Enter a percent between 1 and 100, or choose No deposit.", - "settings_deposit_error_amount": "Enter an amount greater than zero, or choose No deposit." + "settings_deposit_error_amount": "Enter an amount greater than zero, or choose No deposit.", + "settings_deposit_label": "Deposit:", + "settings_deposit_summary_inherit": "Company default ({value})", + "settings_deposit_summary_none": "No deposit", + "settings_deposit_summary_percent": "{percent}% of the price", + "settings_deposit_company_nothing": "nothing", + "settings_deposit_change_link": "Change deposit", + "settings_deposit_heading": "What the client pays up front for this service", + "settings_deposit_explain": "Booking asks for the company default unless this service sets its own. Choose No deposit to ask nothing for this service while the company default still applies everywhere else.", + "settings_deposit_field_label": "Deposit", + "settings_deposit_type_inherit": "Company default", + "settings_deposit_type_none": "No deposit", + "settings_deposit_type_percent": "Percent of the price", + "settings_deposit_type_fixed": "Fixed amount", + "settings_deposit_error_save": "Could not save the deposit." } diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index 016d9c629..5bd19ebbf 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -524,5 +524,19 @@ "settings_pay_rule_error_save": "No se pudo guardar la regla de pago.", "settings_pay_rule_error_remove": "No se pudo quitar la regla de pago.", "settings_deposit_error_percent": "Ingrese un porcentaje entre 1 y 100, o elija Sin depósito.", - "settings_deposit_error_amount": "Ingrese un monto mayor que cero, o elija Sin depósito." + "settings_deposit_error_amount": "Ingrese un monto mayor que cero, o elija Sin depósito.", + "settings_deposit_label": "Depósito:", + "settings_deposit_summary_inherit": "Predeterminado de la empresa ({value})", + "settings_deposit_summary_none": "Sin depósito", + "settings_deposit_summary_percent": "{percent} % del precio", + "settings_deposit_company_nothing": "nada", + "settings_deposit_change_link": "Cambiar el depósito", + "settings_deposit_heading": "Lo que el cliente paga por adelantado por este servicio", + "settings_deposit_explain": "La reserva pide el depósito predeterminado de la empresa, salvo que este servicio defina el suyo. Elija Sin depósito para no pedir nada por este servicio, mientras el predeterminado de la empresa sigue aplicándose al resto.", + "settings_deposit_field_label": "Depósito", + "settings_deposit_type_inherit": "Predeterminado de la empresa", + "settings_deposit_type_none": "Sin depósito", + "settings_deposit_type_percent": "Porcentaje del precio", + "settings_deposit_type_fixed": "Monto fijo", + "settings_deposit_error_save": "No se pudo guardar el depósito." } From af4562ffc56c9bae4ad0ab745be95589fc773c46 Mon Sep 17 00:00:00 2001 From: important-new Date: Thu, 6 Aug 2026 23:30:03 +0800 Subject: [PATCH 63/77] feat(booking): the anchors routing needs, none of which existed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing by distance needs two coordinates, and this schema had neither: no office anchor anywhere under schema/tenant, and no per-inspector origin at all. Load-balancing needs a load, and booking rules need somewhere to put a lead time. One migration lays all of it down. inspector_service_areas which ZIPs an inspector will travel to; zero rows means everywhere, mirroring service_inspectors tenant_configs booking_routing_strategy / booking_min_lead_hours / booking_same_day_cutoff_time, plus company_lat/lng for the address the workspace already types into Settings and only ever used as PDF footer text users service_origin_address/lat/lng — NULL inherits the company coordinates, which is what makes `closest` work for a single-office workspace with no setup Every column is appended at its table's END; both tenant_configs and users are FK-referenced, and a mid-table insert would have drizzle rebuild the table without db:check saying a word. The generated migration is 14 ALTER/CREATE statements and no rebuild. An inspector's service origin can be their home address. That is personal data, but not a CONSUMER data subject's, so it lands in ERASURE_OUT_OF_SCOPE beside users.email and users.phone with a stated reason and no DSAR path — the settled position at the top of that block. It would not have been caught by the PII heuristic either way, which is exactly why it is written down. inline-ddl.ts was proved RED first (missing 6 tenant_configs columns) and is fixed in the same commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- migrations/0043_clumsy_night_thrasher.sql | 20 + migrations/meta/0043_snapshot.json | 10989 ++++++++++++++++ migrations/meta/_journal.json | 7 + server/lib/compliance/erasure-manifest.ts | 11 + server/lib/db/schema/index.ts | 1 + server/lib/db/schema/inspection/index.ts | 1 + .../lib/db/schema/inspection/service-area.ts | 32 + server/lib/db/schema/tenant/core.ts | 32 +- server/lib/db/schema/tenant/user.ts | 19 +- tests/helpers/inline-ddl.ts | 2 +- 10 files changed, 11111 insertions(+), 3 deletions(-) create mode 100644 migrations/0043_clumsy_night_thrasher.sql create mode 100644 migrations/meta/0043_snapshot.json create mode 100644 server/lib/db/schema/inspection/service-area.ts diff --git a/migrations/0043_clumsy_night_thrasher.sql b/migrations/0043_clumsy_night_thrasher.sql new file mode 100644 index 000000000..5b8b8fbd7 --- /dev/null +++ b/migrations/0043_clumsy_night_thrasher.sql @@ -0,0 +1,20 @@ +CREATE TABLE `inspector_service_areas` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `user_id` text NOT NULL, + `zip_prefix` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_inspector_service_areas_tenant` ON `inspector_service_areas` (`tenant_id`);--> statement-breakpoint +CREATE INDEX `idx_inspector_service_areas_user` ON `inspector_service_areas` (`tenant_id`,`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `uq_inspector_service_areas` ON `inspector_service_areas` (`tenant_id`,`user_id`,`zip_prefix`);--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `booking_routing_strategy` text DEFAULT 'first_available' NOT NULL;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `booking_min_lead_hours` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `booking_same_day_cutoff_time` text;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `company_lat` real;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `company_lng` real;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `company_geocoded_at` integer;--> statement-breakpoint +ALTER TABLE `users` ADD `service_origin_address` text;--> statement-breakpoint +ALTER TABLE `users` ADD `service_origin_lat` real;--> statement-breakpoint +ALTER TABLE `users` ADD `service_origin_lng` real; \ No newline at end of file diff --git a/migrations/meta/0043_snapshot.json b/migrations/meta/0043_snapshot.json new file mode 100644 index 000000000..5c50c74e2 --- /dev/null +++ b/migrations/meta/0043_snapshot.json @@ -0,0 +1,10989 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "36b2efa4-d9d2-48fd-9e10-3af455cd678c", + "prevId": "d47143cb-7f09-4b56-8a4d-c36969041c82", + "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_service_pay_splits": { + "name": "inspection_service_pay_splits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_service_id": { + "name": "inspection_service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrects_split_id": { + "name": "corrects_split_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "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_pay_split_line_user": { + "name": "uq_pay_split_line_user", + "columns": [ + "tenant_id", + "inspection_service_id", + "user_id" + ], + "isUnique": true, + "where": "corrects_split_id IS NULL" + }, + "idx_pay_split_user": { + "name": "idx_pay_split_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_pay_split_line": { + "name": "idx_pay_split_line", + "columns": [ + "tenant_id", + "inspection_service_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "deposit_required_cents": { + "name": "deposit_required_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deposit_overridden": { + "name": "is_deposit_overridden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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": {} + }, + "inspector_service_areas": { + "name": "inspector_service_areas", + "columns": { + "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 + }, + "zip_prefix": { + "name": "zip_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_service_areas_tenant": { + "name": "idx_inspector_service_areas_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_service_areas_user": { + "name": "idx_inspector_service_areas_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "uq_inspector_service_areas": { + "name": "uq_inspector_service_areas", + "columns": [ + "tenant_id", + "user_id", + "zip_prefix" + ], + "isUnique": true + } + }, + "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": {} + }, + "service_pay_rules": { + "name": "service_pay_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_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 + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deduction_cents": { + "name": "deduction_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_service_pay_rules_user": { + "name": "uq_service_pay_rules_user", + "columns": [ + "tenant_id", + "service_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "uq_service_pay_rules_default": { + "name": "uq_service_pay_rules_default", + "columns": [ + "tenant_id", + "service_id" + ], + "isUnique": true, + "where": "user_id IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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 + }, + "deposit_policy": { + "name": "deposit_policy", + "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'" + }, + "cancellation_policy": { + "name": "cancellation_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_agreement_id": { + "name": "cancellation_clause_agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_version": { + "name": "cancellation_clause_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_attested_at": { + "name": "cancellation_clause_attested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deposit_policy": { + "name": "deposit_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "booking_routing_strategy": { + "name": "booking_routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'first_available'" + }, + "booking_min_lead_hours": { + "name": "booking_min_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "booking_same_day_cutoff_time": { + "name": "booking_same_day_cutoff_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lat": { + "name": "company_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lng": { + "name": "company_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_geocoded_at": { + "name": "company_geocoded_at", + "type": "integer", + "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 + }, + "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 + }, + "service_origin_address": { + "name": "service_origin_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lat": { + "name": "service_origin_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lng": { + "name": "service_origin_lng", + "type": "real", + "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 87ef4ed72..63cd0e8aa 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -302,6 +302,13 @@ "when": 1786014741502, "tag": "0042_acoustic_khan", "breakpoints": true + }, + { + "idx": 43, + "version": "6", + "when": 1786030075090, + "tag": "0043_clumsy_night_thrasher", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index ce72451cd..4e757b180 100644 --- a/server/lib/compliance/erasure-manifest.ts +++ b/server/lib/compliance/erasure-manifest.ts @@ -199,6 +199,15 @@ export const ERASURE_OUT_OF_SCOPE: ErasureOutOfScopeEntry[] = [ { table: 'users', column: 'phone', reason: 'staff account — not consumer-DSAR scope' }, { table: 'users', column: 'default_signature_base64', reason: 'inspector (staff) signature asset' }, { table: 'users', column: 'is_signature_enabled', reason: 'boolean flag, not personal data' }, + // An inspector's routing origin can be their home address, so it IS personal + // data — it is simply not a CONSUMER data subject's. Same posture as + // users.email/phone above: consumer-DSAR erasure never touches it and there + // is deliberately no DSAR-export path for it. Declared here so the decision + // is recorded rather than inferred from the PII heuristic not matching + // 'service_origin_address'. + { table: 'users', column: 'service_origin_address', reason: 'staff routing origin (may be a home address) — staff offboarding lifecycle, not consumer-DSAR scope' }, + { table: 'users', column: 'service_origin_lat', reason: 'staff routing origin coordinate — not consumer-DSAR scope' }, + { table: 'users', column: 'service_origin_lng', reason: 'staff routing origin coordinate — not consumer-DSAR scope' }, { table: 'tenant_invites', column: 'email', reason: 'staff invite — not consumer-DSAR scope' }, { table: 'audit_logs', column: 'ip_address', reason: 'staff-action security audit trail' }, { table: 'report_signoff', column: 'signature_ref', reason: 'inspector (staff) signoff reference' }, @@ -208,6 +217,8 @@ export const ERASURE_OUT_OF_SCOPE: ErasureOutOfScopeEntry[] = [ { table: 'tenant_configs', column: 'support_email', reason: 'company-owned support address' }, { table: 'tenant_configs', column: 'sender_email', reason: 'company-owned sending address' }, { table: 'tenant_configs', column: 'company_phone', reason: 'company-owned phone' }, + { table: 'tenant_configs', column: 'company_lat', reason: 'company office coordinate — controller business identity' }, + { table: 'tenant_configs', column: 'company_lng', reason: 'company office coordinate — controller business identity' }, // Heuristic false positives — config values and references, not PII. { table: 'tenant_configs', column: 'email_mode', reason: 'config enum, not personal data' }, diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts index 0a2f23f02..c6656d864 100644 --- a/server/lib/db/schema/index.ts +++ b/server/lib/db/schema/index.ts @@ -31,6 +31,7 @@ export { defectCategories, costItems, reports, + inspectorServiceAreas, } from './inspection'; export { inspectorCredentials } from './inspection/inspector-credentials'; export { commercialSubtypes } from './commercial-subtypes'; diff --git a/server/lib/db/schema/inspection/index.ts b/server/lib/db/schema/inspection/index.ts index 0e1710160..7efb18179 100644 --- a/server/lib/db/schema/inspection/index.ts +++ b/server/lib/db/schema/inspection/index.ts @@ -11,3 +11,4 @@ export * from './message-template'; export * from './concierge'; export * from './defect-category'; export * from './cost-items'; +export * from './service-area'; diff --git a/server/lib/db/schema/inspection/service-area.ts b/server/lib/db/schema/inspection/service-area.ts new file mode 100644 index 000000000..430478ace --- /dev/null +++ b/server/lib/db/schema/inspection/service-area.ts @@ -0,0 +1,32 @@ +import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +/** + * Which ZIPs an inspector will travel to. + * + * Absence is the meaningful state: an inspector with ZERO rows serves + * everywhere, mirroring `service_inspectors` (zero rows for a service = every + * inspector qualifies). That default is what keeps this feature opt-in — a + * workspace that never opens the panel behaves exactly as it did before. + * + * `zip_prefix` is stored as typed, uppercased and trimmed. v1 matches a + * property ZIP by PREFIX, so '787' covers all of 787xx and '78701' covers only + * itself; the comparison lives in `server/lib/booking/eligibility.ts` and is + * the only place that knows the rule. + * + * No FKs per Schema Rules — `user_id` is an app-layer reference to `users.id`, + * and the API deletes rows by (tenant, user) rather than relying on cascade. + */ +export const inspectorServiceAreas = sqliteTable('inspector_service_areas', { + id: text('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + userId: text('user_id').notNull(), + zipPrefix: text('zip_prefix').notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}, (t) => [ + index('idx_inspector_service_areas_tenant').on(t.tenantId), + index('idx_inspector_service_areas_user').on(t.tenantId, t.userId), + // One row per (tenant, inspector, prefix). Saving the same list twice must + // not double it; the replace-list write deletes then inserts, and this + // index is what makes a partially-applied replace impossible to paper over. + uniqueIndex('uq_inspector_service_areas').on(t.tenantId, t.userId, t.zipPrefix), +]); diff --git a/server/lib/db/schema/tenant/core.ts b/server/lib/db/schema/tenant/core.ts index 318654da3..9e7278eed 100644 --- a/server/lib/db/schema/tenant/core.ts +++ b/server/lib/db/schema/tenant/core.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real, primaryKey } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; import type { ReportLinkTtl } from '../../../report-link-ttl'; import type { CancellationPolicy } from '../../../billing/cancellation-policy'; @@ -308,6 +308,36 @@ export const tenantConfigs = sqliteTable('tenant_configs', { // Appended at END of the table per the D1 add-column-at-end rule // (tenant_configs is FK-referenced). depositPolicy: text('deposit_policy', { mode: 'json' }).$type(), + // How the server chooses WHICH qualified inspector gets an auto-assigned + // booking. 'first_available' is the shipped behaviour (stable name sort) + // and stays the default, so nothing changes until a workspace opts in. + // + // The other two can be INAPPLICABLE to a given request — `least_loaded` + // when nothing in the ISO week is dated, `closest` when the property or + // every candidate lacks coordinates. When that happens the server falls + // back to first_available and RECORDS the substitution with a named reason + // (see server/lib/booking/routing.ts). A strategy that silently degrades + // into first_available is indistinguishable from one that works. + bookingRoutingStrategy: text('booking_routing_strategy', { + enum: ['first_available', 'least_loaded', 'closest'], + }).notNull().default('first_available'), + // Minimum hours between NOW and the start of a bookable slot. 0 (the + // default) preserves the prior behaviour of accepting any future slot. + bookingMinLeadHours: integer('booking_min_lead_hours').notNull().default(0), + // Wall-clock `HH:MM` in the TENANT timezone after which today's remaining + // slots stop being offered. NULL = no cutoff. Deliberately a civil time, + // not an instant: "no same-day after 3pm" is a statement about the office + // clock and must survive DST without anyone editing it. + bookingSameDayCutoffTime: text('booking_same_day_cutoff_time'), + // Coordinates of `company_address`, resolved ONCE through the Places + // details path when an admin saves the address. They are the default + // service origin for every inspector who has not set their own, which is + // what makes `closest` usable for a single-office workspace with no + // per-inspector setup at all. NULL = never geocoded (or the lookup failed); + // `closest` treats that as "this workspace has no anchor", not as (0,0). + companyLat: real('company_lat'), + companyLng: real('company_lng'), + companyGeocodedAt: integer('company_geocoded_at', { mode: 'timestamp_ms' }), }); /** diff --git a/server/lib/db/schema/tenant/user.ts b/server/lib/db/schema/tenant/user.ts index 6e9fcd0d1..8f7b43076 100644 --- a/server/lib/db/schema/tenant/user.ts +++ b/server/lib/db/schema/tenant/user.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; import { ROLES } from '../../../auth/roles'; import { tenants } from './core'; @@ -100,6 +100,23 @@ export const users = sqliteTable('users', { // 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'] }), + // Where this inspector STARTS their day, for `closest` routing. NULL on + // all three columns = inherit the company address coordinates + // (`tenant_configs.company_lat/lng`), which is the right answer for the + // single-office workspace and the only reason the strategy is usable + // without per-person setup. Set = a multi-office or home-based inspector + // whose drive does not start at the office. + // + // This is STAFF data, not a data subject's: it is declared in + // ERASURE_OUT_OF_SCOPE alongside users.email / users.phone, and consumer + // DSAR erasure never touches it (staff offboarding is a separate + // lifecycle). Do not build a DSAR export path for it. + // + // Appended at END — users is FK-referenced, so a mid-table insert would + // make drizzle rebuild the whole table. + serviceOriginAddress: text('service_origin_address'), + serviceOriginLat: real('service_origin_lat'), + serviceOriginLng: real('service_origin_lng'), }, (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 4747daafa..0253c8625 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\', booking_conflict_policy TEXT NOT NULL DEFAULT \'advisory\', cancellation_policy TEXT, cancellation_clause_agreement_id TEXT, cancellation_clause_version INTEGER, cancellation_clause_attested_at INTEGER, deposit_policy 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\', booking_conflict_policy TEXT NOT NULL DEFAULT \'advisory\', cancellation_policy TEXT, cancellation_clause_agreement_id TEXT, cancellation_clause_version INTEGER, cancellation_clause_attested_at INTEGER, deposit_policy TEXT, booking_routing_strategy TEXT NOT NULL DEFAULT \'first_available\', booking_min_lead_hours INTEGER NOT NULL DEFAULT 0, booking_same_day_cutoff_time TEXT, company_lat REAL, company_lng REAL, company_geocoded_at INTEGER, 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 12406fe982e68cb9f31b7b85c11e25708322f0b0 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 00:05:35 +0800 Subject: [PATCH 64/77] feat(booking): three routing strategies, and a way to tell when one did nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these strategies has an input on which it returns a perfectly plausible inspector while having computed nothing — and in this codebase that input was not an edge case, it was the only case: least_loaded every load ties at 0, so the tiebreak IS first_available. Counted off inspections.date, not scheduled_start_ms, which has no non-NULL rows in production. closest nothing is geocoded, so every distance is undefined and the tiebreak decides again. zip filter "empty ZIP -> degrade gracefully" was 100% of traffic, because no public booking ever carried a ZIP. So routing returns a DECISION, not an id: {requested, applied, reason, candidateCount}. A substitution is logged as booking.routing.substituted and written to audit_logs as booking.routing.applied on the inspection. The slot pipeline reports geoSkipped when the ZIP filter could not run, and outsideServiceArea — a distinct answer from "that time is taken" — when it ran and excluded everyone. Every degenerate branch has a test asserting the REPORTED reason; each was proved red by deleting the guard. A missing geocode is never a distance. An unanchored candidate is removed from `closest` rather than sorted to the bottom, because a sort position is a claim about a distance nobody measured. Also landing, because they are the same feature seen from the other end: - lib/places/geocode.ts. The Places details fetch existed and was CORRECT, and lived inside one JWT-gated route handler, which is exactly why nothing else could reach it. Booking fulfilment now resolves the submitted placeId to the property's coordinates and writes address_zip/lat/lng — columns the wizard had populated for a year and the public form never touched. - inspector_service_areas CRUD (PUT is replace-by-value, so an unguarded retry converges; proved, not asserted) and the ZIP filter ahead of the slot union. - Lead time and same-day cutoff, in the office's wall clock. server/lib/booking is now inside check-tz-safety.mjs SCOPE — a UTC-day bucket shipped green until this commit and was caught by a test, one gate later than it should be. Two extractions paid for the room: slot-arbitration (booking.service.ts had 20 lines of headroom against a hard 400) and the public geocode sub-router (bookings.ts was at its 477 baseline exactly). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- scripts/check-tz-safety.mjs | 6 + server/api/admin.ts | 4 +- server/api/admin/admin-service-areas.ts | 154 +++++++++ server/api/bookings.ts | 124 +------- server/api/bookings/geocode.ts | 122 ++++++++ server/api/places.ts | 63 +--- server/lib/audit.ts | 4 + server/lib/booking/booking-rules.ts | 118 +++++++ server/lib/booking/eligibility.ts | 141 +++++++++ server/lib/booking/routing.ts | 245 +++++++++++++++ server/lib/mcp/openapi-snapshot.json | 76 +++++ server/lib/places/geocode.ts | 140 +++++++++ server/lib/validations/booking.schema.ts | 14 + server/lib/validations/service-area.schema.ts | 42 +++ server/services/booking.service.ts | 167 +++++----- server/services/booking/booking-admission.ts | 60 +++- server/services/booking/fulfill-booking.ts | 67 +++- server/services/booking/route-inspector.ts | 146 +++++++++ server/services/booking/slot-arbitration.ts | 99 ++++++ tests/unit/bookings/booking-rules.spec.ts | 136 ++++++++ tests/unit/bookings/routing.spec.ts | 292 ++++++++++++++++++ tests/unit/bookings/service-area.spec.ts | 137 ++++++++ .../idempotency/service-areas-replay.spec.ts | 146 +++++++++ 23 files changed, 2246 insertions(+), 257 deletions(-) create mode 100644 server/api/admin/admin-service-areas.ts create mode 100644 server/api/bookings/geocode.ts create mode 100644 server/lib/booking/booking-rules.ts create mode 100644 server/lib/booking/eligibility.ts create mode 100644 server/lib/booking/routing.ts create mode 100644 server/lib/places/geocode.ts create mode 100644 server/lib/validations/service-area.schema.ts create mode 100644 server/services/booking/route-inspector.ts create mode 100644 server/services/booking/slot-arbitration.ts create mode 100644 tests/unit/bookings/booking-rules.spec.ts create mode 100644 tests/unit/bookings/routing.spec.ts create mode 100644 tests/unit/bookings/service-area.spec.ts create mode 100644 tests/unit/idempotency/service-areas-replay.spec.ts diff --git a/scripts/check-tz-safety.mjs b/scripts/check-tz-safety.mjs index 85275c22d..9d19b385b 100644 --- a/scripts/check-tz-safety.mjs +++ b/scripts/check-tz-safety.mjs @@ -63,6 +63,12 @@ const SCOPE = [ 'app/routes/calendar.tsx', 'app/routes/calendar-dispatch.tsx', 'server/services/calendar-items.service.ts', + // Booking rules are civil-time rules stated in the OFFICE's terms — a lead + // time in hours and a wall-clock same-day cutoff — and the ISO-week bucket + // that `least_loaded` counts is a calendar question too. A + // `.toISOString().slice(0,10)` here shipped green before this line existed; + // it was caught by a test, which is one gate later than it should have been. + 'server/lib/booking', ]; function collectFiles(path) { diff --git a/server/api/admin.ts b/server/api/admin.ts index 5028a9e1f..7988a6abb 100644 --- a/server/api/admin.ts +++ b/server/api/admin.ts @@ -27,6 +27,7 @@ import adminDataImportRoutes from './admin/admin-data-import'; import adminSettingsRoutes from './admin/admin-settings'; import adminConfigRoutes from './admin/admin-config'; import adminHolidayRoutes from './admin/admin-holidays'; +import adminServiceAreasRoutes from './admin/admin-service-areas'; const adminRoutes = createApiRouter() .route('/', adminAgreementsRoutes) @@ -36,7 +37,8 @@ const adminRoutes = createApiRouter() .route('/', adminDataImportRoutes) .route('/', adminSettingsRoutes) .route('/', adminConfigRoutes) - .route('/', adminHolidayRoutes); + .route('/', adminHolidayRoutes) + .route('/', adminServiceAreasRoutes); export type AdminApi = typeof adminRoutes; diff --git a/server/api/admin/admin-service-areas.ts b/server/api/admin/admin-service-areas.ts new file mode 100644 index 000000000..9ced50b11 --- /dev/null +++ b/server/api/admin/admin-service-areas.ts @@ -0,0 +1,154 @@ +// Admin → Inspector service areas sub-router. +// +// The ZIP territories that feed geographic eligibility on the booking pipeline +// (`server/lib/booking/eligibility.ts`). Zero rows for an inspector means they +// serve everywhere, so the DELETE-then-INSERT replace below is the only write +// shape: there is no "remove one ZIP" endpoint, because a partial failure +// would leave a territory nobody intended. +// +// Mounted through `server/api/admin.ts` (not server/index.ts) so every +// /api/admin path keeps coming from server/api/admin/. +import { createRoute } from '@hono/zod-openapi'; +import { and, eq } from 'drizzle-orm'; +import { createApiRouter } from '../../lib/openapi-router'; +import { requireRole } from '../../lib/middleware/rbac'; +import { getDrizzle } from '../../lib/route-helpers'; +import { Errors } from '../../lib/errors'; +import { auditFromContext } from '../../lib/audit'; +import { inspectorServiceAreas, users } from '../../lib/db/schema'; +import { + ServiceAreaQuerySchema, + ReplaceServiceAreasSchema, + ServiceAreaListResponseSchema, + ServiceAreaMapResponseSchema, +} from '../../lib/validations/service-area.schema'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; + +/* ── GET /api/admin/service-areas?userId= ─────────────────────────────────── */ +const getServiceAreasRoute = createRoute(withMcpMetadata({ + method: 'get', path: '/service-areas', + tags: ['admin'], + summary: 'List the ZIP prefixes one inspector serves', + middleware: [requireRole('owner', 'manager')] as const, + request: { query: ServiceAreaQuerySchema }, + responses: { + 200: { content: { 'application/json': { schema: ServiceAreaListResponseSchema } }, description: 'The inspector ZIP list' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'listInspectorServiceAreas', + description: 'Returns the ZIP prefixes this inspector will travel to. An empty list means they serve every area.', +}, { scopes: ['admin'], tier: 'extended' })); + +/* ── GET /api/admin/service-areas/all ─────────────────────────────────────── */ +const getAllServiceAreasRoute = createRoute(withMcpMetadata({ + method: 'get', path: '/service-areas/all', + tags: ['admin'], + summary: 'List every declared inspector territory in the tenant', + middleware: [requireRole('owner', 'manager')] as const, + request: {}, + responses: { + 200: { content: { 'application/json': { schema: ServiceAreaMapResponseSchema } }, description: 'Declared territories' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'listAllInspectorServiceAreas', + description: 'Returns every inspector that has declared at least one ZIP prefix. Inspectors absent from the list serve every area.', +}, { scopes: ['admin'], tier: 'extended' })); + +/* ── PUT /api/admin/service-areas ─────────────────────────────────────────── */ +const replaceServiceAreasRoute = createRoute(withMcpMetadata({ + method: 'put', path: '/service-areas', + tags: ['admin'], + summary: 'Replace one inspector ZIP list', + middleware: [requireRole('owner', 'manager')] as const, + request: { body: { content: { 'application/json': { schema: ReplaceServiceAreasSchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: ServiceAreaListResponseSchema } }, description: 'The stored list' }, + 404: { description: 'Inspector not found in this tenant' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'replaceInspectorServiceAreas', + description: 'Replaces the inspector ZIP list wholesale. Sending an empty array clears the territory, which means they serve every area again.', +}, { scopes: ['admin'], tier: 'extended' })); + +const adminServiceAreasRoutes = createApiRouter() + .openapi(getServiceAreasRoute, async (c) => { + const tenantId = c.get('tenantId'); + const { userId } = c.req.valid('query'); + const rows = await getDrizzle(c).select({ zipPrefix: inspectorServiceAreas.zipPrefix }) + .from(inspectorServiceAreas) + .where(and( + eq(inspectorServiceAreas.tenantId, tenantId), + eq(inspectorServiceAreas.userId, userId), + )).all(); + return c.json({ + success: true as const, + data: { userId, zipPrefixes: rows.map(r => r.zipPrefix).sort() }, + }, 200); + }) + .openapi(getAllServiceAreasRoute, async (c) => { + const tenantId = c.get('tenantId'); + const rows = await getDrizzle(c).select({ + userId: inspectorServiceAreas.userId, + zipPrefix: inspectorServiceAreas.zipPrefix, + }).from(inspectorServiceAreas) + .where(eq(inspectorServiceAreas.tenantId, tenantId)).all(); + const byUser = new Map(); + for (const row of rows) { + const list = byUser.get(row.userId) ?? []; + list.push(row.zipPrefix); + byUser.set(row.userId, list); + } + return c.json({ + success: true as const, + data: [...byUser.entries()] + .map(([userId, zipPrefixes]) => ({ userId, zipPrefixes: zipPrefixes.sort() })) + .sort((a, b) => a.userId.localeCompare(b.userId)), + }, 200); + }) + .openapi(replaceServiceAreasRoute, async (c) => { + const tenantId = c.get('tenantId'); + const { userId, zipPrefixes } = c.req.valid('json'); + const db = getDrizzle(c); + + // The inspector must belong to THIS tenant. Without this a tampered + // userId would write territory rows keyed to a stranger, and the + // eligibility filter would then read them back under our tenant id. + const member = await db.select({ id: users.id }).from(users) + .where(and(eq(users.id, userId), eq(users.tenantId, tenantId))).get(); + if (!member) throw Errors.NotFound('Inspector not found.'); + + // De-duplicate before writing: the unique index would reject a repeat + // and a client that typed "78701, 78701" meant one ZIP, not an error. + const unique = [...new Set(zipPrefixes)].sort(); + + await db.delete(inspectorServiceAreas).where(and( + eq(inspectorServiceAreas.tenantId, tenantId), + eq(inspectorServiceAreas.userId, userId), + )); + if (unique.length > 0) { + const now = new Date(); + // D1 binds 100 parameters per statement and each row binds 5, so + // chunk rather than trusting the list to stay short. + const CHUNK = 20; + for (let i = 0; i < unique.length; i += CHUNK) { + await db.insert(inspectorServiceAreas).values( + unique.slice(i, i + CHUNK).map(zipPrefix => ({ + id: crypto.randomUUID(), + tenantId, + userId, + zipPrefix, + createdAt: now, + })), + ); + } + } + + auditFromContext(c, 'config.service_areas.replace', 'inspector_service_areas', { + entityId: userId, + metadata: { zipPrefixes: unique }, + }); + return c.json({ success: true as const, data: { userId, zipPrefixes: unique } }, 200); + }); + +export type AdminServiceAreasApi = typeof adminServiceAreasRoutes; +export default adminServiceAreasRoutes; diff --git a/server/api/bookings.ts b/server/api/bookings.ts index 97407fc57..0cee36153 100644 --- a/server/api/bookings.ts +++ b/server/api/bookings.ts @@ -20,7 +20,6 @@ import { eq } from 'drizzle-orm'; import { services as servicesTable, tenants } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { checkRateLimit } from '../lib/rate-limit'; -import { logger } from '../lib/logger'; import { InspectorsResponseSchema, AvailabilityResponseSchema @@ -33,6 +32,7 @@ import bookingProfileRoutes from './bookings/profile'; // lands, so the external path is identical either way. import depositIntentRoutes from './public/deposit-intent'; import agreementRoutes from './bookings/agreement'; +import publicGeocodeRoutes from './bookings/geocode'; import { getDrizzle } from '../lib/route-helpers'; /** @@ -125,55 +125,6 @@ const getAvailabilityRoute = createRoute(withMcpMetadata({ description: "Auto-generated placeholder for getBookingAvailability (GET /availability/{inspectorId}, bookings domain). TODO: replace with a real description sourced from the handler." }, { scopes: ['read'], tier: 'extended' })); -/** - * Sprint 1 C-5 — Public address autocomplete proxy for the unauthenticated - * /book page. The internal `/api/places/autocomplete` endpoint is JWT-gated, - * so we expose a thin public forwarder here with three guarantees: - * - * 1. Token never leaves the worker (kept off the wire entirely). - * 2. If no token is configured, returns `{ data: [], reason: 'NO_API_KEY' }` - * and the client falls back silently to plain-text input. - * 3. Rate-limited via the shared booking rate limiter to deter scraping. - * - * This implementation uses Google Places (existing `GOOGLE_PLACES_API_KEY` - * binding) — same upstream that powers the dashboard's authenticated - * autocomplete. The plan language uses "Mapbox" as a placeholder for any - * geocoder; we align with the existing infrastructure. - */ -const publicGeocodeRoute = createRoute(withMcpMetadata({ - method: 'get', - path: '/geocode', - tags: ["bookings", "public"], - summary: 'Address autocomplete proxy (public, rate-limited)', - request: { - query: z.object({ - q: z.string().min(1).max(200).openapi({ example: '1005 S Gay' }).describe('TODO describe q field for the OpenInspection MCP integration'), - }).describe('TODO describe query field for the OpenInspection MCP integration'), - }, - responses: { - 200: { - content: { - 'application/json': { - schema: z.object({ - data: z.array(z.object({ - label: z.string().describe('TODO describe label field for the OpenInspection MCP integration'), - line1: z.string().describe('TODO describe line1 field for the OpenInspection MCP integration'), - city: z.string().nullable().describe('TODO describe city field for the OpenInspection MCP integration'), - state: z.string().nullable().describe('TODO describe state field for the OpenInspection MCP integration'), - zip: z.string().nullable().describe('TODO describe zip field for the OpenInspection MCP integration'), - placeId: z.string().describe('TODO describe placeId field for the OpenInspection MCP integration'), - })).describe('TODO describe data field for the OpenInspection MCP integration'), - reason: z.enum(['NO_API_KEY', 'UPSTREAM_ERROR']).optional().describe('TODO describe reason field for the OpenInspection MCP integration'), - }), - }, - }, - description: 'Autocomplete suggestions or fallback reason', - }, - }, - operationId: "geocodeBooking", - description: "Auto-generated placeholder for geocodeBooking (GET /geocode, bookings domain). TODO: replace with a real description sourced from the handler." -}, { scopes: ['read'], tier: 'extended' })); - /** * GET /api/public/slots — company-level aggregated bookable time slots (IA-26). */ @@ -189,6 +140,8 @@ const getTenantSlotsRoute = createRoute(withMcpMetadata({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).openapi({ example: '2026-07-01' }).describe('Date to query, YYYY-MM-DD.'), serviceIds: z.string().optional().openapi({ example: 'svc-1,svc-2' }).describe('Comma-separated service ids; restricts the qualified-inspector set.'), inspectorId: z.string().trim().min(1).optional().describe('Restrict slots to a single inspector (client choice / deep link).'), + propertyZip: z.string().trim().min(3).max(10).optional().openapi({ example: '78701' }) + .describe('Property ZIP. Restricts the union to inspectors whose service area covers it; omitted means the geographic filter cannot run.'), }).describe('Tenant slot query parameters'), }, responses: { @@ -206,6 +159,8 @@ const getTenantSlotsRoute = createRoute(withMcpMetadata({ date: z.string().describe('Civil date YYYY-MM-DD'), name: z.string().describe('Holiday display name'), }).optional().describe('Present when public holiday policy is advisory and the date is in the catalog'), + outsideServiceArea: z.boolean().optional() + .describe('Present and true when a propertyZip was supplied and no inspector serves it. Distinguishes "we do not travel there" from "that date is full".'), }).describe('Aggregated slot data'), }).describe('Tenant slots response'), }, @@ -283,71 +238,17 @@ export const bookingsRoutes = createApiRouter() }) .route('/', createBookingRoutes) .route('/', agreementRoutes) - .openapi(publicGeocodeRoute, async (c) => { - await checkRateLimit(c, 'book'); - const { q } = c.req.valid('query'); - if (q.length < 3) { - return c.json({ success: true, data: [] }, 200); - } - const apiKey = c.env.GOOGLE_PLACES_API_KEY; - if (!apiKey) { - return c.json({ success: true, data: [], meta: { reason: 'NO_API_KEY' as const } }, 200); - } - - try { - const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json'); - url.searchParams.set('input', q); - url.searchParams.set('types', 'address'); - url.searchParams.set('components', 'country:us'); - url.searchParams.set('key', apiKey); - const res = await fetch(url.toString()); - if (!res.ok) { - logger.warn('[public.geocode] upstream error', { status: res.status }); - return c.json({ success: true, data: [], meta: { reason: 'UPSTREAM_ERROR' as const } }, 200); - } - const j = await res.json() as { - status: string; - predictions?: Array<{ - place_id: string; - description: string; - terms?: Array<{ value: string }>; - structured_formatting?: { main_text?: string; secondary_text?: string }; - }>; - }; - if (j.status !== 'OK' && j.status !== 'ZERO_RESULTS') { - logger.warn('[public.geocode] upstream status', { status: j.status }); - return c.json({ success: true, data: [], meta: { reason: 'UPSTREAM_ERROR' as const } }, 200); - } - // Best-effort split of secondary_text into city / state / zip — Google - // Places returns "City, ST 12345" for US addresses. We do a lenient - // regex split; clients should treat these as hints, not authoritative. - const data = (j.predictions ?? []).slice(0, 5).map(p => { - const main = p.structured_formatting?.main_text || p.description; - const secondary = p.structured_formatting?.secondary_text || ''; - const m = secondary.match(/^([^,]+),\s*([A-Z]{2})\s*(\d{5})?/); - return { - label: p.description, - line1: main, - city: m?.[1] ?? null, - state: m?.[2] ?? null, - zip: m?.[3] ?? null, - placeId: p.place_id, - }; - }); - return c.json({ success: true, data }, 200); - } catch (e) { - logger.error('[public.geocode] exception', {}, e instanceof Error ? e : undefined); - return c.json({ success: true, data: [], meta: { reason: 'UPSTREAM_ERROR' as const } }, 200); - } - }) + .route('/', publicGeocodeRoutes) .openapi(getTenantSlotsRoute, async (c) => { await checkRateLimit(c, 'availability'); - const { tenant, date, serviceIds, inspectorId } = c.req.valid('query'); + const { tenant, date, serviceIds, inspectorId, propertyZip } = c.req.valid('query'); const tenantRow = await getDrizzle(c).select({ id: tenants.id }) .from(tenants).where(eq(tenants.slug, tenant)).get(); if (!tenantRow) throw Errors.NotFound('Tenant not found.'); const ids = serviceIds ? serviceIds.split(',').filter(Boolean) : []; - const all = await c.var.services.booking.getTenantSlots(tenantRow.id, date, ids); + const all = await c.var.services.booking.getTenantSlots( + tenantRow.id, date, ids, undefined, propertyZip ?? null, + ); const slots = all.slots.map(s => ({ time: s.time, available: inspectorId ? s.inspectorIds.includes(inspectorId) : s.available, @@ -357,6 +258,11 @@ export const bookingsRoutes = createApiRouter() data: { slots, ...(all.holidayAdvisory ? { holidayAdvisory: all.holidayAdvisory } : {}), + // IA-26 keeps inspector identities server-side, but "nobody + // serves your area" is about the CLIENT's property, not about + // who we employ — and a silent empty grid would send them + // hunting through dates for a slot that cannot exist. + ...(all.outsideServiceArea ? { outsideServiceArea: true } : {}), }, }, 200); }); diff --git a/server/api/bookings/geocode.ts b/server/api/bookings/geocode.ts new file mode 100644 index 000000000..0652684d3 --- /dev/null +++ b/server/api/bookings/geocode.ts @@ -0,0 +1,122 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { createApiRouter } from '../../lib/openapi-router'; +import { checkRateLimit } from '../../lib/rate-limit'; +import { logger } from '../../lib/logger'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; + +// Public address autocomplete sub-router. Extracted from bookings.ts +// unchanged, to make room in that file for the propertyZip query param the +// slots route now forwards — the two are the same feature seen from both +// ends: this route hands the browser a placeId + ZIP, and the slots route +// is where that ZIP finally does something. + +/** + * Sprint 1 C-5 — Public address autocomplete proxy for the unauthenticated + * /book page. The internal `/api/places/autocomplete` endpoint is JWT-gated, + * so we expose a thin public forwarder here with three guarantees: + * + * 1. Token never leaves the worker (kept off the wire entirely). + * 2. If no token is configured, returns `{ data: [], reason: 'NO_API_KEY' }` + * and the client falls back silently to plain-text input. + * 3. Rate-limited via the shared booking rate limiter to deter scraping. + * + * This implementation uses Google Places (existing `GOOGLE_PLACES_API_KEY` + * binding) — same upstream that powers the dashboard's authenticated + * autocomplete. The plan language uses "Mapbox" as a placeholder for any + * geocoder; we align with the existing infrastructure. + */ +const publicGeocodeRoute = createRoute(withMcpMetadata({ + method: 'get', + path: '/geocode', + tags: ["bookings", "public"], + summary: 'Address autocomplete proxy (public, rate-limited)', + request: { + query: z.object({ + q: z.string().min(1).max(200).openapi({ example: '1005 S Gay' }).describe('TODO describe q field for the OpenInspection MCP integration'), + }).describe('TODO describe query field for the OpenInspection MCP integration'), + }, + responses: { + 200: { + content: { + 'application/json': { + schema: z.object({ + data: z.array(z.object({ + label: z.string().describe('TODO describe label field for the OpenInspection MCP integration'), + line1: z.string().describe('TODO describe line1 field for the OpenInspection MCP integration'), + city: z.string().nullable().describe('TODO describe city field for the OpenInspection MCP integration'), + state: z.string().nullable().describe('TODO describe state field for the OpenInspection MCP integration'), + zip: z.string().nullable().describe('TODO describe zip field for the OpenInspection MCP integration'), + placeId: z.string().describe('TODO describe placeId field for the OpenInspection MCP integration'), + })).describe('TODO describe data field for the OpenInspection MCP integration'), + reason: z.enum(['NO_API_KEY', 'UPSTREAM_ERROR']).optional().describe('TODO describe reason field for the OpenInspection MCP integration'), + }), + }, + }, + description: 'Autocomplete suggestions or fallback reason', + }, + }, + operationId: "geocodeBooking", + description: "Auto-generated placeholder for geocodeBooking (GET /geocode, bookings domain). TODO: replace with a real description sourced from the handler." +}, { scopes: ['read'], tier: 'extended' })); + +const publicGeocodeRoutes = createApiRouter() + .openapi(publicGeocodeRoute, async (c) => { + await checkRateLimit(c, 'book'); + const { q } = c.req.valid('query'); + if (q.length < 3) { + return c.json({ success: true, data: [] }, 200); + } + const apiKey = c.env.GOOGLE_PLACES_API_KEY; + if (!apiKey) { + return c.json({ success: true, data: [], meta: { reason: 'NO_API_KEY' as const } }, 200); + } + + try { + const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json'); + url.searchParams.set('input', q); + url.searchParams.set('types', 'address'); + url.searchParams.set('components', 'country:us'); + url.searchParams.set('key', apiKey); + const res = await fetch(url.toString()); + if (!res.ok) { + logger.warn('[public.geocode] upstream error', { status: res.status }); + return c.json({ success: true, data: [], meta: { reason: 'UPSTREAM_ERROR' as const } }, 200); + } + const j = await res.json() as { + status: string; + predictions?: Array<{ + place_id: string; + description: string; + terms?: Array<{ value: string }>; + structured_formatting?: { main_text?: string; secondary_text?: string }; + }>; + }; + if (j.status !== 'OK' && j.status !== 'ZERO_RESULTS') { + logger.warn('[public.geocode] upstream status', { status: j.status }); + return c.json({ success: true, data: [], meta: { reason: 'UPSTREAM_ERROR' as const } }, 200); + } + // Best-effort split of secondary_text into city / state / zip — Google + // Places returns "City, ST 12345" for US addresses. We do a lenient + // regex split; clients should treat these as hints, not authoritative. + const data = (j.predictions ?? []).slice(0, 5).map(p => { + const main = p.structured_formatting?.main_text || p.description; + const secondary = p.structured_formatting?.secondary_text || ''; + const m = secondary.match(/^([^,]+),\s*([A-Z]{2})\s*(\d{5})?/); + return { + label: p.description, + line1: main, + city: m?.[1] ?? null, + state: m?.[2] ?? null, + zip: m?.[3] ?? null, + placeId: p.place_id, + }; + }); + return c.json({ success: true, data }, 200); + } catch (e) { + logger.error('[public.geocode] exception', {}, e instanceof Error ? e : undefined); + return c.json({ success: true, data: [], meta: { reason: 'UPSTREAM_ERROR' as const } }, 200); + } + }) +; + +export default publicGeocodeRoutes; diff --git a/server/api/places.ts b/server/api/places.ts index bb1d48dd3..93235949b 100644 --- a/server/api/places.ts +++ b/server/api/places.ts @@ -3,6 +3,7 @@ import { createApiRouter } from '../lib/openapi-router'; import { Errors } from '../lib/errors'; import { logger } from '../lib/logger'; import { withMcpMetadata } from "../lib/route-metadata-standards"; +import { fetchPlaceDetails, placeDetailsCacheKey, type ResolvedPlace } from '../lib/places/geocode'; /** * Spec 5D — Address Autofill (Phase 1) — server-side proxy for the @@ -161,68 +162,20 @@ const placesRoutes = createApiRouter() if (!apiKey) throw Errors.BadRequest('Address details unavailable: GOOGLE_PLACES_API_KEY not configured'); const { placeId, session } = c.req.valid('query'); - const cacheKey = `places:detail:${placeId}`; + const cacheKey = placeDetailsCacheKey(placeId); if (c.env.TENANT_CACHE) { - const cached = await c.env.TENANT_CACHE.get(cacheKey, 'json') as { - placeId: string; formatted: string; - street: string | null; city: string | null; state: string | null; - zip: string | null; county: string | null; - lat: number; lng: number; - } | null; + const cached = await c.env.TENANT_CACHE.get(cacheKey, 'json') as ResolvedPlace | null; if (cached) { return c.json({ success: true, data: cached, meta: { cached: true } }, 200); } } - const url = new URL(`${GOOGLE_BASE}/details/json`); - url.searchParams.set('place_id', placeId); - url.searchParams.set('sessiontoken', session); - // Tight field mask — billed per-field-per-call. - url.searchParams.set('fields', 'place_id,formatted_address,address_components,geometry/location'); - url.searchParams.set('key', apiKey); - - const res = await fetch(url.toString()); - if (!res.ok) { - logger.error('[places.details] google api error', { status: res.status }); - throw Errors.BadRequest('Address details temporarily unavailable'); - } - const data = await res.json() as { - status: string; - result?: { - place_id: string; - formatted_address: string; - address_components: Array<{ long_name: string; short_name: string; types: string[] }>; - geometry: { location: { lat: number; lng: number } }; - }; - }; - - if (data.status !== 'OK' || !data.result) { - logger.error('[places.details] google api status', { status: data.status }); - throw Errors.BadRequest('Address details failed'); - } - - const r = data.result; - const partOf = (type: string, useShort = false): string | null => { - const c = r.address_components.find(x => x.types.includes(type)); - return c ? (useShort ? c.short_name : c.long_name) : null; - }; - - const streetNumber = partOf('street_number'); - const route = partOf('route'); - const street = streetNumber && route ? `${streetNumber} ${route}` : (route || null); - - const payload = { - placeId: r.place_id, - formatted: r.formatted_address, - street, - city: partOf('locality') || partOf('sublocality') || partOf('administrative_area_level_3'), - state: partOf('administrative_area_level_1', true), - zip: partOf('postal_code'), - county: partOf('administrative_area_level_2'), - lat: r.geometry.location.lat, - lng: r.geometry.location.lng, - }; + // The fetch + field mask + component mapping live in lib/places/geocode.ts + // so booking fulfilment and Settings can geocode too — this route was the + // only way to reach that logic, which is exactly why nothing else did. + const payload = await fetchPlaceDetails(apiKey, placeId, session); + if (!payload) throw Errors.BadRequest('Address details temporarily unavailable'); if (c.env.TENANT_CACHE) { await c.env.TENANT_CACHE.put(cacheKey, JSON.stringify(payload), { expirationTtl: 60 * 24 * 60 * 60 }); diff --git a/server/lib/audit.ts b/server/lib/audit.ts index 8ee31844b..7ac492a04 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -84,6 +84,10 @@ export type AuditAction = | 'config.attention_thresholds.update' | 'config.dashboard_columns.update' | 'config.tenant_config.patch' + // The ZIP territories that decide who is even OFFERED a booking. Audited + // because clearing a list silently widens one inspector's reach and + // narrowing one can make a workspace look closed in a whole postcode. + | 'config.service_areas.replace' | 'tag.created' | 'tag.updated' | 'tag.deleted' diff --git a/server/lib/booking/booking-rules.ts b/server/lib/booking/booking-rules.ts new file mode 100644 index 000000000..b582373be --- /dev/null +++ b/server/lib/booking/booking-rules.ts @@ -0,0 +1,118 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { eq } from 'drizzle-orm'; +import { tenantConfigs } from '../db/schema'; +import { epochMsToWallClockYmd, epochMsToWallClockHm, wallClockToEpochMs, resolveTenantTimeZone } from '../tz'; + +/** + * Tenant booking rules: how far ahead a client must book, and when today's + * remaining slots stop being offered. + * + * Both are stated in the OFFICE's terms — hours of notice, and a wall-clock + * cutoff in the tenant zone — so every comparison here goes through + * `server/lib/tz.ts`. A UTC-day bucket would ship green until this change put + * `server/lib/booking` inside the `check-tz-safety.mjs` SCOPE, and it would be + * wrong for every tenant west of UTC after 17:00 local. + * + * Sibling naming: `slot-rules.ts` owns the slot GRID (mode + interval); + * this file owns whether a computed slot may still be booked. + */ + +/** Why a slot is not offerable. `null` when it is. */ +export type BookingRuleBlockReason = 'min_lead' | 'same_day_cutoff'; + +export interface BookingRules { + /** Hours of notice required. 0 = no lead requirement (the default). */ + minLeadHours: number; + /** Wall-clock `HH:MM` in the tenant zone, or null for no cutoff. */ + sameDayCutoffTime: string | null; +} + +export interface BookingRuleInput extends BookingRules { + /** The slot's civil date, `YYYY-MM-DD`, in the tenant zone. */ + civilDate: string; + /** The slot's wall-clock start, `HH:MM`, in the tenant zone. */ + slotTime: string; + tenantTz: string; + nowMs: number; +} + +export interface BookingRuleVerdict { + allowed: boolean; + reason: BookingRuleBlockReason | null; +} + +const ALLOWED: BookingRuleVerdict = { allowed: true, reason: null }; + +/** Normalize a stored cutoff. Anything that is not `HH:MM` means "no cutoff". */ +export function parseCutoffTime(raw: string | null | undefined): string | null { + const v = (raw ?? '').trim(); + return /^([01]\d|2[0-3]):[0-5]\d$/.test(v) ? v : null; +} + +/** Clamp a stored lead time to something a slot filter can act on. */ +export function parseMinLeadHours(raw: number | null | undefined): number { + const n = Number(raw ?? 0); + if (!Number.isFinite(n) || n <= 0) return 0; + // A year of required notice is a data-entry accident, not a policy. + return Math.min(Math.floor(n), 24 * 365); +} + +/** + * Whether one slot may still be booked. + * + * Order matters and is deliberate: the lead requirement is the stronger, + * always-on rule, so it is evaluated first and its reason wins when both + * apply. A UI that reports `same_day_cutoff` on a slot that also violates a + * 48-hour lead would send the client back tomorrow to hit the same wall. + */ +export function applyBookingRules(input: BookingRuleInput): BookingRuleVerdict { + const { civilDate, slotTime, tenantTz, nowMs } = input; + const minLeadHours = parseMinLeadHours(input.minLeadHours); + const cutoff = parseCutoffTime(input.sameDayCutoffTime); + if (minLeadHours === 0 && cutoff === null) return ALLOWED; + + const slotMs = wallClockToEpochMs(civilDate, slotTime, tenantTz); + + if (minLeadHours > 0 && slotMs - nowMs < minLeadHours * 3600_000) { + return { allowed: false, reason: 'min_lead' }; + } + + if (cutoff !== null) { + const todayLocal = epochMsToWallClockYmd(nowMs, tenantTz); + // "Same day" means the OFFICE's today, which is why both sides of this + // comparison are wall-clock in the tenant zone. + if (civilDate === todayLocal && epochMsToWallClockHm(nowMs, tenantTz) >= cutoff) { + return { allowed: false, reason: 'same_day_cutoff' }; + } + } + + return ALLOWED; +} + +/** What a tenant's booking rules amount to, resolved once per slot request. */ +export interface LoadedBookingRules extends BookingRules { + tenantTz: string; + /** True when neither rule is configured — the caller can skip the filter. */ + inactive: boolean; +} + +/** Loader beside the rules, same shape as `loadSlotGridOptions` in slot-rules.ts. */ +export async function loadBookingRules( + d1: D1Database, + tenantId: string, +): Promise { + const row = await drizzle(d1).select({ + minLeadHours: tenantConfigs.bookingMinLeadHours, + cutoff: tenantConfigs.bookingSameDayCutoffTime, + defaultTimezone: tenantConfigs.defaultTimezone, + }).from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + + const minLeadHours = parseMinLeadHours(row?.minLeadHours); + const sameDayCutoffTime = parseCutoffTime(row?.cutoff); + return { + minLeadHours, + sameDayCutoffTime, + tenantTz: resolveTenantTimeZone(row?.defaultTimezone), + inactive: minLeadHours === 0 && sameDayCutoffTime === null, + }; +} diff --git a/server/lib/booking/eligibility.ts b/server/lib/booking/eligibility.ts new file mode 100644 index 000000000..c29960e55 --- /dev/null +++ b/server/lib/booking/eligibility.ts @@ -0,0 +1,141 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq, inArray } from 'drizzle-orm'; +import { inspectorServiceAreas } from '../db/schema'; + +/** + * Geographic eligibility — which inspectors will travel to this property. + * + * THE RULE THIS FILE EXISTS TO ENFORCE: a filter that cannot run is not a + * filter that passed. The first version of this idea "degraded gracefully" on + * an empty property ZIP, which sounded careful and was in fact the only branch + * that ever executed — the public booking form captured no ZIP at all, so 100% + * of bookings took the graceful path and the feature was decoration. Every + * non-filtering outcome here therefore comes back with a NAMED reason the + * caller logs. Silence is not available. + */ + +/** Why the ZIP filter did not narrow the candidate set. `null` = it did. */ +export type EligibilitySkipReason = + /** The property carries no ZIP — nothing to compare service areas against. */ + | 'property_zip_unknown' + /** No inspector in the tenant has declared any service area at all. */ + | 'no_service_areas_configured'; + +export interface EligibilityOutcome { + /** The surviving candidates. Empty is a real answer, not an error. */ + eligibleIds: string[]; + /** True when the ZIP actually narrowed (or could have narrowed) the set. */ + applied: boolean; + /** Set iff `applied` is false. Never both null and unapplied. */ + reason: EligibilitySkipReason | null; + /** + * True when the filter ran and excluded EVERYONE. Distinct from `applied + * && eligibleIds.length > 0`: the caller shows a different message for + * "we do not serve this area" than for "that time is taken". + */ + excludedEveryone: boolean; +} + +/** Normalize a stored prefix or a submitted property ZIP the same way. */ +export function normalizeZip(raw: string | null | undefined): string { + return (raw ?? '').trim().toUpperCase().replace(/\s+/g, ''); +} + +/** + * v1 match: PREFIX. '787' covers 787xx; '78701' covers only itself. A stored + * prefix longer than the property ZIP never matches — '78701' does not serve + * '787', because the shorter string is the less specific claim and treating it + * as a match would quietly widen every territory. + */ +export function zipMatchesPrefix(propertyZip: string, prefix: string): boolean { + const p = normalizeZip(propertyZip); + const q = normalizeZip(prefix); + if (p === '' || q === '') return false; + return p.startsWith(q); +} + +/** + * @param qualifiedIds Candidates that survived service qualification. + * @param propertyZip The property's ZIP, or null when the booking has none. + * @param areasByUser userId -> declared prefixes. A user ABSENT from this map + * has declared nothing and therefore serves everywhere — + * the same "empty means all" convention as + * `service_inspectors`. Callers must pass a map built from + * rows, never one pre-filled with empty arrays, or that + * convention silently inverts. + */ +export function filterEligibleInspectors( + qualifiedIds: string[], + propertyZip: string | null, + areasByUser: Map, +): EligibilityOutcome { + const zip = normalizeZip(propertyZip); + if (zip === '') { + return { + eligibleIds: qualifiedIds, + applied: false, + reason: 'property_zip_unknown', + excludedEveryone: false, + }; + } + // Nobody has drawn a territory. Filtering on an empty rulebook would be a + // no-op that reads like a decision; say so instead. + const anyAreas = [...areasByUser.values()].some((list) => list.length > 0); + if (!anyAreas) { + return { + eligibleIds: qualifiedIds, + applied: false, + reason: 'no_service_areas_configured', + excludedEveryone: false, + }; + } + + const eligibleIds = qualifiedIds.filter((id) => { + const prefixes = areasByUser.get(id) ?? []; + if (prefixes.length === 0) return true; // declared nothing = serves everywhere + return prefixes.some((prefix) => zipMatchesPrefix(zip, prefix)); + }); + + return { + eligibleIds, + applied: true, + reason: null, + excludedEveryone: qualifiedIds.length > 0 && eligibleIds.length === 0, + }; +} + +/** + * Declared territories for the given candidates, keyed by user id. + * + * Users with no rows are ABSENT from the map, not present with an empty array + * — `filterEligibleInspectors` reads absence as "serves everywhere" and the + * two encodings must not drift apart. Same loader-beside-rules shape as + * `loadSlotGridOptions` in slot-rules.ts. + */ +export async function loadServiceAreasByUser( + d1: D1Database, + tenantId: string, + userIds: string[], +): Promise> { + const byUser = new Map(); + if (userIds.length === 0) return byUser; + const db = drizzle(d1); + // D1 binds 100 parameters per statement; the tenant + a chunk of ids fits. + const CHUNK = 90; + for (let i = 0; i < userIds.length; i += CHUNK) { + const rows = await db.select({ + userId: inspectorServiceAreas.userId, + zipPrefix: inspectorServiceAreas.zipPrefix, + }).from(inspectorServiceAreas) + .where(and( + eq(inspectorServiceAreas.tenantId, tenantId), + inArray(inspectorServiceAreas.userId, userIds.slice(i, i + CHUNK)), + )).all(); + for (const row of rows) { + const list = byUser.get(row.userId) ?? []; + list.push(row.zipPrefix); + byUser.set(row.userId, list); + } + } + return byUser; +} diff --git a/server/lib/booking/routing.ts b/server/lib/booking/routing.ts new file mode 100644 index 000000000..a09159bc7 --- /dev/null +++ b/server/lib/booking/routing.ts @@ -0,0 +1,245 @@ +import { epochMsToWallClockYmd } from '../tz'; + +/** + * Which qualified, free inspector gets an auto-assigned booking. + * + * THE INVARIANT: a strategy that cannot be computed is REPORTED, never quietly + * replaced. Both non-default strategies here have a degenerate input on which + * they collapse into `first_available` while still returning a perfectly + * plausible inspector id: + * + * least_loaded every candidate's week load is 0, so every comparison is a + * tie and the tiebreak (name) IS first_available. + * closest the property or the candidates have no coordinates, so + * every distance is undefined and the tiebreak decides again. + * + * Both were live risks, not hypotheticals: `inspections.scheduled_start_ms` has + * zero non-NULL rows in production (which is why load is counted off + * `inspections.date`), and no booking carried a geocode until the public form + * started capturing one. So the result type is a DECISION, not an id: it names + * what was requested, what was applied, and why they differ. Callers log the + * difference and stamp it on the fulfillment audit record. + */ +export type RoutingStrategy = 'first_available' | 'least_loaded' | 'closest'; + +export const ROUTING_STRATEGIES: readonly RoutingStrategy[] = [ + 'first_available', + 'least_loaded', + 'closest', +] as const; + +export function isRoutingStrategy(raw: unknown): raw is RoutingStrategy { + return typeof raw === 'string' && (ROUTING_STRATEGIES as readonly string[]).includes(raw); +} + +/** Why the requested strategy was not the one applied. */ +export type RoutingFallbackReason = + /** `closest`: the property has no lat/lng, so no distance exists. */ + | 'property_ungeocoded' + /** `closest`: fewer than two candidates have a service origin to measure from. */ + | 'no_anchored_candidate' + /** `least_loaded`: no candidate has any dated work in the slot's ISO week. */ + | 'no_dated_work' + /** Any strategy: one candidate, so no strategy could have chosen differently. */ + | 'single_candidate'; + +export interface RoutingDecision { + inspectorId: string | null; + requested: RoutingStrategy; + /** Always `first_available` when `reason` is set. */ + applied: RoutingStrategy; + reason: RoutingFallbackReason | null; + /** How many candidates the strategy could choose between. */ + candidateCount: number; +} + +export interface RoutingCandidate { + id: string; + name: string | null; + /** + * Where this inspector's drive starts: their own service origin, else the + * company coordinates, else null. NULL is NOT a distance and NOT a + * far-away sort position — it removes the candidate from `closest` + * entirely (see `closest` below). + */ + origin: { lat: number; lng: number } | null; + /** Non-cancelled inspections dated inside the slot's ISO week. */ + weekLoad: number; +} + +export interface RoutingInput { + strategy: RoutingStrategy; + candidates: RoutingCandidate[]; + /** The property's coordinates, when it has been geocoded. */ + property: { lat: number; lng: number } | null; +} + +/** Stable order: name, then id. This IS `first_available`, and every tiebreak. */ +function byNameThenId(a: RoutingCandidate, b: RoutingCandidate): number { + return (a.name ?? '').localeCompare(b.name ?? '') || a.id.localeCompare(b.id); +} + +/** Great-circle distance in kilometres. Only ever called with two real points. */ +export function haversineKm( + a: { lat: number; lng: number }, + b: { lat: number; lng: number }, +): number { + const R = 6371; + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(b.lat - a.lat); + const dLng = toRad(b.lng - a.lng); + const lat1 = toRad(a.lat); + const lat2 = toRad(b.lat); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); +} + +function firstAvailable(candidates: RoutingCandidate[]): string | null { + return [...candidates].sort(byNameThenId)[0]?.id ?? null; +} + +function substituted( + requested: RoutingStrategy, + reason: RoutingFallbackReason, + candidates: RoutingCandidate[], +): RoutingDecision { + return { + inspectorId: firstAvailable(candidates), + requested, + applied: 'first_available', + reason, + candidateCount: candidates.length, + }; +} + +/** + * Choose. Pure — every DB read the strategies need is already in `input`, + * which is what makes the degenerate cases testable without a database. + */ +export function pickInspectorByStrategy(input: RoutingInput): RoutingDecision { + const { strategy, candidates, property } = input; + if (candidates.length === 0) { + return { + inspectorId: null, + requested: strategy, + applied: strategy, + reason: null, + candidateCount: 0, + }; + } + + if (strategy === 'first_available') { + return { + inspectorId: firstAvailable(candidates), + requested: strategy, + applied: strategy, + reason: null, + candidateCount: candidates.length, + }; + } + + // One candidate: the strategy did not choose, arithmetic did not happen, + // and reporting it as `least_loaded` would be a claim nobody verified. + if (candidates.length === 1) { + return substituted(strategy, 'single_candidate', candidates); + } + + if (strategy === 'least_loaded') { + // The whole point of the strategy is that loads DIFFER. All-zero is + // not "everyone is equally free"; it is "we have no load signal", and + // the tiebreak below would silently be first_available. + if (candidates.every((c) => c.weekLoad === 0)) { + return substituted(strategy, 'no_dated_work', candidates); + } + const sorted = [...candidates].sort( + (a, b) => a.weekLoad - b.weekLoad || byNameThenId(a, b), + ); + return { + inspectorId: sorted[0]!.id, + requested: strategy, + applied: strategy, + reason: null, + candidateCount: candidates.length, + }; + } + + // closest — a missing geocode is never a distance. + if (!property) { + return substituted(strategy, 'property_ungeocoded', candidates); + } + const anchored = candidates.filter((c) => c.origin !== null); + if (anchored.length < 2) { + return substituted(strategy, 'no_anchored_candidate', candidates); + } + const sorted = [...anchored].sort( + (a, b) => + haversineKm(property, a.origin!) - haversineKm(property, b.origin!) || + byNameThenId(a, b), + ); + return { + inspectorId: sorted[0]!.id, + requested: strategy, + applied: strategy, + reason: null, + candidateCount: candidates.length, + }; +} + +/** + * The civil date a stored `inspections.date` denotes, in the tenant's zone. + * + * The column holds two shapes: a bare `YYYY-MM-DD` (wizard-created, calendar + * semantic) and a full ISO instant (`fulfillBooking` writes `${date}T${hh}:${mm}:00Z`). + * They must be read differently — parsing the bare form as UTC midnight and + * converting it into a negative-offset zone moves the inspection to the + * PREVIOUS day, which is the calendar off-by-one this codebase already has a + * lint gate for. A civil date has no zone to convert from, so it is taken as + * written; an instant goes through the tenant zone. + */ +export function inspectionCivilDate(stored: string, tenantTz: string): string | null { + const raw = String(stored ?? ''); + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; + const ms = Date.parse(raw); + if (Number.isNaN(ms)) return null; + return epochMsToWallClockYmd(ms, tenantTz); +} + +/** + * ISO-8601 week key (`GGGG-Www`) for a civil `YYYY-MM-DD`. Weeks start Monday + * and belong to the year containing their Thursday, so a booking on 1 January + * lands in the same bucket as the December work beside it. + */ +export function isoWeekKey(civilYmd: string): string { + const [y, m, d] = civilYmd.split('-').map(Number); + // UTC arithmetic on a civil date is pure calendar geometry here — the value + // never becomes an instant anyone displays, so no zone is involved. + const date = new Date(Date.UTC(y!, (m ?? 1) - 1, d ?? 1)); + const day = date.getUTCDay() || 7; // Sunday (0) is day 7 of the previous week + date.setUTCDate(date.getUTCDate() + 4 - day); // move to this week's Thursday + const isoYear = date.getUTCFullYear(); + const jan1 = Date.UTC(isoYear, 0, 1); + const week = Math.ceil(((date.getTime() - jan1) / 86400000 + 1) / 7); + return `${isoYear}-W${String(week).padStart(2, '0')}`; +} + +/** + * Monday..Sunday civil dates of the ISO week containing `civilYmd`, widened by + * one day on each side. + * + * The padding is not sloppiness — it is the only correct way to pre-filter in + * SQL. `inspections.date` mixes UTC instants with civil dates, so a row whose + * TENANT-local date is Monday can be stored as a Sunday-evening instant. The + * SQL window over-collects by a day; `isoWeekKey(inspectionCivilDate(...))` + * then decides membership exactly. Narrowing the SQL to the exact week would + * silently drop the boundary jobs and understate somebody's load. + */ +export function isoWeekWindow(civilYmd: string): { fromYmd: string; toYmd: string } { + const [y, m, d] = civilYmd.split('-').map(Number); + const base = Date.UTC(y!, (m ?? 1) - 1, d ?? 1); + const day = new Date(base).getUTCDay() || 7; + const monday = base - (day - 1) * 86400000; + const ymd = (ms: number) => new Date(ms).toISOString().slice(0, 10); // tz-lint-ok: pure calendar geometry on a UTC-constructed civil date, never an instant + return { fromYmd: ymd(monday - 86400000), toYmd: ymd(monday + 7 * 86400000) }; +} diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 5531219fd..7a8d1e6ff 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -8978,6 +8978,19 @@ "minLength": 1, "description": "Restrict slots to a single inspector (client choice / deep link)." } + }, + { + "name": "propertyZip", + "in": "query", + "required": false, + "description": "Property ZIP. Restricts the union to inspectors whose service area covers it; omitted means the geographic filter cannot run.", + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 10, + "example": "78701", + "description": "Property ZIP. Restricts the union to inspectors whose service area covers it; omitted means the geographic filter cannot run." + } } ], "body": null @@ -9488,6 +9501,22 @@ "summary": "List referrals for the signed-in agent", "description": "Lists the signed-in agent's referred inspections across every tenant they have an active agent_tenant_link with, newest first, for the agent-portal dashboard." }, + { + "operationId": "listAllInspectorServiceAreas", + "method": "GET", + "pathTemplate": "/api/admin/service-areas/all", + "scopes": [ + "admin" + ], + "tag": "admin", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": null + }, + "summary": "List every declared inspector territory in the tenant", + "description": "Returns every inspector that has declared at least one ZIP prefix. Inspectors absent from the list serve every area." + }, { "operationId": "listAllTenantMcpGrants", "method": "GET", @@ -10908,6 +10937,35 @@ "summary": "List the signed-in inspector's credentials", "description": "Lists the signed-in inspector self-asserted credentials (label, member number, badge image)." }, + { + "operationId": "listInspectorServiceAreas", + "method": "GET", + "pathTemplate": "/api/admin/service-areas", + "scopes": [ + "admin" + ], + "tag": "admin", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "userId", + "in": "query", + "required": true, + "description": "The inspector whose service areas are being read.", + "schema": { + "type": "string", + "minLength": 1, + "example": "550e8400-e29b-41d4-a716-446655440000", + "description": "The inspector whose service areas are being read." + } + } + ], + "body": null + }, + "summary": "List the ZIP prefixes one inspector serves", + "description": "Returns the ZIP prefixes this inspector will travel to. An empty list means they serve every area." + }, { "operationId": "listIntegrationStatus", "method": "GET", @@ -16307,6 +16365,24 @@ "summary": "Replace inspection request for current tenant", "description": "Auto-generated placeholder for replaceInspectionRequest (PUT /{id}, inspections domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "replaceInspectorServiceAreas", + "method": "PUT", + "pathTemplate": "/api/admin/service-areas", + "scopes": [ + "admin" + ], + "tag": "admin", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "$ref": "#/components/schemas/ReplaceServiceAreas" + } + }, + "summary": "Replace one inspector ZIP list", + "description": "Replaces the inspector ZIP list wholesale. Sending an empty array clears the territory, which means they serve every area again." + }, { "operationId": "replaceMarketplace", "method": "POST", diff --git a/server/lib/places/geocode.ts b/server/lib/places/geocode.ts new file mode 100644 index 000000000..d9188e9f6 --- /dev/null +++ b/server/lib/places/geocode.ts @@ -0,0 +1,140 @@ +import { logger } from '../logger'; + +/** + * Server-side Google Places resolution, shared by everything that needs + * coordinates rather than a string. + * + * This used to exist ONLY inside the `/api/places/details` route handler, and + * that is the whole reason the booking pipeline had no geocode: the capability + * was built, it was correct, and nothing outside one JWT-gated HTTP route + * could reach it. Three callers now do — the details route itself, public + * booking fulfilment (placeId -> the property's coordinates), and Settings + * (company address text -> the workspace's coordinates). + * + * Every function here is FAIL-SOFT and returns null on any failure. A geocode + * is an enrichment: a booking that cannot be located must still be a booking. + * The callers turn a null into a NAMED, logged outcome rather than into a + * zeroed coordinate. + */ +const GOOGLE_BASE = 'https://maps.googleapis.com/maps/api/place'; + +export interface ResolvedPlace { + placeId: string; + formatted: string; + street: string | null; + city: string | null; + state: string | null; + zip: string | null; + county: string | null; + lat: number; + lng: number; +} + +interface GoogleDetailsResult { + place_id: string; + formatted_address: string; + address_components: Array<{ long_name: string; short_name: string; types: string[] }>; + geometry: { location: { lat: number; lng: number } }; +} + +/** Shape the Google details payload into our stored fields. */ +export function toResolvedPlace(r: GoogleDetailsResult): ResolvedPlace { + const partOf = (type: string, useShort = false): string | null => { + const c = r.address_components.find(x => x.types.includes(type)); + return c ? (useShort ? c.short_name : c.long_name) : null; + }; + const streetNumber = partOf('street_number'); + const route = partOf('route'); + return { + placeId: r.place_id, + formatted: r.formatted_address, + street: streetNumber && route ? `${streetNumber} ${route}` : (route || null), + city: partOf('locality') || partOf('sublocality') || partOf('administrative_area_level_3'), + state: partOf('administrative_area_level_1', true), + zip: partOf('postal_code'), + county: partOf('administrative_area_level_2'), + lat: r.geometry.location.lat, + lng: r.geometry.location.lng, + }; +} + +/** Details cache key. Shared with the route so both sides hit the same entry. */ +export function placeDetailsCacheKey(placeId: string): string { + return `places:detail:${placeId}`; +} + +/** + * Resolve one placeId to a full structured address. + * + * @param session Google session token. Optional: the booking/settings callers + * have no typing session to bill against and pass nothing, + * which Google treats as a standalone Details call. + * @throws never — returns null and logs. + */ +export async function fetchPlaceDetails( + apiKey: string, + placeId: string, + session?: string, +): Promise { + try { + const url = new URL(`${GOOGLE_BASE}/details/json`); + url.searchParams.set('place_id', placeId); + if (session) url.searchParams.set('sessiontoken', session); + // Tight field mask — billed per-field-per-call. + url.searchParams.set('fields', 'place_id,formatted_address,address_components,geometry/location'); + url.searchParams.set('key', apiKey); + + const res = await fetch(url.toString()); + if (!res.ok) { + logger.warn('[places.geocode] details upstream error', { status: res.status }); + return null; + } + const data = await res.json() as { status: string; result?: GoogleDetailsResult }; + if (data.status !== 'OK' || !data.result) { + logger.warn('[places.geocode] details upstream status', { status: data.status }); + return null; + } + return toResolvedPlace(data.result); + } catch (e) { + logger.error('[places.geocode] details exception', {}, e instanceof Error ? e : undefined); + return null; + } +} + +/** + * Resolve free-text address to coordinates: autocomplete for a placeId, then + * details for the geometry. Used for the company address, which a workspace + * types as prose in Settings and has done for as long as the field existed. + * + * The FIRST prediction is taken. That is a real limitation and it is stated in + * the UI rather than hidden: Settings shows the formatted address that was + * resolved, so an owner can see when Google picked the wrong "Main St". + */ +export async function geocodeAddressText( + apiKey: string, + text: string, +): Promise { + const q = text.trim(); + if (q.length < 5) return null; + try { + const url = new URL(`${GOOGLE_BASE}/autocomplete/json`); + url.searchParams.set('input', q); + url.searchParams.set('types', 'address'); + url.searchParams.set('key', apiKey); + const res = await fetch(url.toString()); + if (!res.ok) { + logger.warn('[places.geocode] autocomplete upstream error', { status: res.status }); + return null; + } + const j = await res.json() as { status: string; predictions?: Array<{ place_id: string }> }; + const placeId = j.predictions?.[0]?.place_id; + if (!placeId) { + logger.info('[places.geocode] no prediction for address text', { status: j.status }); + return null; + } + return await fetchPlaceDetails(apiKey, placeId); + } catch (e) { + logger.error('[places.geocode] autocomplete exception', {}, e instanceof Error ? e : undefined); + return null; + } +} diff --git a/server/lib/validations/booking.schema.ts b/server/lib/validations/booking.schema.ts index 0f412fe4c..4e3ce2218 100644 --- a/server/lib/validations/booking.schema.ts +++ b/server/lib/validations/booking.schema.ts @@ -16,6 +16,20 @@ export const PublicBookingSchema = z.object({ // GET /book/:tenant/:slug page data. tenant: z.string().min(1, 'Tenant is required').openapi({ example: 'acme-inspections' }).describe('Tenant slug from the booking page URL; resolved server-side to the tenant id.'), address: z.string().min(5, 'Address is too short').openapi({ example: '123 Main St, City, ST 12345' }).describe('TODO describe address field for the OpenInspection MCP integration'), + // The structured half of the address, present when the visitor picked a + // suggestion from the public autocomplete instead of typing free text. + // + // Both stay OPTIONAL and neither is trusted as the last word: `addressZip` + // is the lenient client-side parse of Google's secondary text, and the + // server re-resolves `addressPlaceId` through Places Details to get the + // authoritative ZIP and the coordinates. A booking typed by hand — or made + // on a deployment with no Places key — still submits, and simply has no + // geocode. That absence is reported by the routing decision rather than + // being invented as (0,0). + addressZip: z.string().trim().min(3).max(10).optional().openapi({ example: '78701' }) + .describe('ZIP hint from the selected autocomplete suggestion. Re-resolved server-side when a placeId is supplied.'), + addressPlaceId: z.string().trim().min(8).max(200).optional().openapi({ example: 'ChIJxxx' }) + .describe('Google place id of the selected suggestion. Resolved server-side to the property coordinates used by `closest` routing.'), clientName: z.string().min(1, 'Client name is required').openapi({ example: 'John Doe' }).describe('TODO describe clientName field for the OpenInspection MCP integration'), clientEmail: z.string().email('Invalid email address').openapi({ example: 'john@example.com' }).describe('TODO describe clientEmail field for the OpenInspection MCP integration'), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format (YYYY-MM-DD)').openapi({ example: '2024-04-15' }).describe('TODO describe date field for the OpenInspection MCP integration'), diff --git a/server/lib/validations/service-area.schema.ts b/server/lib/validations/service-area.schema.ts new file mode 100644 index 000000000..99545c370 --- /dev/null +++ b/server/lib/validations/service-area.schema.ts @@ -0,0 +1,42 @@ +import { z } from '@hono/zod-openapi'; + +/** + * Inspector service areas — the ZIP list an admin manages per inspector. + * + * `zipPrefixes` is a REPLACEMENT list, not a delta: the API deletes the + * inspector's rows and reinserts. An empty array is therefore meaningful and + * accepted — it means "serves everywhere", the same state as never having + * configured anything. + */ +export const ServiceAreaQuerySchema = z.object({ + userId: z.string().trim().min(1).openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }) + .describe('The inspector whose service areas are being read.'), +}).describe('Identifies the inspector whose ZIP list is requested.'); + +export const ReplaceServiceAreasSchema = z.object({ + userId: z.string().trim().min(1).openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }) + .describe('The inspector whose ZIP list is being replaced. Must belong to the caller tenant.'), + zipPrefixes: z.array( + // 3-10 chars covers a US 3-digit prefix, a full 5-digit ZIP, ZIP+4, and + // a Canadian FSA. Validated as alphanumeric rather than digits-only so + // a non-US deployment is not locked out by the shape of its postcodes. + z.string().trim().toUpperCase().min(3).max(10).regex(/^[A-Z0-9]+$/, 'Use letters and digits only'), + ).max(500).openapi({ example: ['78701', '787'] }) + .describe('Full replacement list of ZIP prefixes. Empty array = serves everywhere.'), +}).openapi('ReplaceServiceAreas'); + +export const ServiceAreaListResponseSchema = z.object({ + success: z.literal(true).describe('Always true on success.'), + data: z.object({ + userId: z.string().describe('The inspector these areas belong to.'), + zipPrefixes: z.array(z.string()).describe('Declared ZIP prefixes, sorted. Empty = serves everywhere.'), + }).describe('One inspector service-area list.'), +}).openapi('ServiceAreaListResponse'); + +export const ServiceAreaMapResponseSchema = z.object({ + success: z.literal(true).describe('Always true on success.'), + data: z.array(z.object({ + userId: z.string().describe('Inspector id.'), + zipPrefixes: z.array(z.string()).describe('Declared ZIP prefixes, sorted.'), + })).describe('Every inspector in the tenant that has declared at least one area.'), +}).openapi('ServiceAreaMapResponse'); diff --git a/server/services/booking.service.ts b/server/services/booking.service.ts index 48317e7ad..aaff92efa 100644 --- a/server/services/booking.service.ts +++ b/server/services/booking.service.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono'; import { drizzle } from 'drizzle-orm/d1'; import { eq, and, gte, lte, sql, inArray, isNull, ne } from 'drizzle-orm'; -import { availability, availabilityOverrides, calendarBlocks, inspections, inspectionInspectors, inspectionRequests, serviceInspectors, users } from '../lib/db/schema'; +import { availability, availabilityOverrides, calendarBlocks, inspections, inspectionInspectors, serviceInspectors, users } from '../lib/db/schema'; import { logger } from '../lib/logger'; import type { HonoConfig } from '../types/hono'; import type { PublicBookingSchema } from '../lib/validations/booking.schema'; @@ -12,6 +12,17 @@ import { computeBusyTimes } from '../lib/booking/busy-times'; import { buildTenantSlotMap } from '../lib/booking/tenant-slot-map'; import { resolvePublicHolidayEffect } from '../lib/holidays/load-tenant-holidays'; import { fulfillBooking as runFulfillBooking } from './booking/fulfill-booking'; +import { + arbitrateSlotRace as runArbitrateSlotRace, + revokeBooking as runRevokeBooking, +} from './booking/slot-arbitration'; +import { + routeInspector as runRouteInspector, + type RouteInspectorOptions, +} from './booking/route-inspector'; +import type { RoutingDecision } from '../lib/booking/routing'; +import { filterEligibleInspectors, loadServiceAreasByUser, type EligibilitySkipReason } from '../lib/booking/eligibility'; +import { applyBookingRules, loadBookingRules } from '../lib/booking/booking-rules'; import type { PlanQuotaGuard } from '../features/plan-quota/guard'; /** * Service to handle public booking flow and availability lookups. @@ -170,16 +181,29 @@ export class BookingService { * override that date, and (c) has no inspection at that time (via the * inspection_inspectors link table, so helper assignments count as busy * too). Storage stays per-inspector; this only changes the query face. + * Geographic eligibility runs BEFORE the union (an inspector who will not + * travel to this ZIP should never contribute a slot), and the tenant + * booking rules run AFTER it (they mark computed slots unbookable). Both + * report why they did nothing — `geoSkipped` / `rulesActive` are on the + * return value precisely so "the filter degraded gracefully" can never + * again be indistinguishable from "the filter ran". + * * @param qualifiedIds Optional precomputed result of getQualifiedInspectorIds to avoid duplicate lookups. + * @param propertyZip The property's ZIP when the booking carries one. */ async getTenantSlots( tenantId: string, dateStr: string, serviceIds: string[], qualifiedIds?: string[], + propertyZip?: string | null, ): Promise<{ slots: Array<{ time: string; available: boolean; inspectorIds: string[] }>; holidayAdvisory?: { date: string; name: string }; + /** Set when the ZIP filter could NOT run; null when it did. */ + geoSkipped?: EligibilitySkipReason | null; + /** True when the ZIP filter ran and left nobody serving this area. */ + outsideServiceArea?: boolean; }> { const holiday = await resolvePublicHolidayEffect(this.db, tenantId, dateStr); if (holiday.effect === 'block') { @@ -188,25 +212,35 @@ export class BookingService { const db = this.getDrizzle(); const qualified = qualifiedIds ?? await this.getQualifiedInspectorIds(tenantId, serviceIds); - if (qualified.length === 0) { - return { - slots: [], - ...(holiday.effect === 'advisory' && holiday.name - ? { holidayAdvisory: { date: dateStr, name: holiday.name } } - : {}), - }; + const advisory = holiday.effect === 'advisory' && holiday.name + ? { holidayAdvisory: { date: dateStr, name: holiday.name } } + : {}; + if (qualified.length === 0) return { slots: [], ...advisory }; + + const geo = filterEligibleInspectors( + qualified, + propertyZip ?? null, + await loadServiceAreasByUser(this.db, tenantId, qualified), + ); + if (geo.reason) { + logger.info('booking.eligibility.not-applied', { tenantId, reason: geo.reason }); + } + if (geo.excludedEveryone) { + return { slots: [], ...advisory, geoSkipped: null, outsideServiceArea: true }; } + const eligible = geo.eligibleIds; + const reported = { geoSkipped: geo.reason, outsideServiceArea: false }; const dayOfWeek = new Date(dateStr + 'T00:00:00').getDay(); const [windows, overrides, busy, blocks] = await Promise.all([ db.select().from(availability).where(and( eq(availability.tenantId, tenantId), - inArray(availability.inspectorId, qualified), + inArray(availability.inspectorId, eligible), eq(availability.dayOfWeek, dayOfWeek), )).all(), db.select().from(availabilityOverrides).where(and( eq(availabilityOverrides.tenantId, tenantId), - inArray(availabilityOverrides.inspectorId, qualified), + inArray(availabilityOverrides.inspectorId, eligible), eq(availabilityOverrides.date, dateStr), )).all(), db.select({ userId: inspectionInspectors.userId, date: inspections.date }) @@ -214,30 +248,39 @@ export class BookingService { .innerJoin(inspections, eq(inspections.id, inspectionInspectors.inspectionId)) .where(and( eq(inspectionInspectors.tenantId, tenantId), - inArray(inspectionInspectors.userId, qualified), + inArray(inspectionInspectors.userId, eligible), sql`date(${inspections.date}) = ${dateStr}`, sql`${inspections.status} not in ('cancelled')`, )).all(), db.select().from(calendarBlocks).where(and( eq(calendarBlocks.tenantId, tenantId), - inArray(calendarBlocks.userId, qualified), + inArray(calendarBlocks.userId, eligible), eq(calendarBlocks.date, dateStr), )).all(), ]); const gridOpts = await loadSlotGridOptions(this.db, tenantId); - const slotMap = buildTenantSlotMap(qualified, windows, overrides, busy, blocks, gridOpts); + const slotMap = buildTenantSlotMap(eligible, windows, overrides, busy, blocks, gridOpts); + const rules = await loadBookingRules(this.db, tenantId); const slots = [...slotMap.entries()] .sort(([a], [b]) => (a < b ? -1 : 1)) - .map(([time, ids]) => ({ time, available: ids.size > 0, inspectorIds: [...ids].sort() })); + .map(([time, ids]) => { + // Lead time and same-day cutoff mark an otherwise-free slot + // unbookable. Applied here rather than inside the grid builder + // so the reason stays a property of the tenant's POLICY, not of + // anyone's calendar. + const blocked = rules.inactive ? false : !applyBookingRules({ + ...rules, civilDate: dateStr, slotTime: time, nowMs: Date.now(), + }).allowed; + return { + time, + available: !blocked && ids.size > 0, + inspectorIds: blocked ? [] : [...ids].sort(), + }; + }); - return { - slots, - ...(holiday.effect === 'advisory' && holiday.name - ? { holidayAdvisory: { date: dateStr, name: holiday.name } } - : {}), - }; + return { slots, ...advisory, ...reported }; } /** @@ -255,19 +298,20 @@ export class BookingService { } /** - * B-28 — post-insert TOCTOU arbitration. The slot read and the inspection - * insert in POST /book are not atomic (D1 has no row locks), so two - * concurrent submits can both pass the advisory check and double-book the - * same inspector. Instead of preventing the race we resolve it after the - * fact: every racer calls this AFTER its own insert and BEFORE any side - * effect (emails, calendar). All racers see the same conflicting rows and - * apply the same deterministic order — sort by (createdAt, id) — so the - * earliest booking wins and every later racer self-compensates - * (revokeBooking + 409). Exactly one winner, no coordination needed. - * - * Busy semantics mirror getTenantSlots: link-table join, non-cancelled, - * HH:MM read from the ISO datetime at slice(11,16). + * Strategy-aware auto-assignment. Returns the DECISION, not just an id: + * `least_loaded` and `closest` can be inapplicable to a request, and the + * substitution has to be visible to the caller so it reaches the audit + * record. See `./booking/route-inspector`. */ + async routeInspector( + tenantId: string, + freeIds: string[], + opts: RouteInspectorOptions, + ): Promise { + return runRouteInspector(this.db, tenantId, freeIds, opts); + } + + /** B-28 post-insert TOCTOU arbitration — see `./booking/slot-arbitration`. */ async arbitrateSlotRace( tenantId: string, inspectorId: string, @@ -275,65 +319,12 @@ export class BookingService { time: string, myRequestId: string, ): Promise<'win' | 'lose'> { - const db = this.getDrizzle(); - const rows = await db.select({ - inspectionId: inspectionInspectors.inspectionId, - requestId: inspections.requestId, - date: inspections.date, - createdAt: inspections.createdAt, - }) - .from(inspectionInspectors) - .innerJoin(inspections, eq(inspections.id, inspectionInspectors.inspectionId)) - .where(and( - eq(inspectionInspectors.tenantId, tenantId), - eq(inspectionInspectors.userId, inspectorId), - sql`date(${inspections.date}) = ${dateStr}`, - sql`${inspections.status} not in ('cancelled')`, - )).all(); - - const atSlot = rows.filter(r => String(r.date).slice(11, 16) === time); - const mine = atSlot.filter(r => r.requestId === myRequestId); - const others = atSlot.filter(r => r.requestId !== myRequestId); - // No competitor — or our rows are not visible (nothing to arbitrate). - if (mine.length === 0 || others.length === 0) return 'win'; - - type Key = [number, string]; - const key = (r: typeof rows[number]): Key => [ - r.createdAt instanceof Date ? r.createdAt.getTime() : Number(r.createdAt ?? 0), - r.inspectionId, - ]; - const cmp = (a: Key, b: Key) => a[0] - b[0] || (Number(a[1] > b[1]) - Number(a[1] < b[1])); - const myKey = mine.map(key).sort(cmp)[0]!; - const otherKey = others.map(key).sort(cmp)[0]!; - return cmp(otherKey, myKey) < 0 ? 'lose' : 'win'; + return runArbitrateSlotRace(this.db, tenantId, inspectorId, dateStr, time, myRequestId); } - /** - * B-28 compensation — fully retract a booking this request just created: - * link rows, inspections, then the request row. Only ever called on rows - * the caller inserted milliseconds ago (the client got a 409, never a - * confirmation), so hard delete is correct — no cancelled tombstones. - */ + /** B-28 compensation — see `./booking/slot-arbitration`. */ async revokeBooking(tenantId: string, requestId: string): Promise { - const db = this.getDrizzle(); - const rows = await db.select({ id: inspections.id }).from(inspections) - .where(and(eq(inspections.tenantId, tenantId), eq(inspections.requestId, requestId))) - .all(); - const ids = rows.map(r => r.id); - if (ids.length > 0) { - await db.delete(inspectionInspectors).where(and( - eq(inspectionInspectors.tenantId, tenantId), - inArray(inspectionInspectors.inspectionId, ids), - )); - await db.delete(inspections).where(and( - eq(inspections.tenantId, tenantId), - inArray(inspections.id, ids), - )); - } - await db.delete(inspectionRequests).where(and( - eq(inspectionRequests.tenantId, tenantId), - eq(inspectionRequests.id, requestId), - )); + return runRevokeBooking(this.db, tenantId, requestId); } /** diff --git a/server/services/booking/booking-admission.ts b/server/services/booking/booking-admission.ts index 8a80f38b6..08f848d6a 100644 --- a/server/services/booking/booking-admission.ts +++ b/server/services/booking/booking-admission.ts @@ -7,6 +7,8 @@ import { resolvePublicHolidayEffect } from '../../lib/holidays/load-tenant-holid import type { HonoConfig } from '../../types/hono'; import type { PublicBookingSchema } from '../../lib/validations/booking.schema'; import type { z } from '@hono/zod-openapi'; +import { fetchPlaceDetails, placeDetailsCacheKey, type ResolvedPlace } from '../../lib/places/geocode'; +import type { RoutingDecision } from '../../lib/booking/routing'; /** What survives admission: a slot claimed for a named, tenant-owned inspector. */ export interface BookingClaim { @@ -15,6 +17,43 @@ export interface BookingClaim { /** Carried through for the widget success/error telemetry the caller emits. */ isWidgetSubmit: boolean; originHeader: string | undefined; + /** + * The property's resolved location, or null when the booking carried no + * placeId / the lookup failed. Written onto the inspection row by the + * caller and fed to `closest` routing here. + */ + place: ResolvedPlace | null; + /** + * How the inspector was chosen — including a substitution and its reason. + * Null only when the client named an inspector, in which case no strategy + * ran and there is nothing to report. + */ + routing: RoutingDecision | null; +} + +/** + * Resolve the selected address to coordinates. Fail-soft by contract: a + * booking whose geocode fails is still a booking, and the consequence (no + * `closest` routing for it) is reported by the routing decision rather than + * guessed at. + */ +async function resolveProperty( + c: Context, + placeId: string | undefined, +): Promise { + if (!placeId) return null; + const apiKey = c.env.GOOGLE_PLACES_API_KEY; + if (!apiKey) return null; + const cacheKey = placeDetailsCacheKey(placeId); + if (c.env.TENANT_CACHE) { + const cached = await c.env.TENANT_CACHE.get(cacheKey, 'json') as ResolvedPlace | null; + if (cached) return cached; + } + const resolved = await fetchPlaceDetails(apiKey, placeId); + if (resolved && c.env.TENANT_CACHE) { + await c.env.TENANT_CACHE.put(cacheKey, JSON.stringify(resolved), { expirationTtl: 60 * 24 * 60 * 60 }); + } + return resolved; } /** @@ -117,16 +156,31 @@ export async function admitBooking( // Accepted for launch traffic; a post-insert recheck/compensation is // tracked in the backlog. Do NOT "fix" by randomizing the pick — the // determinism is intentional (idempotent re-submits). - const { slots } = await service.getTenantSlots(tenantId, body.date, serviceIdsForQual, qualifiedIds); + // The property's own location, resolved once: the ZIP narrows who is even + // offered a slot, and the coordinates are what `closest` measures to. + const place = await resolveProperty(c, body.addressPlaceId); + const propertyZip = place?.zip ?? body.addressZip ?? null; + + const { slots, outsideServiceArea } = await service.getTenantSlots( + tenantId, body.date, serviceIdsForQual, qualifiedIds, propertyZip, + ); + if (outsideServiceArea) { + throw Errors.Conflict('No inspector currently serves that area. Please contact the company directly to schedule.'); + } const target = slots.find(s => s.time === requestedTime); const freeIds = (target?.inspectorIds ?? []).filter(id => !inspectorId || id === inspectorId); if (freeIds.length === 0) { throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); } + let routing: RoutingDecision | null = null; if (!inspectorId) { - inspectorId = await service.pickInspector(tenantId, freeIds); + routing = await service.routeInspector(tenantId, freeIds, { + civilDate: body.date, + property: place ? { lat: place.lat, lng: place.lng } : null, + }); + inspectorId = routing.inspectorId; if (!inspectorId) throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); } - return { inspectorId, requestedTime, isWidgetSubmit, originHeader }; + return { inspectorId, requestedTime, isWidgetSubmit, originHeader, place, routing }; } diff --git a/server/services/booking/fulfill-booking.ts b/server/services/booking/fulfill-booking.ts index a7e3224a2..1b8ca87bd 100644 --- a/server/services/booking/fulfill-booking.ts +++ b/server/services/booking/fulfill-booking.ts @@ -5,6 +5,7 @@ import { inspections, inspectionRequests, tenantConfigs, services as servicesTab import { wallClockToEpochMs, resolveTenantTimeZone } from '../../lib/tz'; import { Errors } from '../../lib/errors'; import { logger } from '../../lib/logger'; +import { writeAuditLogWithSlug } from '../../lib/audit'; import { fireAutomation } from '../inspection/shared'; import { syncInspectionAssignments } from '../../lib/db/assignment-links'; import { INSPECTION_STATUS } from '../../lib/status/inspection-status'; @@ -52,7 +53,7 @@ export async function fulfillBooking( const service = c.var.services.booking; const db = drizzle(c.env.DB); - const { inspectorId, requestedTime, isWidgetSubmit, originHeader } = + const { inspectorId, requestedTime, isWidgetSubmit, originHeader, place, routing } = await admitBooking(c, db, deps.d1, tenantId, body); const resolvedAgentContactId = await resolveBookingAgentReferral(db, tenantId, body.agentRefSlug); @@ -124,6 +125,11 @@ export async function fulfillBooking( clientName: body.clientName, clientEmail: body.clientEmail, propertyAddress: body.address, + // The request row has carried property_zip since it was created and + // the public form never filled it. It does now. + propertyCity: place?.city ?? null, + propertyState: place?.state ?? null, + propertyZip: place?.zip ?? body.addressZip ?? null, scheduledAt: new Date(startIso), status: 'pending', totalAmount: 0, @@ -175,6 +181,65 @@ export async function fulfillBooking( throw Errors.Conflict('That time slot is no longer available. Please pick another time.'); } + // Stamp the resolved address on every inspection this booking created. + // + // These columns existed and were populated ONLY by the dashboard wizard; + // a public booking wrote free text and nothing else, which is why the + // geographic half of routing had no input and "no geocode" was 100% of + // traffic rather than an edge case. Written for both branches at once + // rather than inside the two inserts, since the multi-service path creates + // its rows through InspectionRequestService. + // + // Non-fatal, like the stamp below: the rows are committed and an + // enrichment failure must not 500 an anonymous booker. + if (place) { + try { + await db.update(inspections) + .set({ + addressPlaceId: place.placeId, + addressStreet: place.street, + addressCity: place.city, + addressState: place.state, + addressZip: place.zip, + addressCounty: place.county, + addressLat: place.lat, + addressLng: place.lng, + addressGeocodedAt: new Date(), + }) + .where(and( + inArray(inspections.id, allInspectionIds), + eq(inspections.tenantId, tenantId), + )); + } catch (e) { + logger.warn('booking.geocode.stamp.failed', { + inspectionIds: allInspectionIds, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + // What routing actually did, on the durable record. The decision is + // written whether or not it degraded — an audit row that appears only on + // failure teaches nobody what normal looks like — but a substitution + // carries the reason that explains why the workspace's chosen strategy is + // not the one that ran. `closest` on a workspace that never geocoded its + // address would otherwise be silently indistinguishable from working. + if (routing) { + c.executionCtx.waitUntil(writeAuditLogWithSlug(c.env.DB, { + tenantId, + action: 'booking.routing.applied', + entityType: 'inspection', + entityId: inspectionId, + metadata: { + requested: routing.requested, + applied: routing.applied, + reason: routing.reason, + candidateCount: routing.candidateCount, + inspectorId, + }, + })); + } + // A-polish 9b — stamp the precise scheduled instant on every inspection // this booking created. The wall-clock slot time is interpreted in the // TENANT tz (not the naive :00Z of the startIso busy-check key), so diff --git a/server/services/booking/route-inspector.ts b/server/services/booking/route-inspector.ts new file mode 100644 index 000000000..6e0355ecd --- /dev/null +++ b/server/services/booking/route-inspector.ts @@ -0,0 +1,146 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { inspections, inspectionInspectors, tenantConfigs, users } from '../../lib/db/schema'; +import { logger } from '../../lib/logger'; +import { resolveTenantTimeZone } from '../../lib/tz'; +import { + inspectionCivilDate, + isoWeekKey, + isoWeekWindow, + isRoutingStrategy, + pickInspectorByStrategy, + type RoutingCandidate, + type RoutingDecision, + type RoutingStrategy, +} from '../../lib/booking/routing'; + +export interface RouteInspectorOptions { + /** The slot's civil date (YYYY-MM-DD) — the ISO week `least_loaded` counts. */ + civilDate: string; + /** The property's coordinates when the booking carried a resolvable address. */ + property: { lat: number; lng: number } | null; + /** Override the tenant's configured strategy (tests, and future dispatch UI). */ + strategy?: RoutingStrategy; +} + +/** + * Read what the strategies need, then choose — and say what actually happened. + * + * Lives beside `booking-admission.ts` rather than inside `BookingService` + * because it is the same kind of thing: a decision with several DB reads + * behind it, whose value is the decision RECORD, not just the id. The service + * keeps a three-line delegating method so `c.var.services.booking` remains the + * single entry point callers know. + */ +export async function routeInspector( + d1: D1Database, + tenantId: string, + freeIds: string[], + opts: RouteInspectorOptions, +): Promise { + const db = drizzle(d1); + + const cfg = await db.select({ + strategy: tenantConfigs.bookingRoutingStrategy, + defaultTimezone: tenantConfigs.defaultTimezone, + companyLat: tenantConfigs.companyLat, + companyLng: tenantConfigs.companyLng, + }).from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + + const strategy: RoutingStrategy = + opts.strategy ?? (isRoutingStrategy(cfg?.strategy) ? cfg.strategy : 'first_available'); + + if (freeIds.length === 0) { + return { inspectorId: null, requested: strategy, applied: strategy, reason: null, candidateCount: 0 }; + } + + const staff = await db.select({ + id: users.id, + name: users.name, + lat: users.serviceOriginLat, + lng: users.serviceOriginLng, + }).from(users) + .where(and(eq(users.tenantId, tenantId), inArray(users.id, freeIds))).all(); + + // The company coordinates ARE the default service origin. That inheritance + // is what lets a single-office workspace use `closest` with no per-person + // setup — and it is also why `closest` degrades honestly rather than + // half-working: with no company geocode and no overrides, NOBODY is + // anchored, which the strategy reports as `no_anchored_candidate`. + const companyOrigin = + typeof cfg?.companyLat === 'number' && typeof cfg?.companyLng === 'number' + ? { lat: cfg.companyLat, lng: cfg.companyLng } + : null; + + const weekLoads = strategy === 'least_loaded' + ? await loadWeekCounts(db, tenantId, freeIds, opts.civilDate, resolveTenantTimeZone(cfg?.defaultTimezone)) + : new Map(); + + const candidates: RoutingCandidate[] = staff.map(s => ({ + id: s.id, + name: s.name, + origin: typeof s.lat === 'number' && typeof s.lng === 'number' + ? { lat: s.lat, lng: s.lng } + : companyOrigin, + weekLoad: weekLoads.get(s.id) ?? 0, + })); + + const decision = pickInspectorByStrategy({ strategy, candidates, property: opts.property }); + + if (decision.reason) { + // The substitution is an EVENT. Without this line a `closest` workspace + // whose address never geocoded would see first_available results + // forever and have nothing anywhere to explain why. + logger.warn('booking.routing.substituted', { + tenantId, + requested: decision.requested, + applied: decision.applied, + reason: decision.reason, + candidateCount: decision.candidateCount, + }); + } + return decision; +} + +/** + * Non-cancelled inspections per inspector in the ISO week around `civilDate`. + * + * Counts off `inspections.date`, NOT `scheduled_start_ms`. The latter is the + * phase-C authoritative instant and has zero non-NULL rows in production, so + * counting on it would make every load 0, every comparison a tie, and + * `least_loaded` an alias for `first_available` with no error and no log. + * The bucket is derived through the tenant zone, never off the raw string. + */ +async function loadWeekCounts( + db: ReturnType, + tenantId: string, + userIds: string[], + civilDate: string, + tenantTz: string, +): Promise> { + const counts = new Map(); + const targetWeek = isoWeekKey(civilDate); + const { fromYmd, toYmd } = isoWeekWindow(civilDate); + + const CHUNK = 80; + for (let i = 0; i < userIds.length; i += CHUNK) { + const rows = await db.select({ + userId: inspectionInspectors.userId, + date: inspections.date, + }).from(inspectionInspectors) + .innerJoin(inspections, eq(inspections.id, inspectionInspectors.inspectionId)) + .where(and( + eq(inspectionInspectors.tenantId, tenantId), + inArray(inspectionInspectors.userId, userIds.slice(i, i + CHUNK)), + sql`date(${inspections.date}) >= ${fromYmd}`, + sql`date(${inspections.date}) <= ${toYmd}`, + sql`${inspections.status} not in ('cancelled')`, + )).all(); + for (const row of rows) { + const civil = inspectionCivilDate(String(row.date), tenantTz); + if (!civil || isoWeekKey(civil) !== targetWeek) continue; + counts.set(row.userId, (counts.get(row.userId) ?? 0) + 1); + } + } + return counts; +} diff --git a/server/services/booking/slot-arbitration.ts b/server/services/booking/slot-arbitration.ts new file mode 100644 index 000000000..254b7c00c --- /dev/null +++ b/server/services/booking/slot-arbitration.ts @@ -0,0 +1,99 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { inspections, inspectionInspectors, inspectionRequests } from '../../lib/db/schema'; + +/** + * B-28 — post-insert TOCTOU arbitration and its compensation. + * + * The two functions are one mechanism and are extracted together for that + * reason: `arbitrateSlotRace` is only meaningful because `revokeBooking` can + * undo what the loser just wrote, and `revokeBooking` is only ever safe + * because arbitration is the sole caller (the rows are milliseconds old and + * nobody has been told the booking exists). + */ + +/** + * The slot read and the inspection insert in POST /book are not atomic (D1 has + * no row locks), so two concurrent submits can both pass the advisory check + * and double-book the same inspector. Instead of preventing the race we + * resolve it after the fact: every racer calls this AFTER its own insert and + * BEFORE any side effect (emails, calendar). All racers see the same + * conflicting rows and apply the same deterministic order — sort by + * (createdAt, id) — so the earliest booking wins and every later racer + * self-compensates (revokeBooking + 409). Exactly one winner, no coordination. + * + * Busy semantics mirror getTenantSlots: link-table join, non-cancelled, HH:MM + * read from the ISO datetime at slice(11,16). + */ +export async function arbitrateSlotRace( + d1: D1Database, + tenantId: string, + inspectorId: string, + dateStr: string, + time: string, + myRequestId: string, +): Promise<'win' | 'lose'> { + const db = drizzle(d1); + const rows = await db.select({ + inspectionId: inspectionInspectors.inspectionId, + requestId: inspections.requestId, + date: inspections.date, + createdAt: inspections.createdAt, + }) + .from(inspectionInspectors) + .innerJoin(inspections, eq(inspections.id, inspectionInspectors.inspectionId)) + .where(and( + eq(inspectionInspectors.tenantId, tenantId), + eq(inspectionInspectors.userId, inspectorId), + sql`date(${inspections.date}) = ${dateStr}`, + sql`${inspections.status} not in ('cancelled')`, + )).all(); + + const atSlot = rows.filter(r => String(r.date).slice(11, 16) === time); + const mine = atSlot.filter(r => r.requestId === myRequestId); + const others = atSlot.filter(r => r.requestId !== myRequestId); + // No competitor — or our rows are not visible (nothing to arbitrate). + if (mine.length === 0 || others.length === 0) return 'win'; + + type Key = [number, string]; + const key = (r: typeof rows[number]): Key => [ + r.createdAt instanceof Date ? r.createdAt.getTime() : Number(r.createdAt ?? 0), + r.inspectionId, + ]; + const cmp = (a: Key, b: Key) => a[0] - b[0] || (Number(a[1] > b[1]) - Number(a[1] < b[1])); + const myKey = mine.map(key).sort(cmp)[0]!; + const otherKey = others.map(key).sort(cmp)[0]!; + return cmp(otherKey, myKey) < 0 ? 'lose' : 'win'; +} + +/** + * B-28 compensation — fully retract a booking this request just created: link + * rows, inspections, then the request row. Only ever called on rows the caller + * inserted milliseconds ago (the client got a 409, never a confirmation), so + * hard delete is correct — no cancelled tombstones. + */ +export async function revokeBooking( + d1: D1Database, + tenantId: string, + requestId: string, +): Promise { + const db = drizzle(d1); + const rows = await db.select({ id: inspections.id }).from(inspections) + .where(and(eq(inspections.tenantId, tenantId), eq(inspections.requestId, requestId))) + .all(); + const ids = rows.map(r => r.id); + if (ids.length > 0) { + await db.delete(inspectionInspectors).where(and( + eq(inspectionInspectors.tenantId, tenantId), + inArray(inspectionInspectors.inspectionId, ids), + )); + await db.delete(inspections).where(and( + eq(inspections.tenantId, tenantId), + inArray(inspections.id, ids), + )); + } + await db.delete(inspectionRequests).where(and( + eq(inspectionRequests.tenantId, tenantId), + eq(inspectionRequests.id, requestId), + )); +} diff --git a/tests/unit/bookings/booking-rules.spec.ts b/tests/unit/bookings/booking-rules.spec.ts new file mode 100644 index 000000000..9d9c99ea8 --- /dev/null +++ b/tests/unit/bookings/booking-rules.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import { BookingService } from '../../../server/services/booking.service'; +import { tenants, tenantConfigs, users, availability } from '../../../server/lib/db/schema'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { + applyBookingRules, + parseCutoffTime, + parseMinLeadHours, +} from '../../../server/lib/booking/booking-rules'; +import { wallClockToEpochMs } from '../../../server/lib/tz'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +const CHICAGO = 'America/Chicago'; +const MONDAY = '2026-06-08'; + +describe('booking rules are civil-time rules, evaluated in the tenant zone', () => { + const at = (ymd: string, hm: string) => wallClockToEpochMs(ymd, hm, CHICAGO); + + it('a 24h lead time blocks tomorrow morning and allows the day after', () => { + const base = { + minLeadHours: 24, sameDayCutoffTime: null, tenantTz: CHICAGO, + nowMs: at('2026-06-08', '10:00'), + }; + expect(applyBookingRules({ ...base, civilDate: '2026-06-09', slotTime: '08:00' })) + .toEqual({ allowed: false, reason: 'min_lead' }); + expect(applyBookingRules({ ...base, civilDate: '2026-06-09', slotTime: '11:00' })) + .toEqual({ allowed: true, reason: null }); + }); + + it('a 15:00 same-day cutoff closes today at 15:00 tenant-local and leaves tomorrow alone', () => { + const rules = { minLeadHours: 0, sameDayCutoffTime: '15:00', tenantTz: CHICAGO }; + // 14:59 local — today is still open. + expect(applyBookingRules({ + ...rules, civilDate: MONDAY, slotTime: '17:00', nowMs: at(MONDAY, '14:59'), + }).allowed).toBe(true); + // 15:00 local — closed for today. + expect(applyBookingRules({ + ...rules, civilDate: MONDAY, slotTime: '17:00', nowMs: at(MONDAY, '15:00'), + })).toEqual({ allowed: false, reason: 'same_day_cutoff' }); + // Tomorrow is unaffected by today's cutoff. + expect(applyBookingRules({ + ...rules, civilDate: '2026-06-09', slotTime: '08:00', nowMs: at(MONDAY, '15:00'), + }).allowed).toBe(true); + }); + + it('the cutoff follows the OFFICE clock, not UTC', () => { + const rules = { minLeadHours: 0, sameDayCutoffTime: '15:00', tenantTz: CHICAGO }; + // 20:30Z on the 8th is 15:30 in Chicago (CDT) — past the cutoff — but + // still "the 8th" in UTC either way. The zone is what decides, and a + // naive UTC comparison of 20:30 >= 15:00 would agree here by accident. + // 17:30Z is 12:30 local: open. A UTC comparison would call it closed. + expect(applyBookingRules({ + ...rules, civilDate: MONDAY, slotTime: '18:00', nowMs: Date.parse('2026-06-08T17:30:00Z'), + }).allowed).toBe(true); + expect(applyBookingRules({ + ...rules, civilDate: MONDAY, slotTime: '18:00', nowMs: Date.parse('2026-06-08T20:30:00Z'), + }).allowed).toBe(false); + }); + + it('lead time wins when both rules apply, so the client is told the binding one', () => { + expect(applyBookingRules({ + minLeadHours: 48, sameDayCutoffTime: '15:00', tenantTz: CHICAGO, + civilDate: MONDAY, slotTime: '17:00', nowMs: at(MONDAY, '16:00'), + })).toEqual({ allowed: false, reason: 'min_lead' }); + }); + + it('unconfigured rules allow everything', () => { + expect(applyBookingRules({ + minLeadHours: 0, sameDayCutoffTime: null, tenantTz: CHICAGO, + civilDate: '2020-01-01', slotTime: '08:00', nowMs: Date.now(), + })).toEqual({ allowed: true, reason: null }); + }); + + it('malformed stored values mean "no rule", never a crash or an accidental block', () => { + expect(parseCutoffTime('25:00')).toBeNull(); + expect(parseCutoffTime('3pm')).toBeNull(); + expect(parseCutoffTime('')).toBeNull(); + expect(parseCutoffTime('09:30')).toBe('09:30'); + expect(parseMinLeadHours(-5)).toBe(0); + expect(parseMinLeadHours(null)).toBe(0); + expect(parseMinLeadHours(1e9)).toBe(24 * 365); + }); +}); + +describe('getTenantSlots enforces the rules on the computed grid', () => { + let svc: BookingService; + let db: BetterSQLite3Database; + let sqlite: any; + + beforeEach(async () => { + const setup = createTestDb(); + db = setup.db; sqlite = setup.sqlite; + await setupSchema(sqlite); + (mockDrizzle as any).mockReturnValue(db); + svc = new BookingService({} as any); + await db.insert(tenants).values({ id: 't1', name: 'Acme', slug: 'acme', createdAt: new Date() }); + await db.insert(users).values({ + id: 'u1', tenantId: 't1', email: 'u1@x.com', passwordHash: 'h', + role: 'inspector', name: 'Ann', createdAt: new Date(), + }); + await db.insert(availability).values({ + id: 'a1', tenantId: 't1', inspectorId: 'u1', dayOfWeek: 1, + startTime: '08:00', endTime: '10:00', createdAt: new Date(), + }); + }); + afterEach(() => { vi.useRealTimers(); sqlite.close(); }); + + it('a same-day cutoff already passed leaves the day with no bookable slot', async () => { + await db.insert(tenantConfigs).values({ + tenantId: 't1', defaultTimezone: CHICAGO, + bookingSameDayCutoffTime: '07:00', updatedAt: new Date(), + }); + vi.useFakeTimers(); + vi.setSystemTime(new Date(wallClockToEpochMs(MONDAY, '07:30', CHICAGO))); + + const { slots } = await svc.getTenantSlots('t1', MONDAY, []); + expect(slots.length).toBeGreaterThan(0); + expect(slots.every(s => s.available === false)).toBe(true); + expect(slots.every(s => s.inspectorIds.length === 0)).toBe(true); + }); + + it('with no rules configured the same grid is fully bookable', async () => { + await db.insert(tenantConfigs).values({ + tenantId: 't1', defaultTimezone: CHICAGO, updatedAt: new Date(), + }); + vi.useFakeTimers(); + vi.setSystemTime(new Date(wallClockToEpochMs(MONDAY, '07:30', CHICAGO))); + + const { slots } = await svc.getTenantSlots('t1', MONDAY, []); + expect(slots.some(s => s.available)).toBe(true); + }); +}); diff --git a/tests/unit/bookings/routing.spec.ts b/tests/unit/bookings/routing.spec.ts new file mode 100644 index 000000000..4b1cf665a --- /dev/null +++ b/tests/unit/bookings/routing.spec.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { createTestDb, setupSchema } from '../db'; +import { BookingService } from '../../../server/services/booking.service'; +import { + tenants, tenantConfigs, users, inspections, inspectionInspectors, +} from '../../../server/lib/db/schema'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { + pickInspectorByStrategy, + inspectionCivilDate, + isoWeekKey, + haversineKm, + type RoutingCandidate, +} from '../../../server/lib/booking/routing'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +/** + * THE POINT OF THIS FILE. + * + * Every strategy here has an input on which it returns a perfectly plausible + * inspector id while having computed nothing at all — `least_loaded` when no + * candidate has dated work, `closest` when nothing is geocoded. Those are not + * corner cases: they were the ONLY case in production, because + * `scheduled_start_ms` has no non-NULL rows and no public booking carried a + * geocode. A test that asserted "least_loaded returns somebody" would have + * passed against an empty database and proved nothing. + * + * So every degenerate assertion below is on the REPORTED reason, not on the + * returned id. + */ + +const cand = ( + id: string, + over: Partial = {}, +): RoutingCandidate => ({ id, name: id, origin: null, weekLoad: 0, ...over }); + +const AUSTIN = { lat: 30.2672, lng: -97.7431 }; +const DALLAS = { lat: 32.7767, lng: -96.797 }; +const HOUSTON = { lat: 29.7604, lng: -95.3698 }; + +describe('routing strategies report the case they cannot compute', () => { + it('first_available sorts by (name, id) and never reports a substitution', () => { + const d = pickInspectorByStrategy({ + strategy: 'first_available', + candidates: [cand('u3', { name: 'Carl' }), cand('u1', { name: 'Ann' }), cand('u2', { name: 'Bea' })], + property: null, + }); + expect(d.inspectorId).toBe('u1'); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBeNull(); + }); + + // ── least_loaded ──────────────────────────────────────────────────────── + it('least_loaded picks the lighter week when loads differ', () => { + const d = pickInspectorByStrategy({ + strategy: 'least_loaded', + candidates: [cand('u1', { name: 'Ann', weekLoad: 4 }), cand('u2', { name: 'Bea', weekLoad: 1 })], + property: null, + }); + expect(d.inspectorId).toBe('u2'); + expect(d.applied).toBe('least_loaded'); + expect(d.reason).toBeNull(); + }); + + it('least_loaded REPORTS no_dated_work when every load is zero, instead of tying into first_available', () => { + const d = pickInspectorByStrategy({ + strategy: 'least_loaded', + candidates: [cand('u1', { name: 'Ann' }), cand('u2', { name: 'Bea' })], + property: null, + }); + // It still returns somebody — that is the whole danger. The signal is + // the reason, and the honest statement of which strategy ran. + expect(d.inspectorId).toBe('u1'); + expect(d.requested).toBe('least_loaded'); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBe('no_dated_work'); + }); + + // ── closest ───────────────────────────────────────────────────────────── + it('closest picks the nearest anchored candidate', () => { + const d = pickInspectorByStrategy({ + strategy: 'closest', + candidates: [ + cand('u1', { name: 'Ann', origin: DALLAS }), + cand('u2', { name: 'Bea', origin: HOUSTON }), + ], + property: AUSTIN, + }); + expect(haversineKm(AUSTIN, HOUSTON)).toBeLessThan(haversineKm(AUSTIN, DALLAS)); + expect(d.inspectorId).toBe('u2'); + expect(d.applied).toBe('closest'); + expect(d.reason).toBeNull(); + }); + + it('closest REPORTS property_ungeocoded rather than ranking an unknown property last', () => { + const d = pickInspectorByStrategy({ + strategy: 'closest', + candidates: [cand('u1', { name: 'Ann', origin: DALLAS }), cand('u2', { name: 'Bea', origin: HOUSTON })], + property: null, + }); + expect(d.requested).toBe('closest'); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBe('property_ungeocoded'); + }); + + it('closest REPORTS no_anchored_candidate when fewer than two candidates have an origin', () => { + const d = pickInspectorByStrategy({ + strategy: 'closest', + candidates: [cand('u1', { name: 'Ann', origin: DALLAS }), cand('u2', { name: 'Bea' })], + property: AUSTIN, + }); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBe('no_anchored_candidate'); + }); + + it('a candidate with no origin is NOT ranked last — it is not an input at all', () => { + // u3 is unanchored and sits far from everyone. With three candidates + // the strategy still runs, and u3 must be absent from the ranking + // rather than sorted to the bottom (a sort position is a claim about + // a distance nobody measured). + const d = pickInspectorByStrategy({ + strategy: 'closest', + candidates: [ + cand('u1', { name: 'Ann', origin: DALLAS }), + cand('u2', { name: 'Bea', origin: HOUSTON }), + cand('u3', { name: 'Aaa' }), + ], + property: AUSTIN, + }); + expect(d.inspectorId).toBe('u2'); + expect(d.reason).toBeNull(); + }); + + it('a single candidate reports single_candidate: no strategy chose anything', () => { + for (const strategy of ['least_loaded', 'closest'] as const) { + const d = pickInspectorByStrategy({ + strategy, + candidates: [cand('u1', { name: 'Ann', origin: DALLAS, weekLoad: 3 })], + property: AUSTIN, + }); + expect(d.inspectorId).toBe('u1'); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBe('single_candidate'); + } + }); + + it('no candidates yields a null id and no invented reason', () => { + const d = pickInspectorByStrategy({ strategy: 'closest', candidates: [], property: AUSTIN }); + expect(d.inspectorId).toBeNull(); + expect(d.reason).toBeNull(); + expect(d.candidateCount).toBe(0); + }); +}); + +describe('ISO-week bucketing reads inspections.date through the tenant zone', () => { + it('a bare civil date is taken as written, never reinterpreted as UTC midnight', () => { + // Parsing '2026-06-08' as an instant and rendering it in Chicago would + // yield 2026-06-07 — the calendar off-by-one this repo has a lint gate + // for. A civil date has no zone to convert from. + expect(inspectionCivilDate('2026-06-08', 'America/Chicago')).toBe('2026-06-08'); + }); + + it('a stored instant IS converted, so late-evening UTC lands on the office day', () => { + // 2026-06-09T02:00Z is still the 8th in Chicago (UTC-5 in June). + expect(inspectionCivilDate('2026-06-09T02:00:00Z', 'America/Chicago')).toBe('2026-06-08'); + expect(inspectionCivilDate('2026-06-09T02:00:00Z', 'UTC')).toBe('2026-06-09'); + }); + + it('unparseable stored values are dropped, not counted as today', () => { + expect(inspectionCivilDate('not-a-date', 'UTC')).toBeNull(); + }); + + it('ISO weeks run Monday to Sunday and belong to their Thursday year', () => { + expect(isoWeekKey('2026-06-08')).toBe(isoWeekKey('2026-06-14')); // Mon..Sun + expect(isoWeekKey('2026-06-08')).not.toBe(isoWeekKey('2026-06-15')); + // 2027-01-01 is a Friday, so it belongs to ISO week 53 of 2026. + expect(isoWeekKey('2027-01-01')).toBe('2026-W53'); + }); +}); + +// 2026-06-08 is a Monday. +const MONDAY = '2026-06-08'; + +describe('routeInspector against real rows', () => { + let svc: BookingService; + let db: BetterSQLite3Database; + let sqlite: any; + + const setStrategy = async (strategy: string, extra: Record = {}) => { + await db.delete(tenantConfigs); + await db.insert(tenantConfigs).values({ + tenantId: 't1', + bookingRoutingStrategy: strategy as 'first_available', + defaultTimezone: 'America/Chicago', + updatedAt: new Date(), + ...extra, + }); + }; + + beforeEach(async () => { + const setup = createTestDb(); + db = setup.db; sqlite = setup.sqlite; + await setupSchema(sqlite); + (mockDrizzle as any).mockReturnValue(db); + svc = new BookingService({} as any); + + await db.insert(tenants).values({ id: 't1', name: 'Acme', slug: 'acme', createdAt: new Date() }); + await db.insert(users).values([ + { id: 'u1', tenantId: 't1', email: 'u1@x.com', passwordHash: 'h', role: 'inspector', name: 'Ann', createdAt: new Date() }, + { id: 'u2', tenantId: 't1', email: 'u2@x.com', passwordHash: 'h', role: 'inspector', name: 'Bea', createdAt: new Date() }, + ]); + }); + afterEach(() => sqlite.close()); + + /** Give `who` a dated, non-cancelled inspection inside MONDAY's ISO week. */ + const giveWork = async (id: string, who: string, date: string) => { + await db.insert(inspections).values({ + id, tenantId: 't1', inspectorId: who, propertyAddress: '1 Main St', + date, status: 'scheduled', createdAt: new Date(), + }); + await db.insert(inspectionInspectors).values({ + inspectionId: id, userId: who, tenantId: 't1', role: 'lead', createdAt: new Date(), + }); + }; + + it('least_loaded counts inspections.date — NOT scheduled_start_ms, which is empty', async () => { + await setStrategy('least_loaded'); + // Both rows leave scheduled_start_ms NULL, exactly as every production + // row does. Counting on that column would make both loads 0 and this + // assertion would read `no_dated_work`. + await giveWork('i1', 'u1', `${MONDAY}T09:00:00Z`); + await giveWork('i2', 'u1', '2026-06-10'); + + const d = await svc.routeInspector('t1', ['u1', 'u2'], { civilDate: MONDAY, property: null }); + expect(d.applied).toBe('least_loaded'); + expect(d.reason).toBeNull(); + expect(d.inspectorId).toBe('u2'); + }); + + it('least_loaded on a workspace with no dated work reports no_dated_work', async () => { + await setStrategy('least_loaded'); + const d = await svc.routeInspector('t1', ['u1', 'u2'], { civilDate: MONDAY, property: null }); + expect(d.requested).toBe('least_loaded'); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBe('no_dated_work'); + }); + + it('work in an ADJACENT week is not this week`s load', async () => { + await setStrategy('least_loaded'); + await giveWork('i1', 'u1', '2026-06-15'); // the following Monday + const d = await svc.routeInspector('t1', ['u1', 'u2'], { civilDate: MONDAY, property: null }); + expect(d.reason).toBe('no_dated_work'); + }); + + it('closest inherits the COMPANY coordinates as each inspector default origin', async () => { + await setStrategy('closest', { companyLat: DALLAS.lat, companyLng: DALLAS.lng }); + // u2 works out of Houston; u1 inherits the Dallas office. + await db.update(users).set({ serviceOriginLat: HOUSTON.lat, serviceOriginLng: HOUSTON.lng }) + .where(eq(users.id, 'u2')); + + const d = await svc.routeInspector('t1', ['u1', 'u2'], { civilDate: MONDAY, property: AUSTIN }); + expect(d.applied).toBe('closest'); + expect(d.reason).toBeNull(); + expect(d.inspectorId).toBe('u2'); + }); + + it('closest with no company geocode and no overrides reports no_anchored_candidate', async () => { + await setStrategy('closest'); + const d = await svc.routeInspector('t1', ['u1', 'u2'], { civilDate: MONDAY, property: AUSTIN }); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBe('no_anchored_candidate'); + }); + + it('closest on an ungeocoded property reports property_ungeocoded', async () => { + await setStrategy('closest', { companyLat: DALLAS.lat, companyLng: DALLAS.lng }); + const d = await svc.routeInspector('t1', ['u1', 'u2'], { civilDate: MONDAY, property: null }); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBe('property_ungeocoded'); + }); + + it('a tenant with no config row routes first_available without reporting a substitution', async () => { + const d = await svc.routeInspector('t1', ['u1', 'u2'], { civilDate: MONDAY, property: null }); + expect(d.requested).toBe('first_available'); + expect(d.applied).toBe('first_available'); + expect(d.reason).toBeNull(); + expect(d.inspectorId).toBe('u1'); + }); +}); diff --git a/tests/unit/bookings/service-area.spec.ts b/tests/unit/bookings/service-area.spec.ts new file mode 100644 index 000000000..bab925d0d --- /dev/null +++ b/tests/unit/bookings/service-area.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import { BookingService } from '../../../server/services/booking.service'; +import { + tenants, users, availability, inspectorServiceAreas, +} from '../../../server/lib/db/schema'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { + filterEligibleInspectors, + zipMatchesPrefix, +} from '../../../server/lib/booking/eligibility'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +const MONDAY = '2026-06-08'; +const areas = (entries: Array<[string, string[]]>) => new Map(entries); + +describe('ZIP eligibility says when it did NOT filter', () => { + it('an empty property ZIP is reported, not silently waved through', () => { + // This is the defect the whole feature was built around: with no ZIP + // on any public booking, "empty zip -> no geo filter (graceful + // degrade)" was 100% of traffic and looked identical to a filter that + // ran and matched everyone. + const out = filterEligibleInspectors(['u1', 'u2'], null, areas([['u1', ['78701']]])); + expect(out.eligibleIds).toEqual(['u1', 'u2']); + expect(out.applied).toBe(false); + expect(out.reason).toBe('property_zip_unknown'); + }); + + it('a tenant with no declared territories is reported too', () => { + const out = filterEligibleInspectors(['u1', 'u2'], '78701', areas([])); + expect(out.applied).toBe(false); + expect(out.reason).toBe('no_service_areas_configured'); + }); + + it('filters when it can, and says so', () => { + const out = filterEligibleInspectors( + ['u1', 'u2'], '78701', areas([['u1', ['78701']], ['u2', ['73301']]]), + ); + expect(out.eligibleIds).toEqual(['u1']); + expect(out.applied).toBe(true); + expect(out.reason).toBeNull(); + expect(out.excludedEveryone).toBe(false); + }); + + it('an inspector with no rows serves everywhere (mirrors service_inspectors)', () => { + const out = filterEligibleInspectors( + ['u1', 'u2'], '99999', areas([['u1', ['78701']]]), + ); + expect(out.eligibleIds).toEqual(['u2']); + expect(out.applied).toBe(true); + }); + + it('excluding everyone is a distinct, reported outcome — not an empty accident', () => { + const out = filterEligibleInspectors( + ['u1'], '99999', areas([['u1', ['78701']]]), + ); + expect(out.eligibleIds).toEqual([]); + expect(out.applied).toBe(true); + expect(out.excludedEveryone).toBe(true); + }); + + it('prefix matching runs one way only: a 3-digit area covers a full ZIP, never the reverse', () => { + expect(zipMatchesPrefix('78701', '787')).toBe(true); + expect(zipMatchesPrefix('78701', '78701')).toBe(true); + expect(zipMatchesPrefix('787', '78701')).toBe(false); + expect(zipMatchesPrefix('78701', '')).toBe(false); + expect(zipMatchesPrefix(' 78701 ', 'M5V')).toBe(false); + expect(zipMatchesPrefix('m5v3a8', 'M5V')).toBe(true); + }); +}); + +describe('getTenantSlots applies the ZIP filter before the slot union', () => { + let svc: BookingService; + let db: BetterSQLite3Database; + let sqlite: any; + + beforeEach(async () => { + const setup = createTestDb(); + db = setup.db; sqlite = setup.sqlite; + await setupSchema(sqlite); + (mockDrizzle as any).mockReturnValue(db); + svc = new BookingService({} as any); + + await db.insert(tenants).values({ id: 't1', name: 'Acme', slug: 'acme', createdAt: new Date() }); + await db.insert(users).values([ + { id: 'u1', tenantId: 't1', email: 'u1@x.com', passwordHash: 'h', role: 'inspector', name: 'Ann', createdAt: new Date() }, + { id: 'u2', tenantId: 't1', email: 'u2@x.com', passwordHash: 'h', role: 'inspector', name: 'Bea', createdAt: new Date() }, + ]); + await db.insert(availability).values([ + { id: 'a1', tenantId: 't1', inspectorId: 'u1', dayOfWeek: 1, startTime: '08:00', endTime: '10:00', createdAt: new Date() }, + { id: 'a2', tenantId: 't1', inspectorId: 'u2', dayOfWeek: 1, startTime: '08:00', endTime: '10:00', createdAt: new Date() }, + ]); + }); + afterEach(() => sqlite.close()); + + const area = (id: string, userId: string, zipPrefix: string) => + db.insert(inspectorServiceAreas).values({ + id, tenantId: 't1', userId, zipPrefix, createdAt: new Date(), + }); + + it('an in-area ZIP leaves only the serving inspector on the slot', async () => { + await area('sa1', 'u1', '78701'); + await area('sa2', 'u2', '73301'); + const out = await svc.getTenantSlots('t1', MONDAY, [], undefined, '78701'); + expect(out.slots.find(s => s.time === '08:00')!.inspectorIds).toEqual(['u1']); + expect(out.geoSkipped).toBeNull(); + }); + + it('a ZIP nobody serves yields no slots AND says why', async () => { + await area('sa1', 'u1', '78701'); + await area('sa2', 'u2', '73301'); + const out = await svc.getTenantSlots('t1', MONDAY, [], undefined, '99999'); + expect(out.slots).toEqual([]); + expect(out.outsideServiceArea).toBe(true); + }); + + it('no ZIP supplied returns every slot and reports that the filter did not run', async () => { + await area('sa1', 'u1', '78701'); + const out = await svc.getTenantSlots('t1', MONDAY, []); + expect(out.slots.find(s => s.time === '08:00')!.inspectorIds.sort()).toEqual(['u1', 'u2']); + expect(out.geoSkipped).toBe('property_zip_unknown'); + }); + + it('another tenant`s territory rows never narrow this tenant', async () => { + await db.insert(tenants).values({ id: 't2', name: 'Other', slug: 'other', createdAt: new Date() }); + await db.insert(inspectorServiceAreas).values({ + id: 'sa9', tenantId: 't2', userId: 'u1', zipPrefix: '00000', createdAt: new Date(), + }); + const out = await svc.getTenantSlots('t1', MONDAY, [], undefined, '78701'); + // t1 has no rows of its own, so the filter cannot run and says so. + expect(out.geoSkipped).toBe('no_service_areas_configured'); + expect(out.slots.find(s => s.time === '08:00')!.inspectorIds.sort()).toEqual(['u1', 'u2']); + }); +}); diff --git a/tests/unit/idempotency/service-areas-replay.spec.ts b/tests/unit/idempotency/service-areas-replay.spec.ts new file mode 100644 index 000000000..d9465b2f2 --- /dev/null +++ b/tests/unit/idempotency/service-areas-replay.spec.ts @@ -0,0 +1,146 @@ +/** + * PUT '/api/admin/service-areas' — replacing an inspector's ZIP territory. + * + * Two things could go wrong on a retry and only one of them is obvious. + * + * The obvious one: the handler writes by DELETE-then-INSERT, and the table + * carries a unique index on (tenant, user, zip). A naive replay could either + * violate that index or, worse, land between the delete and the insert of the + * first attempt and leave a territory nobody chose. + * + * The subtle one: a territory decides who is even OFFERED a booking. A doubled + * or half-applied write does not surface as an error anywhere — it surfaces + * weeks later as "why does the Round Rock job keep going to Dave". + * + * So this asserts BOTH guarantees: the mounted guard replays the stored + * response rather than re-running the write, AND the stored rows are identical + * either way (the write is genuinely replace-by-value, so even an unguarded + * retry converges). + */ +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() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import adminRoutes from '../../../server/api/admin'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; + +const TENANT = '11111111-1111-4111-8111-111111111111'; +const USER = '22222222-2222-4222-8222-222222222222'; + +let db: BetterSQLite3Database; + +function buildApp() { + 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('userRole', 'owner'); + c.set('tenantId', TENANT); + c.set('user', { sub: USER } as never); + c.set('services', {} as HonoConfig['Variables']['services']); + await next(); + }); + // The mounted shape: tenant on the context first, then the guard. + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/admin', adminRoutes); + return app; +} + +const ENV = { DB: {}, JWT_SECRET: 'test-secret' }; +const EXEC = { + waitUntil: (p: Promise) => { void Promise.resolve(p).catch(() => {}); }, + passThroughOnException: () => {}, +} as ExecutionContext; + +function put(key: string | null, zipPrefixes: string[]) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().request('/api/admin/service-areas', { + method: 'PUT', headers, body: JSON.stringify({ userId: USER, zipPrefixes }), + }, ENV, EXEC); +} + +const storedZips = async () => (await db.select({ zipPrefix: schema.inspectorServiceAreas.zipPrefix }) + .from(schema.inspectorServiceAreas) + .where(and( + eq(schema.inspectorServiceAreas.tenantId, TENANT), + eq(schema.inspectorServiceAreas.userId, USER), + )).all()).map(r => r.zipPrefix).sort(); + +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: 'A', slug: 'a', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.users).values({ + id: USER, tenantId: TENANT, email: 'u@test.com', passwordHash: 'h', + role: 'inspector', name: 'Ann', createdAt: new Date(), + }); +}); + +describe("PUT '/api/admin/service-areas' — replay leaves one territory, not two", () => { + it('two sends under one key store exactly the requested list', async () => { + const first = await put('sa-1', ['78701', '787']); + const second = await put('sa-1', ['78701', '787']); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(await storedZips()).toEqual(['787', '78701']); + }); + + it('an UNGUARDED retry converges too — the write is replace-by-value', async () => { + // The guard is the first line of defence, not the only one. A client + // that retries without a key must still not double the territory. + await put(null, ['78701']); + await put(null, ['78701']); + expect(await storedZips()).toEqual(['78701']); + }); + + it('an empty list clears the territory rather than being ignored', async () => { + await put(null, ['78701', '73301']); + expect(await storedZips()).toHaveLength(2); + await put(null, []); + expect(await storedZips()).toEqual([]); + }); + + it('duplicates in one payload collapse instead of hitting the unique index', async () => { + const res = await put(null, ['78701', '78701', ' 78701 ']); + expect(res.status).toBe(200); + expect(await storedZips()).toEqual(['78701']); + }); + + it('a userId from another tenant is refused, not written under ours', async () => { + await db.insert(schema.tenants).values({ + id: 'other', name: 'B', slug: 'b', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.users).values({ + id: 'stranger', tenantId: 'other', email: 's@test.com', passwordHash: 'h', + role: 'inspector', name: 'Sam', createdAt: new Date(), + }); + const res = await buildApp().request('/api/admin/service-areas', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: 'stranger', zipPrefixes: ['00000'] }), + }, ENV, EXEC); + expect(res.status).toBe(404); + const rows = await db.select().from(schema.inspectorServiceAreas).all(); + expect(rows).toEqual([]); + }); +}); From cacc17ec378a864b9d7127630f9bd9d6f6fdcbef Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 00:55:24 +0800 Subject: [PATCH 65/77] feat(settings): the surfaces that make a routing strategy choosable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server can now route three ways and report when it could not. Nothing could choose between them, and — the reason the geographic half was dead in the first place — nothing captured a property ZIP. Public booking page. The address field is now an autocomplete against `/api/public/geocode`, the public rate-limited endpoint that has returned a ZIP and a placeId for as long as the booking page has existed and that the page never called. A picked suggestion sends `addressZip` + `addressPlaceId`; a typed one sends neither, and the server says so rather than pretending the filter ran. Deliberately NOT the dashboard's AddressAutocomplete: that goes through a session-gated BFF and returns an empty list to a signed-out visitor, silently, which would have read as "Places is not configured". Settings → Online Booking gains two panels: - Routing & booking rules. Three strategies with the readiness of each stated NEXT TO the option — "needs a Google Places key", "no address has been located", "only one inspector has a start address". A radio that silently does nothing is the exact failure this programme keeps producing, so the panel refuses to present one as live. Company geocoding is an explicit button with the matched address shown back, not a side effect of saving a colour, and a failed lookup names its reason. - Inspector territories. ZIP list + start address per inspector, with the empty state stated: no ZIPs means all areas, an empty start address means the company one. Both hang off a new `/api/admin/booking-routing` sub-router rather than admin-settings.ts, which sits exactly on its 754-line baseline and has no business making outbound Google calls from a generic config PATCH. Chrome walkthrough, light and dark. Three things it caught that no gate did: the blocker used invented `ih-warn-*` classes Tailwind dropped silently (now the DS `Banner tone="warn"`, which also carries role="alert"); `lint:ds` does not catch a token that does not exist. Live, with the tenant in UTC-4, a 24h lead time made 2026-08-07 bookable from 13:00 local exactly — the tenant zone, not UTC. Clearing the cutoff restored all 18 slots. settings-booking.tsx crossed 400 lines, so the read and write halves were extracted to ~/lib/settings/booking-routing-{data,actions} rather than the baseline being raised. en + es-419 in this commit; FALLBACK_ALLOW stays empty. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- app/components/booking/BookingSteps.tsx | 12 +- app/components/booking/BookingWizard.tsx | 4 +- .../booking/PublicAddressAutocomplete.tsx | 167 ++++++++++++ app/components/booking/useBookingFormState.ts | 12 + .../settings/BookingRoutingPanel.test.tsx | 101 +++++++ .../settings/BookingRoutingPanel.tsx | 195 ++++++++++++++ .../settings/InspectorServiceAreasPanel.tsx | 157 +++++++++++ app/lib/settings/booking-routing-actions.ts | 122 +++++++++ app/lib/settings/booking-routing-data.ts | 96 +++++++ app/routes/settings-booking.tsx | 53 +++- messages/en/booking.json | 1 + messages/en/settings-components.json | 46 ++++ messages/es-419/booking.json | 1 + messages/es-419/settings-components.json | 46 ++++ server/api/admin.ts | 4 +- server/api/admin/admin-booking-routing.ts | 248 ++++++++++++++++++ server/lib/mcp/openapi-snapshot.json | 68 +++++ .../lib/validations/admin/booking-routing.ts | 70 +++++ .../booking-routing-replay.spec.ts | 202 ++++++++++++++ 19 files changed, 1594 insertions(+), 11 deletions(-) create mode 100644 app/components/booking/PublicAddressAutocomplete.tsx create mode 100644 app/components/settings/BookingRoutingPanel.test.tsx create mode 100644 app/components/settings/BookingRoutingPanel.tsx create mode 100644 app/components/settings/InspectorServiceAreasPanel.tsx create mode 100644 app/lib/settings/booking-routing-actions.ts create mode 100644 app/lib/settings/booking-routing-data.ts create mode 100644 server/api/admin/admin-booking-routing.ts create mode 100644 server/lib/validations/admin/booking-routing.ts create mode 100644 tests/unit/idempotency/booking-routing-replay.spec.ts diff --git a/app/components/booking/BookingSteps.tsx b/app/components/booking/BookingSteps.tsx index f278b5579..13631037f 100644 --- a/app/components/booking/BookingSteps.tsx +++ b/app/components/booking/BookingSteps.tsx @@ -1,4 +1,5 @@ import { timeWindows, type CompanyProfile } from "./booking-constants"; +import { PublicAddressAutocomplete, type PublicAddressSuggestion } from "./PublicAddressAutocomplete"; import { BookingDepositPanel } from "./BookingDepositPanel"; import { formatCurrency } from "~/lib/format"; import { useDisplayLocale } from "~/hooks/useSessionContext"; @@ -7,9 +8,12 @@ import { m } from "~/paraglide/messages"; export function PropertyStep({ address, setAddress, + onSelectAddress, }: { address: string; setAddress: (v: string) => void; + /** Carries the ZIP + placeId of a picked suggestion up to the form state. */ + onSelectAddress: (sel: PublicAddressSuggestion | null) => void; }) { return (
@@ -19,14 +23,12 @@ export function PropertyStep({
diff --git a/app/components/booking/BookingWizard.tsx b/app/components/booking/BookingWizard.tsx index 1704b7a49..a77f65d11 100644 --- a/app/components/booking/BookingWizard.tsx +++ b/app/components/booking/BookingWizard.tsx @@ -22,7 +22,7 @@ export function BookingWizard({ }) { const { step, setStep, - address, setAddress, + address, setAddress, setAddressPick, selectedServices, inspectionDate, setInspectionDate, timeWindow, setTimeWindow, @@ -101,7 +101,7 @@ export function BookingWizard({ {/* Step 0: Property */} {step === 0 && ( - + )} {/* Step 1: Services */} diff --git a/app/components/booking/PublicAddressAutocomplete.tsx b/app/components/booking/PublicAddressAutocomplete.tsx new file mode 100644 index 000000000..38e574ed1 --- /dev/null +++ b/app/components/booking/PublicAddressAutocomplete.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from "react"; +import { m } from "~/paraglide/messages"; + +/** One suggestion from `GET /api/public/geocode`. */ +export interface PublicAddressSuggestion { + label: string; + line1: string; + city: string | null; + state: string | null; + zip: string | null; + placeId: string; +} + +/** + * Address autocomplete for the UNAUTHENTICATED booking page. + * + * `/api/public/geocode` has existed, public and rate-limited, returning a ZIP + * and a placeId per suggestion, for as long as the booking page has — and the + * page never called it. Every booking arrived as free text, which is why the + * ZIP-based service-area filter had nothing to filter on and `closest` routing + * had no property to measure to. This component is the missing call. + * + * It is deliberately NOT the dashboard's `AddressAutocomplete`: that one goes + * through the `/resources/places` BFF, which requires a session token and + * returns `{ suggestions: [] }` to a signed-out visitor — silently, so it + * would have looked like "Places is not configured" rather than "this endpoint + * is not for you". + * + * Fail-soft: with no API key configured the endpoint returns an empty list, the + * dropdown never opens, and this behaves as the plain text input it replaces. + * The booking still submits; it simply carries no ZIP, which the server + * reports rather than treating as a filter that passed. + */ +export function PublicAddressAutocomplete({ + value, + onValueChange, + onSelect, + id = "booking-address", + placeholder, + autoFocus, +}: { + value: string; + onValueChange: (v: string) => void; + /** Fires when the visitor picks a suggestion. Null clears a prior pick. */ + onSelect: (sel: PublicAddressSuggestion | null) => void; + id?: string; + placeholder?: string; + autoFocus?: boolean; +}) { + const [suggestions, setSuggestions] = useState([]); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); + const debounceRef = useRef | null>(null); + // Guards against a slow earlier response overwriting a newer one. + const seqRef = useRef(0); + + useEffect(() => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, []); + + function handleChange(next: string) { + onValueChange(next); + // Editing after a pick invalidates it: the stored ZIP and placeId belong + // to the address that was chosen, not to whatever is in the box now. + onSelect(null); + setActive(-1); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (next.trim().length < 3) { + setSuggestions([]); + setOpen(false); + return; + } + const seq = ++seqRef.current; + debounceRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/public/geocode?q=${encodeURIComponent(next.trim())}`); + if (!res.ok) return; + const body = (await res.json()) as { data?: PublicAddressSuggestion[] }; + if (seq !== seqRef.current) return; + setSuggestions(body.data ?? []); + setOpen((body.data ?? []).length > 0); + } catch { + // Offline or blocked: the field stays usable as free text. + } + }, 250); + } + + function choose(s: PublicAddressSuggestion) { + onValueChange(s.label); + onSelect(s); + setOpen(false); + setActive(-1); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (!open || suggestions.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (i + 1) % suggestions.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (i <= 0 ? suggestions.length - 1 : i - 1)); + } else if (e.key === "Enter" && active >= 0) { + e.preventDefault(); + choose(suggestions[active]); + } else if (e.key === "Escape") { + setOpen(false); + setActive(-1); + } + } + + const listboxId = `${id}-listbox`; + + return ( +
+ 0} + aria-controls={listboxId} + aria-autocomplete="list" + autoComplete="street-address" + autoFocus={autoFocus} + value={value} + placeholder={placeholder} + onChange={(e) => handleChange(e.target.value)} + onKeyDown={onKeyDown} + onBlur={() => setTimeout(() => setOpen(false), 120)} + className="mt-1 w-full h-10 px-3 rounded-md border border-ih-border bg-ih-bg-card focus:border-ih-primary focus:shadow-ih-focus outline-none text-[14px] font-medium transition-colors" + /> + {open && suggestions.length > 0 && ( +
    + {suggestions.map((s, i) => ( +
  • { + e.preventDefault(); + choose(s); + }} + onMouseEnter={() => setActive(i)} + className={`px-3 py-2 cursor-pointer text-[13px] ${i === active ? "bg-ih-primary-tint text-ih-primary" : "text-ih-fg-2"}`} + > + {s.line1} + {s.city && ( + + {" "} + {s.city} + {s.state ? `, ${s.state}` : ""} + {s.zip ? ` ${s.zip}` : ""} + + )} +
  • + ))} +
+ )} +

{m.booking_field_address_hint()}

+
+ ); +} diff --git a/app/components/booking/useBookingFormState.ts b/app/components/booking/useBookingFormState.ts index 7c6846b94..6b603d7da 100644 --- a/app/components/booking/useBookingFormState.ts +++ b/app/components/booking/useBookingFormState.ts @@ -1,6 +1,7 @@ import { useState, useMemo, useRef, useEffect } from "react"; import { useFetcher } from "react-router"; import type { CompanyProfile } from "./booking-constants"; +import type { PublicAddressSuggestion } from "./PublicAddressAutocomplete"; import { resolveOrderDeposit } from "../../../server/lib/billing/deposit-policy"; import { m } from "~/paraglide/messages"; @@ -21,6 +22,10 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug // Form state const [address, setAddress] = useState(""); + // The structured half of the address, set only when the visitor picks a + // suggestion. Null for a typed address — which is a real outcome the server + // reports, not a value to invent. + const [addressPick, setAddressPick] = useState(null); const [selectedServices, setSelectedServices] = useState>(new Set()); const [inspectionDate, setInspectionDate] = useState(""); const [timeWindow, setTimeWindow] = useState("morning"); @@ -180,6 +185,12 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug body: JSON.stringify({ tenant, address, + // Sent only when a suggestion was picked. The server re-resolves the + // placeId through Places Details for the authoritative ZIP and the + // coordinates; the zip here is the client-side hint and the fallback + // when details cannot be reached. + ...(addressPick?.zip ? { addressZip: addressPick.zip } : {}), + ...(addressPick?.placeId ? { addressPlaceId: addressPick.placeId } : {}), date: inspectionDate, timeSlot: timeWindow === "custom" ? "custom" : timeWindow, ...(timeWindow === "custom" ? { customTime } : {}), @@ -218,6 +229,7 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug return { step, setStep, address, setAddress, + addressPick, setAddressPick, selectedServices, inspectionDate, setInspectionDate, timeWindow, setTimeWindow, diff --git a/app/components/settings/BookingRoutingPanel.test.tsx b/app/components/settings/BookingRoutingPanel.test.tsx new file mode 100644 index 000000000..34a21d21c --- /dev/null +++ b/app/components/settings/BookingRoutingPanel.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; +import { BookingRoutingPanel, type BookingRoutingConfig } from "./BookingRoutingPanel"; +import { InspectorServiceAreasPanel, parseZipList } from "./InspectorServiceAreasPanel"; + +/** + * The point of these panels is that they REFUSE to present a strategy as live + * when it could not run. A snapshot of "three radios rendered" would pass + * against the exact bug this feature exists to remove, so every assertion here + * is about the readiness sentence, not about the controls. + */ +const base: BookingRoutingConfig = { + routingStrategy: "closest", + minLeadHours: 0, + sameDayCutoffTime: null, + companyAddress: "1 Main St, Austin, TX", + companyLat: null, + companyLng: null, + geocodeAvailable: true, + originCount: 0, +}; + +function mount(initial: BookingRoutingConfig, anchored = 0) { + const Stub = createRoutesStub([ + { path: "/", Component: () => }, + ]); + return render(); +} + +describe("BookingRoutingPanel states the blocker before the radio is trusted", () => { + it("says closest cannot run when nothing has been located", async () => { + mount(base); + expect(await screen.findByText(/cannot compare anyone/i)).toBeTruthy(); + }); + + it("says closest cannot run when Places is not configured, and prefers that reason", async () => { + mount({ ...base, geocodeAvailable: false }); + expect(await screen.findByText(/needs a Google Places key/i)).toBeTruthy(); + }); + + it("says so when only ONE inspector is anchored — a comparison of one is not a comparison", async () => { + mount({ ...base, companyLat: 30.26, companyLng: -97.74 }, 1); + expect(await screen.findByText(/Only one inspector has a start address/i)).toBeTruthy(); + }); + + it("shows no blocker once two inspectors are anchored", async () => { + mount({ ...base, companyLat: 30.26, companyLng: -97.74 }, 2); + expect(await screen.findByText(/located at 30\.2600, -97\.7400/i)).toBeTruthy(); + expect(screen.queryByText(/cannot compare anyone/i)).toBeNull(); + expect(screen.queryByText(/Only one inspector/i)).toBeNull(); + }); + + it("first_available never carries a blocker — it always works", async () => { + mount({ ...base, routingStrategy: "first_available" }); + expect(await screen.findByText(/Routing & booking rules/i)).toBeTruthy(); + expect(screen.queryByText(/cannot compare anyone/i)).toBeNull(); + }); + + it("an unlocated company address is labelled as such, not left ambiguous", async () => { + mount(base); + expect(await screen.findByText(/not located yet/i)).toBeTruthy(); + }); +}); + +describe("InspectorServiceAreasPanel", () => { + const members = [ + { id: "u1", email: "ann@x.com", zipPrefixes: ["78701"], originAddress: null, originLocated: false }, + { id: "u2", email: "bea@x.com", zipPrefixes: [], originAddress: "500 Main", originLocated: true }, + ]; + + it("states the empty-ZIP meaning rather than showing a blank box", async () => { + const Stub = createRoutesStub([ + { path: "/", Component: () => }, + ]); + render(); + expect(await screen.findByText(/serves all areas/i)).toBeTruthy(); + }); + + it("says an inspector with no override starts from the company address", async () => { + const Stub = createRoutesStub([ + { path: "/", Component: () => }, + ]); + render(); + expect(await screen.findByText(/Starts from the company address/i)).toBeTruthy(); + }); + + it("renders an honest empty state when the workspace has no schedulable staff", async () => { + const Stub = createRoutesStub([ + { path: "/", Component: () => }, + ]); + render(); + expect(await screen.findByText(/No inspectors yet/i)).toBeTruthy(); + }); + + it("parseZipList normalizes case, whitespace and duplicates", () => { + expect(parseZipList(" 78701, 787 ,, 78701 \n m5v ")).toEqual(["78701", "787", "M5V"]); + expect(parseZipList("")).toEqual([]); + }); +}); diff --git a/app/components/settings/BookingRoutingPanel.tsx b/app/components/settings/BookingRoutingPanel.tsx new file mode 100644 index 000000000..ffba3b0ef --- /dev/null +++ b/app/components/settings/BookingRoutingPanel.tsx @@ -0,0 +1,195 @@ +import { useState } from "react"; +import { useFetcher } from "react-router"; +import { Banner, Input, RadioCardGroup } from "@core/shared-ui"; +import type { action } from "~/routes/settings-booking"; +import { m } from "~/paraglide/messages"; + +export type RoutingStrategy = "first_available" | "least_loaded" | "closest"; + +export interface BookingRoutingConfig { + routingStrategy: RoutingStrategy; + minLeadHours: number; + sameDayCutoffTime: string | null; + companyAddress: string | null; + companyLat: number | null; + companyLng: number | null; + geocodeAvailable: boolean; + /** Inspectors with their own start address; everyone else inherits the company one. */ + originCount: number; +} + +/** + * Routing strategy + booking rules. + * + * The part worth defending: `closest` cannot work without coordinates, and a + * radio button that silently does nothing is the exact failure this whole + * change exists to remove. So the readiness of each strategy is stated NEXT TO + * the option, before it is chosen — and the company geocode is an explicit + * button with a visible result rather than a side effect of saving an + * unrelated field. + * + * Named `BookingRoutingPanel`, not `BookingRulesPanel`: this page already + * carries `BookingSlotRulesPanel` and `BookingPoliciesPanel`, and a third + * "rules" panel would be indistinguishable from both in a file list. + */ +export function BookingRoutingPanel({ + initial, + anchoredInspectorCount, +}: { + initial: BookingRoutingConfig; + /** Inspectors that would have a service origin — company-inherited or their own. */ + anchoredInspectorCount: number; +}) { + const fetcher = useFetcher(); + const geocodeFetcher = useFetcher(); + const [strategy, setStrategy] = useState(initial.routingStrategy); + const [leadHours, setLeadHours] = useState(String(initial.minLeadHours)); + const [cutoff, setCutoff] = useState(initial.sameDayCutoffTime ?? ""); + const [dirty, setDirty] = useState(false); + + const saving = fetcher.state !== "idle"; + const done = fetcher.state === "idle" && fetcher.data?.intent === "routing-save" && !dirty; + const saved = done && fetcher.data?.ok === true; + const failed = done && fetcher.data?.ok === false; + + const hasCompanyAnchor = initial.companyLat !== null && initial.companyLng !== null; + const geocoding = geocodeFetcher.state !== "idle"; + const geocodeResult = + geocodeFetcher.state === "idle" && geocodeFetcher.data?.intent === "routing-geocode-company" + ? geocodeFetcher.data + : null; + + // Why the chosen strategy would NOT run today. Null when it will. + const blocker: string | null = + strategy === "closest" && !initial.geocodeAvailable + ? m.settings_routing_closest_no_places() + : strategy === "closest" && !hasCompanyAnchor && initial.originCount === 0 + ? m.settings_routing_closest_no_anchor() + : strategy === "closest" && anchoredInspectorCount < 2 + ? m.settings_routing_closest_one_anchor() + : null; + + function handleSave() { + setDirty(false); + fetcher.submit( + { + intent: "routing-save", + routingStrategy: strategy, + minLeadHours: String(Math.max(0, Number(leadHours) || 0)), + sameDayCutoffTime: cutoff, + }, + { method: "post" }, + ); + } + + return ( +
+
+

+ {m.settings_routing_heading()} +

+

{m.settings_routing_desc()}

+
+ +
+ { + setStrategy(v as RoutingStrategy); + setDirty(true); + }} + options={[ + { value: "first_available", title: m.settings_routing_first_available(), description: m.settings_routing_first_available_desc() }, + { value: "least_loaded", title: m.settings_routing_least_loaded(), description: m.settings_routing_least_loaded_desc() }, + { value: "closest", title: m.settings_routing_closest(), description: m.settings_routing_closest_desc() }, + ]} + /> + {/* Banner, not a bare

: this is the sentence that stops someone + trusting a radio that would do nothing, and `tone="warn"` also + gives it role="alert" so a screen reader hears it when the + selection changes. An earlier version used invented + `ih-warn-*` classes, which Tailwind dropped silently and + `lint:ds` did not catch — the text rendered unstyled. */} + {blocker && {blocker}} +

+ + {/* The anchor `closest` measures from. Shown for every strategy, because + knowing the workspace is locatable is useful before choosing one. */} +
+

{m.settings_routing_company_anchor_label()}

+ {initial.companyAddress ? ( +

+ {initial.companyAddress} + {" — "} + {hasCompanyAnchor + ? m.settings_routing_anchor_located({ + lat: initial.companyLat!.toFixed(4), + lng: initial.companyLng!.toFixed(4), + }) + : m.settings_routing_anchor_missing()} +

+ ) : ( +

{m.settings_routing_anchor_no_address()}

+ )} + + + + + {geocodeResult && ( +

+ {geocodeResult.message ?? m.settings_holiday_save_failed()} +

+ )} +
+ +
+ { + setLeadHours(e.target.value); + setDirty(true); + }} + /> + { + setCutoff(e.target.value); + setDirty(true); + }} + /> +
+ +
+ + {saved && {m.settings_holiday_saved()}} + {failed && ( + + {fetcher.data?.message ?? m.settings_holiday_save_failed()} + + )} +
+
+ ); +} diff --git a/app/components/settings/InspectorServiceAreasPanel.tsx b/app/components/settings/InspectorServiceAreasPanel.tsx new file mode 100644 index 000000000..a4812add6 --- /dev/null +++ b/app/components/settings/InspectorServiceAreasPanel.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { useFetcher } from "react-router"; +import { Input, Select } from "@core/shared-ui"; +import type { action } from "~/routes/settings-booking"; +import { m } from "~/paraglide/messages"; + +export interface ServiceAreaMember { + id: string; + email: string; + /** ZIP prefixes this inspector serves. Empty = serves everywhere. */ + zipPrefixes: string[]; + /** Their own start address, or null when inheriting the company one. */ + originAddress: string | null; + /** True when that override actually resolved to coordinates. */ + originLocated: boolean; +} + +/** "78701, 787 ,, 73301" -> ["78701","787","73301"] */ +export function parseZipList(raw: string): string[] { + return [...new Set( + raw.split(/[,\s]+/).map((z) => z.trim().toUpperCase()).filter(Boolean), + )]; +} + +/** + * Per-inspector territory + service origin. + * + * Two settings, one panel, because they answer the same question from two + * sides: WHERE will this person travel, and WHERE do they start. Splitting + * them would put the ZIP list next to routing and the origin next to the + * profile, and nobody would find the second one. + * + * The empty state is load-bearing and stated, not implied: no ZIPs means + * serves everywhere, which is what the server does and what a workspace that + * never opens this panel gets. + */ +export function InspectorServiceAreasPanel({ members }: { members: ServiceAreaMember[] }) { + const fetcher = useFetcher(); + const [selectedId, setSelectedId] = useState(members[0]?.id ?? ""); + const selected = members.find((x) => x.id === selectedId) ?? null; + + const [zips, setZips] = useState(selected?.zipPrefixes.join(", ") ?? ""); + const [origin, setOrigin] = useState(selected?.originAddress ?? ""); + + // Switching inspector must load THEIR values, not keep the last person's — + // a stale box here saves one inspector's territory onto another. + useEffect(() => { + const next = members.find((x) => x.id === selectedId) ?? null; + setZips(next?.zipPrefixes.join(", ") ?? ""); + setOrigin(next?.originAddress ?? ""); + }, [selectedId, members]); + + const saving = fetcher.state !== "idle"; + const result = + fetcher.state === "idle" && + (fetcher.data?.intent === "service-areas-save" || fetcher.data?.intent === "service-origin-save") + ? fetcher.data + : null; + + if (members.length === 0) { + return ( +
+

+ {m.settings_serviceareas_heading()} +

+

{m.settings_serviceareas_no_members()}

+
+ ); + } + + const parsed = parseZipList(zips); + + return ( +
+
+

+ {m.settings_serviceareas_heading()} +

+

{m.settings_serviceareas_desc()}

+
+ +
+ setZips(e.target.value)} + /> +

+ {parsed.length === 0 + ? m.settings_serviceareas_empty_state() + : m.settings_serviceareas_parsed({ list: parsed.join(", ") })} +

+ +
+ +
+ setOrigin(e.target.value)} + /> +

+ {origin.trim() === "" + ? m.settings_serviceareas_origin_inherits() + : selected?.originLocated + ? m.settings_serviceareas_origin_located() + : m.settings_serviceareas_origin_unlocated()} +

+ +
+ + {result && ( +

+ {result.message ?? (result.ok ? m.settings_holiday_saved() : m.settings_holiday_save_failed())} +

+ )} +
+ ); +} diff --git a/app/lib/settings/booking-routing-actions.ts b/app/lib/settings/booking-routing-actions.ts new file mode 100644 index 000000000..190ad322a --- /dev/null +++ b/app/lib/settings/booking-routing-actions.ts @@ -0,0 +1,122 @@ +import { m } from "~/paraglide/messages"; + +/** + * The four routing / territory intents of `/settings/booking`. + * + * Extracted from the route because that file crossed the 400-line size gate + * when they landed, and these four are the cohesive unit: all of them talk to + * one admin sub-router, and three of them share the same "a lookup that + * resolved nothing must say WHICH nothing" post-processing. + * + * Returns `null` for an intent it does not own, so the route keeps its + * existing if-chain shape and the ownership stays obvious at the call site. + */ + +export interface BookingActionResult { + ok: boolean; + intent: string; + message?: string | undefined; + // The route's other intents return `holiday` / `deletedId`. TypeScript + // normalizes a union of object LITERALS by adding `prop?: undefined` to the + // members that lack each key — but a declared interface gets no such + // treatment, so without these two the union stops exposing `.message` to + // every panel on the page. Declared here rather than fixed at eight call + // sites. + holiday?: undefined; + deletedId?: undefined; +} + +/** hono/client returns a ClientResponse, which is not assignable to Response. */ +interface JsonReadable { + ok: boolean; + json: () => Promise; +} + +interface GeocodeBody { + data?: { resolved?: boolean; formatted?: string | null; reason?: string | null }; +} + +async function errorMessage(res: JsonReadable): Promise { + const err = await res.json().catch(() => null); + return ((err as Record> | null)?.error?.message) as string | undefined; +} + +/** A geocode that resolved nothing always says WHICH nothing. */ +function geocodeFailureMessage(reason: string | null): string { + if (reason === "no_api_key") return m.settings_routing_geocode_no_key(); + if (reason === "no_address") return m.settings_routing_geocode_no_address(); + return m.settings_routing_geocode_not_found(); +} + +/** The subset of the typed API client these intents use. */ +interface RoutingApi { + admin: { + "booking-routing": { + $patch: (args: { json: Record }) => Promise; + "geocode-company": { $post: () => Promise }; + "service-origin": { $put: (args: { json: Record }) => Promise }; + }; + "service-areas": { + $put: (args: { json: Record }) => Promise; + }; + }; +} + +export async function handleBookingRoutingIntent( + api: unknown, + form: FormData, + intent: string, +): Promise { + const client = api as RoutingApi; + + if (intent === "routing-save") { + const cutoffRaw = String(form.get("sameDayCutoffTime") ?? "").trim(); + const res = await client.admin["booking-routing"].$patch({ + json: { + routingStrategy: String(form.get("routingStrategy") ?? "first_available"), + minLeadHours: Math.max(0, Number(form.get("minLeadHours") ?? 0) || 0), + // An empty box is an explicit CLEAR here, because the panel always + // sends the field. Omitting it would mean "leave alone", which is not + // what a user who just emptied the input asked for. + sameDayCutoffTime: cutoffRaw === "" ? null : cutoffRaw, + }, + }); + return { ok: res.ok, intent, message: res.ok ? undefined : await errorMessage(res) }; + } + + if (intent === "routing-geocode-company") { + const res = await client.admin["booking-routing"]["geocode-company"].$post(); + if (!res.ok) return { ok: false, intent, message: await errorMessage(res) }; + const body = (await res.json()) as GeocodeBody; + // A lookup that found nothing is a 200 with a reason — the outcome belongs + // on the page, not swallowed into a generic success. + if (!body.data?.resolved) { + return { ok: false, intent, message: geocodeFailureMessage(body.data?.reason ?? null) }; + } + return { ok: true, intent, message: m.settings_routing_located({ address: body.data.formatted ?? "" }) }; + } + + if (intent === "service-areas-save") { + const zipPrefixes = String(form.get("zipPrefixes") ?? "") + .split(",").map((z) => z.trim().toUpperCase()).filter(Boolean); + const res = await client.admin["service-areas"].$put({ + json: { userId: String(form.get("userId") ?? ""), zipPrefixes }, + }); + return { ok: res.ok, intent, message: res.ok ? undefined : await errorMessage(res) }; + } + + if (intent === "service-origin-save") { + const address = String(form.get("address") ?? "").trim(); + const res = await client.admin["booking-routing"]["service-origin"].$put({ + json: { userId: String(form.get("userId") ?? ""), address: address === "" ? null : address }, + }); + if (!res.ok) return { ok: false, intent, message: await errorMessage(res) }; + const body = (await res.json()) as GeocodeBody; + if (address === "") return { ok: true, intent, message: m.settings_serviceareas_origin_cleared() }; + return body.data?.resolved + ? { ok: true, intent, message: m.settings_routing_located({ address: body.data.formatted ?? "" }) } + : { ok: false, intent, message: geocodeFailureMessage(body.data?.reason ?? null) }; + } + + return null; +} diff --git a/app/lib/settings/booking-routing-data.ts b/app/lib/settings/booking-routing-data.ts new file mode 100644 index 000000000..d2c42de72 --- /dev/null +++ b/app/lib/settings/booking-routing-data.ts @@ -0,0 +1,96 @@ +import type { BookingRoutingConfig, RoutingStrategy } from "~/components/settings/BookingRoutingPanel"; +import type { ServiceAreaMember } from "~/components/settings/InspectorServiceAreasPanel"; + +/** + * Reading the routing surface: the loader's parse of `GET /booking-routing` + * and `GET /service-areas/all`, plus the derivation the page needs from both. + * + * Lives beside `booking-routing-actions` (the write half) and out of the route + * file, which crossed the 400-line size gate when this feature landed. + */ + +/** Per-inspector service-origin override as the routing endpoint returns it. */ +export interface RoutingOrigin { + userId: string; + address: string | null; + lat: number | null; + lng: number | null; +} + +export const EMPTY_ROUTING: BookingRoutingConfig = { + routingStrategy: "first_available", + minLeadHours: 0, + sameDayCutoffTime: null, + companyAddress: null, + companyLat: null, + companyLng: null, + geocodeAvailable: false, + originCount: 0, +}; + +function parseStrategy(raw: unknown): RoutingStrategy { + return raw === "least_loaded" || raw === "closest" ? raw : "first_available"; +} + +/** Shape one `GET /booking-routing` body. Anything unreadable stays default. */ +export function parseRoutingBody( + raw: unknown, +): { routing: BookingRoutingConfig; origins: RoutingOrigin[] } { + const d = ((raw as { data?: Record } | null)?.data) ?? {}; + const origins = (d.origins as RoutingOrigin[] | undefined) ?? []; + return { + origins, + routing: { + routingStrategy: parseStrategy(d.routingStrategy), + minLeadHours: Number(d.minLeadHours ?? 0), + sameDayCutoffTime: typeof d.sameDayCutoffTime === "string" ? d.sameDayCutoffTime : null, + companyAddress: typeof d.companyAddress === "string" ? d.companyAddress : null, + companyLat: typeof d.companyLat === "number" ? d.companyLat : null, + companyLng: typeof d.companyLng === "number" ? d.companyLng : null, + geocodeAvailable: Boolean(d.geocodeAvailable), + // Only a resolved override counts. A stored address that never geocoded + // is not an anchor, and counting it here would make the panel claim + // `closest` is ready when the strategy would report otherwise. + originCount: origins.filter((o) => o.lat !== null).length, + }, + }; +} + +/** Shape one `GET /service-areas/all` body into userId -> prefixes. */ +export function parseServiceAreaBody(raw: unknown): Record { + const rows = ((raw as { data?: Array<{ userId: string; zipPrefixes: string[] }> } | null)?.data) ?? []; + return Object.fromEntries(rows.map((r) => [r.userId, r.zipPrefixes])); +} + +/** Join members with their territory and origin for the panel. */ +export function buildServiceAreaMembers( + members: Array<{ id: string; email: string }>, + areasByUser: Record, + origins: RoutingOrigin[], +): ServiceAreaMember[] { + const byUser = new Map(origins.map((o) => [o.userId, o])); + return members.map((x) => { + const origin = byUser.get(x.id); + return { + id: x.id, + email: x.email, + zipPrefixes: areasByUser[x.id] ?? [], + originAddress: origin?.address ?? null, + originLocated: origin?.lat !== null && origin?.lat !== undefined, + }; + }); +} + +/** + * How many inspectors `closest` could actually measure from — their own + * resolved origin, or the company one they inherit. Fewer than two and the + * strategy has nothing to compare, which the panel says out loud instead of + * letting the radio look live. + */ +export function countAnchoredInspectors( + members: ServiceAreaMember[], + routing: BookingRoutingConfig, +): number { + const companyAnchored = routing.companyLat !== null && routing.companyLng !== null; + return members.filter((x) => x.originLocated || companyAnchored).length; +} diff --git a/app/routes/settings-booking.tsx b/app/routes/settings-booking.tsx index 7613132fd..dfe5086f9 100644 --- a/app/routes/settings-booking.tsx +++ b/app/routes/settings-booking.tsx @@ -22,6 +22,16 @@ import { type HolidayPublicPolicy, } from "~/components/settings/HolidayClosedPanel"; import { getHolidayDataCoverage } from "../../server/lib/holidays/resolve-closed-dates"; +import { BookingRoutingPanel } from "~/components/settings/BookingRoutingPanel"; +import { InspectorServiceAreasPanel } from "~/components/settings/InspectorServiceAreasPanel"; +import { handleBookingRoutingIntent } from "~/lib/settings/booking-routing-actions"; +import { + EMPTY_ROUTING, + buildServiceAreaMembers, + countAnchoredInspectors, + parseRoutingBody, + parseServiceAreaBody, +} from "~/lib/settings/booking-routing-data"; import { parseDepositPolicy } from "~/lib/deposit-policy-form"; import type { DepositPolicy } from "../../server/lib/billing/deposit-policy"; import { m } from "~/paraglide/messages"; @@ -57,6 +67,8 @@ function parseInternalPolicy(raw: unknown): HolidayInternalPolicy { return raw === "block" ? "block" : "advisory"; } + + export function meta() { return [{ title: m.settings_booking_meta_title() }]; } @@ -68,7 +80,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const api = createApi(context, { token }); - const [configRes, membersRes, holidaysRes, brandingRes] = await Promise.all([ + const [configRes, membersRes, holidaysRes, brandingRes, routingRes, areasRes] = await Promise.all([ api.admin["tenant-config"].$get().catch(() => null), api.admin.members.$get().catch(() => null), (api.admin as unknown as { @@ -78,6 +90,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { // policies above, but it is written through branding and is missing from // the tenant-config projection — so it is read from where it is written. api.adminBranding.branding.$get().catch(() => null), + api.admin["booking-routing"].$get().catch(() => null), + api.admin["service-areas"].all.$get().catch(() => null), ]); let config: TenantConfig = { @@ -126,7 +140,18 @@ export async function loader({ request, context }: Route.LoaderArgs) { customHolidays = body.data?.holidays ?? []; } + // Routing configuration + the two anchors `closest` depends on. Its own + // endpoint rather than the tenant-config projection: the geocode actions call + // an external API and have no business in a generic settings PATCH. + const parsed = routingRes?.ok + ? parseRoutingBody(await routingRes.json()) + : { routing: EMPTY_ROUTING, origins: [] }; + const areasByUser = areasRes?.ok ? parseServiceAreaBody(await areasRes.json()) : {}; + return { + routing: parsed.routing, + origins: parsed.origins, + areasByUser, config, members, customHolidays, @@ -143,6 +168,12 @@ export async function action({ request, context }: Route.ActionArgs) { const form = await request.formData(); const intent = String(form.get("intent")); + // Routing, territories and the two geocode actions live together in + // ~/lib/settings/booking-routing-actions — they share one admin sub-router + // and one "a lookup that resolved nothing says which nothing" rule. + const routed = await handleBookingRoutingIntent(api, form, intent); + if (routed) return routed; + if (intent === "policies-save") { const res = await api.admin["tenant-config"].$patch({ json: { @@ -247,7 +278,7 @@ export async function action({ request, context }: Route.ActionArgs) { return { ok: false, intent, message }; } const body = (await res.json()) as { data?: { holiday?: CustomHoliday } }; - return { ok: true, intent, holiday: body.data?.holiday }; + return { ok: true, intent, message: undefined, holiday: body.data?.holiday }; } if (intent === "holiday-custom-delete") { @@ -267,7 +298,7 @@ export async function action({ request, context }: Route.ActionArgs) { | undefined; return { ok: false, intent, message }; } - return { ok: true, intent, deletedId: id }; + return { ok: true, intent, message: undefined, deletedId: id }; } return { ok: false, intent }; @@ -287,9 +318,16 @@ export default function SettingsBookingPage() { { id: "booking-policies", label: m.settings_policies_heading() }, { id: "holidays", label: m.settings_holiday_panel_heading() }, { id: "slot-rules", label: m.settings_slotrules_heading() }, + { id: "routing", label: m.settings_routing_heading() }, + { id: "service-areas", label: m.settings_serviceareas_heading() }, { id: "embed-widget", label: m.settings_embed_heading() }, ]; + const serviceAreaMembers = buildServiceAreaMembers( + schedulingMembers, data.areasByUser, data.origins, + ); + const anchoredInspectorCount = countAnchoredInspectors(serviceAreaMembers, data.routing); + return (
@@ -330,6 +368,15 @@ export default function SettingsBookingPage() { }} />
+
+ +
+
+ +
diff --git a/messages/en/booking.json b/messages/en/booking.json index 3b9a34582..9ee1139e5 100644 --- a/messages/en/booking.json +++ b/messages/en/booking.json @@ -25,6 +25,7 @@ "booking_embed_phone_placeholder": "(555) 555-5555", "booking_embed_date_label": "Preferred date", "booking_field_address_label": "Property address", + "booking_field_address_hint": "Start typing and pick your address so we can match you with an inspector who covers it.", "booking_field_email_label": "Email", "booking_field_inspector_label": "Inspector", "booking_placeholder_name": "Jane Doe", diff --git a/messages/en/settings-components.json b/messages/en/settings-components.json index 935e38439..b2f59b9bb 100644 --- a/messages/en/settings-components.json +++ b/messages/en/settings-components.json @@ -195,6 +195,52 @@ "settings_slotrules_open_desc": "Starts snap to the clock (e.g. :00 / :30) at every interval inside each window.", "settings_slotrules_interval_label": "Slot interval", "settings_slotrules_save": "Save slot rules", + "settings_routing_heading": "Routing & booking rules", + "settings_routing_desc": "Who gets an auto-assigned booking, and how far ahead clients must book.", + "settings_routing_strategy_label": "Assign new bookings to", + "settings_routing_first_available": "First available", + "settings_routing_first_available_desc": "The free inspector who comes first by name. Predictable, and always available.", + "settings_routing_least_loaded": "Least loaded", + "settings_routing_least_loaded_desc": "The free inspector with the fewest inspections that week. Falls back to First available when nobody has dated work yet.", + "settings_routing_closest": "Closest to the property", + "settings_routing_closest_desc": "The free inspector whose start address is nearest. Needs coordinates for both the property and at least two inspectors.", + "settings_routing_closest_no_places": "Closest routing needs a Google Places key. Without one, no address can be located and bookings will use First available.", + "settings_routing_closest_no_anchor": "No address has been located yet, so Closest cannot compare anyone. Locate the company address below, or give inspectors their own start address.", + "settings_routing_closest_one_anchor": "Only one inspector has a start address, so there is nothing to compare. Bookings will use First available until a second one does.", + "settings_routing_company_anchor_label": "Company start address", + "settings_routing_anchor_located": "located at {lat}, {lng}", + "settings_routing_anchor_missing": "not located yet", + "settings_routing_anchor_no_address": "No company address is set. Add one under Company settings, then locate it here.", + "settings_routing_locate": "Locate this address", + "settings_routing_locating": "Locating…", + "settings_routing_located": "Located: {address}", + "settings_routing_geocode_no_key": "Address lookup is not configured on this deployment (no Google Places key).", + "settings_routing_geocode_no_address": "There is no address to locate yet.", + "settings_routing_geocode_not_found": "That address could not be located. Check the spelling, or add the city and state.", + "settings_routing_lead_label": "Minimum notice (hours)", + "settings_routing_lead_hint": "0 means a client may book the next open slot.", + "settings_routing_cutoff_label": "Same-day cutoff", + "settings_routing_cutoff_hint": "After this time, today stops being bookable. Leave empty for no cutoff.", + "settings_routing_save": "Save routing & rules", + "settings_serviceareas_heading": "Inspector territories", + "settings_serviceareas_desc": "Which ZIP codes each inspector will travel to, and where their day starts.", + "settings_serviceareas_no_members": "No inspectors yet. Territories appear here once your team has schedulable members.", + "settings_serviceareas_inspector_label": "Inspector", + "settings_serviceareas_option_with_zips": "{email} — {count} ZIP codes", + "settings_serviceareas_option_all_areas": "{email} — all areas", + "settings_serviceareas_zips_label": "ZIP codes served", + "settings_serviceareas_zips_hint": "Comma separated. A 3-digit entry covers the whole range, so 787 covers every 787xx.", + "settings_serviceareas_empty_state": "No ZIP codes means this inspector serves all areas.", + "settings_serviceareas_parsed": "Will be saved as: {list}", + "settings_serviceareas_save": "Save territory", + "settings_serviceareas_origin_label": "Start address", + "settings_serviceareas_origin_placeholder": "Leave empty to start from the company address", + "settings_serviceareas_origin_hint": "Used by Closest routing to measure the drive. Only needed for a second office or a home-based inspector.", + "settings_serviceareas_origin_inherits": "Starts from the company address.", + "settings_serviceareas_origin_located": "This address has been located and is used by Closest routing.", + "settings_serviceareas_origin_unlocated": "This address is saved but could not be located, so Closest routing skips this inspector.", + "settings_serviceareas_origin_save": "Save start address", + "settings_serviceareas_origin_cleared": "Start address cleared — this inspector starts from the company address.", "settings_embed_style_light": "Light", "settings_embed_style_dark": "Dark", "settings_embed_style_branded": "Branded", diff --git a/messages/es-419/booking.json b/messages/es-419/booking.json index cda34ff1b..f0462eacd 100644 --- a/messages/es-419/booking.json +++ b/messages/es-419/booking.json @@ -25,6 +25,7 @@ "booking_embed_phone_placeholder": "(555) 555-5555", "booking_embed_date_label": "Fecha preferida", "booking_field_address_label": "Dirección de la propiedad", + "booking_field_address_hint": "Empiece a escribir y elija su dirección para que podamos asignarle un inspector que cubra esa zona.", "booking_field_email_label": "Correo electrónico", "booking_field_inspector_label": "Inspector", "booking_placeholder_name": "Ana Pérez", diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index 5bd19ebbf..707ad7c4a 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -195,6 +195,52 @@ "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_routing_heading": "Asignación y reglas de reserva", + "settings_routing_desc": "Quién recibe una reserva asignada automáticamente y con cuánta anticipación deben reservar los clientes.", + "settings_routing_strategy_label": "Asignar las reservas nuevas a", + "settings_routing_first_available": "El primero disponible", + "settings_routing_first_available_desc": "El inspector libre que aparece primero por nombre. Predecible y siempre disponible.", + "settings_routing_least_loaded": "El de menor carga", + "settings_routing_least_loaded_desc": "El inspector libre con menos inspecciones esa semana. Vuelve a «El primero disponible» cuando nadie tiene trabajo con fecha todavía.", + "settings_routing_closest": "El más cercano a la propiedad", + "settings_routing_closest_desc": "El inspector libre cuya dirección de inicio está más cerca. Requiere coordenadas de la propiedad y de al menos dos inspectores.", + "settings_routing_closest_no_places": "La asignación por cercanía necesita una clave de Google Places. Sin ella no se puede ubicar ninguna dirección y las reservas usarán «El primero disponible».", + "settings_routing_closest_no_anchor": "Todavía no se ha ubicado ninguna dirección, así que «El más cercano» no puede comparar a nadie. Ubique la dirección de la empresa abajo o asigne a los inspectores su propia dirección de inicio.", + "settings_routing_closest_one_anchor": "Solo un inspector tiene dirección de inicio, así que no hay nada que comparar. Las reservas usarán «El primero disponible» hasta que haya un segundo.", + "settings_routing_company_anchor_label": "Dirección de inicio de la empresa", + "settings_routing_anchor_located": "ubicada en {lat}, {lng}", + "settings_routing_anchor_missing": "todavía sin ubicar", + "settings_routing_anchor_no_address": "No hay ninguna dirección de la empresa configurada. Agregue una en la configuración de la empresa y luego ubíquela aquí.", + "settings_routing_locate": "Ubicar esta dirección", + "settings_routing_locating": "Ubicando…", + "settings_routing_located": "Ubicada: {address}", + "settings_routing_geocode_no_key": "La búsqueda de direcciones no está configurada en esta instalación (falta la clave de Google Places).", + "settings_routing_geocode_no_address": "Todavía no hay ninguna dirección que ubicar.", + "settings_routing_geocode_not_found": "No se pudo ubicar esa dirección. Revise la ortografía o agregue la ciudad y el estado.", + "settings_routing_lead_label": "Anticipación mínima (horas)", + "settings_routing_lead_hint": "0 significa que un cliente puede reservar el siguiente horario libre.", + "settings_routing_cutoff_label": "Hora límite para el mismo día", + "settings_routing_cutoff_hint": "Después de esta hora, el día de hoy deja de ser reservable. Déjelo vacío para no poner límite.", + "settings_routing_save": "Guardar la asignación y las reglas", + "settings_serviceareas_heading": "Zonas de los inspectores", + "settings_serviceareas_desc": "A qué códigos postales viaja cada inspector y dónde empieza su día.", + "settings_serviceareas_no_members": "Todavía no hay inspectores. Las zonas aparecerán aquí cuando su equipo tenga miembros programables.", + "settings_serviceareas_inspector_label": "Inspector", + "settings_serviceareas_option_with_zips": "{email} — {count} códigos postales", + "settings_serviceareas_option_all_areas": "{email} — todas las zonas", + "settings_serviceareas_zips_label": "Códigos postales atendidos", + "settings_serviceareas_zips_hint": "Separados por comas. Una entrada de 3 dígitos cubre todo el rango, así que 787 cubre todos los 787xx.", + "settings_serviceareas_empty_state": "Sin códigos postales, este inspector atiende todas las zonas.", + "settings_serviceareas_parsed": "Se guardará como: {list}", + "settings_serviceareas_save": "Guardar la zona", + "settings_serviceareas_origin_label": "Dirección de inicio", + "settings_serviceareas_origin_placeholder": "Déjelo vacío para empezar desde la dirección de la empresa", + "settings_serviceareas_origin_hint": "La asignación por cercanía la usa para medir el trayecto. Solo hace falta para una segunda oficina o un inspector que trabaja desde casa.", + "settings_serviceareas_origin_inherits": "Empieza desde la dirección de la empresa.", + "settings_serviceareas_origin_located": "Esta dirección se ubicó y la usa la asignación por cercanía.", + "settings_serviceareas_origin_unlocated": "Esta dirección se guardó pero no se pudo ubicar, así que la asignación por cercanía omite a este inspector.", + "settings_serviceareas_origin_save": "Guardar la dirección de inicio", + "settings_serviceareas_origin_cleared": "Dirección de inicio borrada — este inspector empieza desde la dirección de la empresa.", "settings_embed_style_light": "Claro", "settings_embed_style_dark": "Oscuro", "settings_embed_style_branded": "Con marca", diff --git a/server/api/admin.ts b/server/api/admin.ts index 7988a6abb..081823550 100644 --- a/server/api/admin.ts +++ b/server/api/admin.ts @@ -28,6 +28,7 @@ import adminSettingsRoutes from './admin/admin-settings'; import adminConfigRoutes from './admin/admin-config'; import adminHolidayRoutes from './admin/admin-holidays'; import adminServiceAreasRoutes from './admin/admin-service-areas'; +import adminBookingRoutingRoutes from './admin/admin-booking-routing'; const adminRoutes = createApiRouter() .route('/', adminAgreementsRoutes) @@ -38,7 +39,8 @@ const adminRoutes = createApiRouter() .route('/', adminSettingsRoutes) .route('/', adminConfigRoutes) .route('/', adminHolidayRoutes) - .route('/', adminServiceAreasRoutes); + .route('/', adminServiceAreasRoutes) + .route('/', adminBookingRoutingRoutes); export type AdminApi = typeof adminRoutes; diff --git a/server/api/admin/admin-booking-routing.ts b/server/api/admin/admin-booking-routing.ts new file mode 100644 index 000000000..21d96a320 --- /dev/null +++ b/server/api/admin/admin-booking-routing.ts @@ -0,0 +1,248 @@ +// Admin → Booking routing, rules, and the two anchors `closest` depends on. +// +// Kept off `admin-settings.ts` deliberately. Every tenant_configs column that +// goes through that file touches four places in it, and it sits exactly on its +// 754-line size baseline — but the stronger reason is that the geocode actions +// here call an external API, which has no business inside a generic +// tenant-config PATCH that fires whenever anyone edits a colour. +// +// The geocode is an EXPLICIT action with a visible result. `closest` is +// unusable without coordinates, and a workspace that never resolved its +// address must be able to see that on the page rather than discover it as +// bookings quietly routing by name for months. +import { createRoute } from '@hono/zod-openapi'; +import { and, eq, isNotNull, or } from 'drizzle-orm'; +import { createApiRouter } from '../../lib/openapi-router'; +import { requireRole } from '../../lib/middleware/rbac'; +import { getDrizzle } from '../../lib/route-helpers'; +import { Errors } from '../../lib/errors'; +import { auditFromContext } from '../../lib/audit'; +import { tenantConfigs, users } from '../../lib/db/schema'; +import { geocodeAddressText } from '../../lib/places/geocode'; +import { isRoutingStrategy, type RoutingStrategy } from '../../lib/booking/routing'; +import { parseCutoffTime, parseMinLeadHours } from '../../lib/booking/booking-rules'; +import { + BookingRoutingSchema, + ServiceOriginSchema, + BookingRoutingResponseSchema, + GeocodeResultResponseSchema, +} from '../../lib/validations/admin/booking-routing'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; + +const ROLES = ['owner', 'manager'] as const; + +const getRoutingRoute = createRoute(withMcpMetadata({ + method: 'get', path: '/booking-routing', + tags: ['admin'], + summary: 'Booking routing strategy, rules, and geocode anchors', + middleware: [requireRole(...ROLES)] as const, + request: {}, + responses: { + 200: { content: { 'application/json': { schema: BookingRoutingResponseSchema } }, description: 'Routing configuration' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'getBookingRouting', + description: 'Returns the routing strategy, lead time and cutoff, plus whether the company address and per-inspector service origins have coordinates — which is what decides whether `closest` can run at all.', +}, { scopes: ['admin'], tier: 'extended' })); + +const patchRoutingRoute = createRoute(withMcpMetadata({ + method: 'patch', path: '/booking-routing', + tags: ['admin'], + summary: 'Update booking routing strategy and rules', + middleware: [requireRole(...ROLES)] as const, + request: { body: { content: { 'application/json': { schema: BookingRoutingSchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: BookingRoutingResponseSchema } }, description: 'Updated configuration' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'patchBookingRouting', + description: 'Patches routing strategy, minimum lead hours, and same-day cutoff. An omitted key is left alone; an explicit null on sameDayCutoffTime clears it.', +}, { scopes: ['admin'], tier: 'extended' })); + +const geocodeCompanyRoute = createRoute(withMcpMetadata({ + method: 'post', path: '/booking-routing/geocode-company', + tags: ['admin'], + summary: 'Resolve the company address to coordinates', + middleware: [requireRole(...ROLES)] as const, + request: {}, + responses: { + 200: { content: { 'application/json': { schema: GeocodeResultResponseSchema } }, description: 'Geocode outcome' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'geocodeCompanyAddress', + description: 'Resolves the stored company address once and saves the coordinates. Returns the matched address so a wrong match is visible. A failure is reported in the body with a named reason, never as a silent null.', +}, { scopes: ['admin'], tier: 'extended' })); + +const putServiceOriginRoute = createRoute(withMcpMetadata({ + method: 'put', path: '/booking-routing/service-origin', + tags: ['admin'], + summary: 'Set or clear one inspector service origin', + middleware: [requireRole(...ROLES)] as const, + request: { body: { content: { 'application/json': { schema: ServiceOriginSchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: GeocodeResultResponseSchema } }, description: 'Geocode outcome' }, + 404: { description: 'Inspector not found in this tenant' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'putInspectorServiceOrigin', + description: 'Geocodes and stores where one inspector starts their day. A null or empty address clears the override so they inherit the company coordinates.', +}, { scopes: ['admin'], tier: 'extended' })); + +type Db = ReturnType; + +async function readRouting(db: Db, tenantId: string, geocodeAvailable: boolean) { + const cfg = await db.select({ + routingStrategy: tenantConfigs.bookingRoutingStrategy, + minLeadHours: tenantConfigs.bookingMinLeadHours, + sameDayCutoffTime: tenantConfigs.bookingSameDayCutoffTime, + companyAddress: tenantConfigs.companyAddress, + companyLat: tenantConfigs.companyLat, + companyLng: tenantConfigs.companyLng, + companyGeocodedAt: tenantConfigs.companyGeocodedAt, + }).from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + + const originRows = await db.select({ + userId: users.id, + address: users.serviceOriginAddress, + lat: users.serviceOriginLat, + lng: users.serviceOriginLng, + }).from(users).where(and( + eq(users.tenantId, tenantId), + or(isNotNull(users.serviceOriginAddress), isNotNull(users.serviceOriginLat)), + )).all(); + + return { + routingStrategy: isRoutingStrategy(cfg?.routingStrategy) ? cfg.routingStrategy : 'first_available', + minLeadHours: parseMinLeadHours(cfg?.minLeadHours), + sameDayCutoffTime: parseCutoffTime(cfg?.sameDayCutoffTime), + companyAddress: cfg?.companyAddress ?? null, + companyLat: cfg?.companyLat ?? null, + companyLng: cfg?.companyLng ?? null, + companyGeocodedAt: cfg?.companyGeocodedAt ? new Date(cfg.companyGeocodedAt).toISOString() : null, + geocodeAvailable, + origins: originRows.map(r => ({ + userId: r.userId, + address: r.address ?? null, + lat: r.lat ?? null, + lng: r.lng ?? null, + })), + }; +} + +/** Ensure the tenant_configs row exists before an UPDATE that assumes it. */ +async function ensureConfigRow(db: Db, tenantId: string): Promise { + const row = await db.select({ tenantId: tenantConfigs.tenantId }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + if (!row) await db.insert(tenantConfigs).values({ tenantId, updatedAt: new Date() }); +} + +const adminBookingRoutingRoutes = createApiRouter() + .openapi(getRoutingRoute, async (c) => { + const data = await readRouting(getDrizzle(c), c.get('tenantId'), !!c.env.GOOGLE_PLACES_API_KEY); + return c.json({ success: true as const, data }, 200); + }) + .openapi(patchRoutingRoute, async (c) => { + const tenantId = c.get('tenantId'); + const body = c.req.valid('json'); + const db = getDrizzle(c); + await ensureConfigRow(db, tenantId); + + const update: Record = { updatedAt: new Date() }; + if (body.routingStrategy !== undefined) { + update.bookingRoutingStrategy = body.routingStrategy as RoutingStrategy; + } + if (body.minLeadHours !== undefined) update.bookingMinLeadHours = body.minLeadHours; + // `undefined` = untouched, `null` = cleared. Collapsing the two here is + // how a PATCH silently loses a field the caller never mentioned. + if (body.sameDayCutoffTime !== undefined) update.bookingSameDayCutoffTime = body.sameDayCutoffTime; + + await db.update(tenantConfigs).set(update).where(eq(tenantConfigs.tenantId, tenantId)); + auditFromContext(c, 'config.tenant_config.patch', 'tenant_config', { + entityId: tenantId, metadata: { bookingRouting: body }, + }); + return c.json({ + success: true as const, + data: await readRouting(db, tenantId, !!c.env.GOOGLE_PLACES_API_KEY), + }, 200); + }) + .openapi(geocodeCompanyRoute, async (c) => { + const tenantId = c.get('tenantId'); + const db = getDrizzle(c); + const apiKey = c.env.GOOGLE_PLACES_API_KEY; + const miss = (reason: 'no_api_key' | 'no_address' | 'not_found') => + c.json({ success: true as const, data: { resolved: false, formatted: null, lat: null, lng: null, reason } }, 200); + if (!apiKey) return miss('no_api_key'); + + const cfg = await db.select({ companyAddress: tenantConfigs.companyAddress }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const address = (cfg?.companyAddress ?? '').trim(); + if (!address) return miss('no_address'); + + const place = await geocodeAddressText(apiKey, address); + if (!place) return miss('not_found'); + + await db.update(tenantConfigs).set({ + companyLat: place.lat, + companyLng: place.lng, + companyGeocodedAt: new Date(), + updatedAt: new Date(), + }).where(eq(tenantConfigs.tenantId, tenantId)); + auditFromContext(c, 'config.tenant_config.patch', 'tenant_config', { + entityId: tenantId, metadata: { companyGeocode: place.formatted }, + }); + return c.json({ + success: true as const, + data: { resolved: true, formatted: place.formatted, lat: place.lat, lng: place.lng, reason: null }, + }, 200); + }) + .openapi(putServiceOriginRoute, async (c) => { + const tenantId = c.get('tenantId'); + const { userId, address } = c.req.valid('json'); + const db = getDrizzle(c); + + const member = await db.select({ id: users.id }).from(users) + .where(and(eq(users.id, userId), eq(users.tenantId, tenantId))).get(); + if (!member) throw Errors.NotFound('Inspector not found.'); + + const trimmed = (address ?? '').trim(); + if (trimmed === '') { + // Clearing all three columns is the ONLY way to inherit again — + // leaving a stale lat/lng behind would keep routing to an office + // the inspector no longer starts from. + await db.update(users) + .set({ serviceOriginAddress: null, serviceOriginLat: null, serviceOriginLng: null }) + .where(and(eq(users.id, userId), eq(users.tenantId, tenantId))); + auditFromContext(c, 'config.tenant_config.patch', 'user_service_origin', { entityId: userId }); + return c.json({ + success: true as const, + data: { resolved: false, formatted: null, lat: null, lng: null, reason: 'no_address' as const }, + }, 200); + } + + const apiKey = c.env.GOOGLE_PLACES_API_KEY; + const place = apiKey ? await geocodeAddressText(apiKey, trimmed) : null; + // The typed address is stored either way, so the setting is not lost + // when Google is unreachable — but without coordinates this inspector + // is simply not an input to `closest`, which the strategy reports. + await db.update(users).set({ + serviceOriginAddress: trimmed, + serviceOriginLat: place?.lat ?? null, + serviceOriginLng: place?.lng ?? null, + }).where(and(eq(users.id, userId), eq(users.tenantId, tenantId))); + auditFromContext(c, 'config.tenant_config.patch', 'user_service_origin', { + entityId: userId, metadata: { resolved: !!place }, + }); + return c.json({ + success: true as const, + data: { + resolved: !!place, + formatted: place?.formatted ?? null, + lat: place?.lat ?? null, + lng: place?.lng ?? null, + reason: place ? null : (apiKey ? 'not_found' as const : 'no_api_key' as const), + }, + }, 200); + }); + +export type AdminBookingRoutingApi = typeof adminBookingRoutingRoutes; +export default adminBookingRoutingRoutes; diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 7a8d1e6ff..cd8fce222 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -7100,6 +7100,22 @@ "summary": "Address autocomplete proxy (public, rate-limited)", "description": "Auto-generated placeholder for geocodeBooking (GET /geocode, bookings domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "geocodeCompanyAddress", + "method": "POST", + "pathTemplate": "/api/admin/booking-routing/geocode-company", + "scopes": [ + "admin" + ], + "tag": "admin", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": null + }, + "summary": "Resolve the company address to coordinates", + "description": "Resolves the stored company address once and saves the coordinates. Returns the matched address so a wrong match is visible. A failure is reported in the body with a named reason, never as a silent null." + }, { "operationId": "getAgentNotificationPreferences", "method": "GET", @@ -7247,6 +7263,22 @@ "summary": "Get combined sign & pay checkout context (public, token-gated)", "description": "Combined sign & pay context for the public checkout page (GET /checkout/:token, bookings domain). Resolves a signer token to the agreement snapshot, envelope progress, outstanding invoice/payment state, and tenant branding." }, + { + "operationId": "getBookingRouting", + "method": "GET", + "pathTemplate": "/api/admin/booking-routing", + "scopes": [ + "admin" + ], + "tag": "admin", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": null + }, + "summary": "Booking routing strategy, rules, and geocode anchors", + "description": "Returns the routing strategy, lead time and cutoff, plus whether the company address and per-inspector service origins have coordinates — which is what decides whether `closest` can run at all." + }, { "operationId": "getCancellationQuote", "method": "GET", @@ -12564,6 +12596,24 @@ "summary": "Patch automation for current tenant", "description": "Auto-generated placeholder for patchAutomation (PATCH /{id}, automations domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "patchBookingRouting", + "method": "PATCH", + "pathTemplate": "/api/admin/booking-routing", + "scopes": [ + "admin" + ], + "tag": "admin", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "$ref": "#/components/schemas/BookingRoutingPatch" + } + }, + "summary": "Update booking routing strategy and rules", + "description": "Patches routing strategy, minimum lead hours, and same-day cutoff. An omitted key is left alone; an explicit null on sameDayCutoffTime clears it." + }, { "operationId": "patchInspection", "method": "PATCH", @@ -15835,6 +15885,24 @@ "summary": "Publish inspection for current tenant", "description": "Auto-generated placeholder for publishInspection (POST /{id}/publish, inspections domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "putInspectorServiceOrigin", + "method": "PUT", + "pathTemplate": "/api/admin/booking-routing/service-origin", + "scopes": [ + "admin" + ], + "tag": "admin", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "$ref": "#/components/schemas/ServiceOrigin" + } + }, + "summary": "Set or clear one inspector service origin", + "description": "Geocodes and stores where one inspector starts their day. A null or empty address clears the override so they inherit the company coordinates." + }, { "operationId": "putIntegrationSecrets", "method": "PUT", diff --git a/server/lib/validations/admin/booking-routing.ts b/server/lib/validations/admin/booking-routing.ts new file mode 100644 index 000000000..e0cc542ae --- /dev/null +++ b/server/lib/validations/admin/booking-routing.ts @@ -0,0 +1,70 @@ +import { z } from '@hono/zod-openapi'; +import { ROUTING_STRATEGIES } from '../../booking/routing'; + +/** + * Booking routing + rules settings. + * + * These live on their own admin sub-router rather than threading through + * `admin-settings.ts`, whose GET schema, PATCH schema, response mapping and + * update handler would each need a new branch — in a file sitting exactly on + * its 754-line size baseline. A cohesive router is the extraction the + * file-size rule asks for, and it keeps the geocode actions (which call an + * external API) out of the generic tenant-config PATCH. + */ +export const BookingRoutingSchema = z.object({ + routingStrategy: z.enum(ROUTING_STRATEGIES as unknown as [string, ...string[]]).optional() + .openapi({ example: 'closest' }) + .describe('Which qualified inspector gets an auto-assigned booking.'), + minLeadHours: z.number().int().min(0).max(24 * 365).optional().openapi({ example: 24 }) + .describe('Hours of notice required before a slot may be booked. 0 = no requirement.'), + // `null` CLEARS the cutoff; an omitted key leaves it alone. The distinction + // matters: a PATCH that treated absence as null would silently drop a + // configured cutoff every time the routing radio was changed. + sameDayCutoffTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Use HH:MM (24h)').nullable().optional() + .openapi({ example: '15:00' }) + .describe('Wall-clock HH:MM in the tenant timezone after which today stops being bookable. Null clears it.'), +}).openapi('BookingRoutingPatch'); + +export const ServiceOriginSchema = z.object({ + userId: z.string().trim().min(1).openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }) + .describe('The inspector whose service origin is being set. Must belong to the caller tenant.'), + // Null or empty CLEARS the override, so the inspector inherits the company + // coordinates again. That inheritance is the default and the reason + // `closest` is usable with no per-person setup at all. + address: z.string().trim().max(300).nullable().openapi({ example: '500 W 2nd St, Austin, TX' }) + .describe('Free-text start address, geocoded on save. Null or empty clears the override and inherits the company address.'), +}).openapi('ServiceOrigin'); + +/** Why a geocode produced no coordinates. Never a silent null. */ +export const GEOCODE_FAILURE_REASONS = ['no_api_key', 'no_address', 'not_found'] as const; + +export const BookingRoutingResponseSchema = z.object({ + success: z.literal(true).describe('Always true on success.'), + data: z.object({ + routingStrategy: z.string().describe('Configured strategy.'), + minLeadHours: z.number().describe('Configured lead time in hours.'), + sameDayCutoffTime: z.string().nullable().describe('Configured cutoff, or null.'), + companyAddress: z.string().nullable().describe('The address the coordinates were resolved from.'), + companyLat: z.number().nullable().describe('Company latitude, or null when never resolved.'), + companyLng: z.number().nullable().describe('Company longitude, or null when never resolved.'), + companyGeocodedAt: z.string().nullable().describe('When the company address was last resolved (ISO).'), + geocodeAvailable: z.boolean().describe('False when GOOGLE_PLACES_API_KEY is unset, so `closest` cannot be made to work on this deployment.'), + origins: z.array(z.object({ + userId: z.string().describe('Inspector id.'), + address: z.string().nullable().describe('Their own start address, or null when inheriting the company one.'), + lat: z.number().nullable().describe('Resolved latitude of the override.'), + lng: z.number().nullable().describe('Resolved longitude of the override.'), + })).describe('Per-inspector service-origin overrides. Inspectors absent from this list inherit the company coordinates.'), + }).describe('Routing configuration and the anchors it depends on.'), +}).openapi('BookingRoutingResponse'); + +export const GeocodeResultResponseSchema = z.object({ + success: z.literal(true).describe('Always true on success — a failed LOOKUP is reported in the body, not as an HTTP error.'), + data: z.object({ + resolved: z.boolean().describe('Whether coordinates were stored.'), + formatted: z.string().nullable().describe('The address Google matched, so an owner can see a wrong match.'), + lat: z.number().nullable().describe('Stored latitude.'), + lng: z.number().nullable().describe('Stored longitude.'), + reason: z.enum(GEOCODE_FAILURE_REASONS).nullable().describe('Why nothing was stored. Null on success.'), + }).describe('Outcome of one geocode attempt.'), +}).openapi('GeocodeResult'); diff --git a/tests/unit/idempotency/booking-routing-replay.spec.ts b/tests/unit/idempotency/booking-routing-replay.spec.ts new file mode 100644 index 000000000..0617d584f --- /dev/null +++ b/tests/unit/idempotency/booking-routing-replay.spec.ts @@ -0,0 +1,202 @@ +/** + * The three mutating routes on the booking-routing settings surface. + * + * Two of them are settings writes and converge trivially; the interesting one + * is `POST /api/admin/booking-routing/geocode-company`, which calls Google. + * A retry there must not bill a second lookup NOR — the part a plain + * "returns 200 twice" test would miss — leave the workspace anchored to + * something different from what the first attempt returned. + * + * `PUT /booking-routing/service-origin` gets its own attention on the clear + * path: setting an address writes three columns and clearing must null all + * three, because a stale lat/lng left behind keeps routing to an office the + * inspector no longer starts from, with the UI showing no override at all. + */ +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 { OpenAPIHono } from '@hono/zod-openapi'; +import adminRoutes from '../../../server/api/admin'; +import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency'; +import { AppError } from '../../../server/lib/errors'; +import type { HonoConfig } from '../../../server/types/hono'; + +const TENANT = '11111111-1111-4111-8111-111111111111'; +const USER = '22222222-2222-4222-8222-222222222222'; + +let db: BetterSQLite3Database; +let fetchCalls: string[]; + +function buildApp() { + 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('userRole', 'owner'); + c.set('tenantId', TENANT); + c.set('user', { sub: USER } as never); + c.set('services', {} as HonoConfig['Variables']['services']); + await next(); + }); + app.use('*', idempotencyMiddleware({ getDb: () => db as never })); + app.route('/api/admin', adminRoutes); + return app; +} + +const ENV = { DB: {}, JWT_SECRET: 'test-secret', GOOGLE_PLACES_API_KEY: 'k' }; +const EXEC = { + waitUntil: (p: Promise) => { void Promise.resolve(p).catch(() => {}); }, + passThroughOnException: () => {}, +} as ExecutionContext; + +function call(path: string, method: string, body?: unknown, key?: string, env: Record = ENV) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (key) headers['Idempotency-Key'] = key; + return buildApp().request(path, { + method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }, env, EXEC); +} + +/** Google autocomplete + details, both answered from one stub. */ +function stubGoogle(lat: number, lng: number, formatted: string) { + fetchCalls = []; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + fetchCalls.push(String(url)); + if (String(url).includes('/autocomplete/')) { + return new Response(JSON.stringify({ status: 'OK', predictions: [{ place_id: 'p1' }] })); + } + return new Response(JSON.stringify({ + status: 'OK', + result: { + place_id: 'p1', + formatted_address: formatted, + address_components: [{ long_name: 'Austin', short_name: 'Austin', types: ['locality'] }], + geometry: { location: { lat, lng } }, + }, + })); + })); +} + +beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + vi.unstubAllGlobals(); + + await db.insert(schema.tenants).values({ + id: TENANT, name: 'A', slug: 'a', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT, companyAddress: '1 Main St, Austin, TX', updatedAt: new Date(), + }); + await db.insert(schema.users).values({ + id: USER, tenantId: TENANT, email: 'u@test.com', passwordHash: 'h', + role: 'inspector', name: 'Ann', createdAt: new Date(), + }); +}); + +const cfg = async () => (await db.select().from(schema.tenantConfigs) + .where(eq(schema.tenantConfigs.tenantId, TENANT)).get())!; +const user = async () => (await db.select().from(schema.users) + .where(eq(schema.users.id, USER)).get())!; + +describe("PATCH '/api/admin/booking-routing' — settings converge on replay", () => { + it('two patches under one key leave one strategy', async () => { + const a = await call('/api/admin/booking-routing', 'PATCH', { routingStrategy: 'closest', minLeadHours: 24 }, 'br-1'); + const b = await call('/api/admin/booking-routing', 'PATCH', { routingStrategy: 'closest', minLeadHours: 24 }, 'br-1'); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + const row = await cfg(); + expect(row.bookingRoutingStrategy).toBe('closest'); + expect(row.bookingMinLeadHours).toBe(24); + }); + + it('an omitted cutoff is left alone; an explicit null clears it', async () => { + await call('/api/admin/booking-routing', 'PATCH', { sameDayCutoffTime: '15:00' }); + expect((await cfg()).bookingSameDayCutoffTime).toBe('15:00'); + // Changing only the strategy must not drop the cutoff — the zod + // .partial()-shaped bug this codebase has already been bitten by. + await call('/api/admin/booking-routing', 'PATCH', { routingStrategy: 'least_loaded' }); + expect((await cfg()).bookingSameDayCutoffTime).toBe('15:00'); + await call('/api/admin/booking-routing', 'PATCH', { sameDayCutoffTime: null }); + expect((await cfg()).bookingSameDayCutoffTime).toBeNull(); + }); +}); + +describe("POST '/api/admin/booking-routing/geocode-company' — one anchor, one lookup", () => { + it('replays without a second Google call and stores the same coordinates', async () => { + stubGoogle(30.2672, -97.7431, '1 Main St, Austin, TX 78701, USA'); + const a = await call('/api/admin/booking-routing/geocode-company', 'POST', undefined, 'geo-1'); + const before = fetchCalls.length; + const b = await call('/api/admin/booking-routing/geocode-company', 'POST', undefined, 'geo-1'); + + expect(a.status).toBe(200); + expect(b.status).toBe(200); + expect(fetchCalls.length).toBe(before); // the replay never reached Google + const row = await cfg(); + expect(row.companyLat).toBeCloseTo(30.2672, 4); + expect(row.companyLng).toBeCloseTo(-97.7431, 4); + }); + + it('a missing API key is a NAMED body reason, not a 500 and not a silent null', async () => { + const res = await call('/api/admin/booking-routing/geocode-company', 'POST', undefined, undefined, { DB: {}, JWT_SECRET: 's' }); + expect(res.status).toBe(200); + const body = await res.json() as { data: { resolved: boolean; reason: string } }; + expect(body.data.resolved).toBe(false); + expect(body.data.reason).toBe('no_api_key'); + expect((await cfg()).companyLat).toBeNull(); + }); + + it('an unresolvable address reports not_found and leaves the old anchor untouched', async () => { + stubGoogle(30.2672, -97.7431, 'x'); + await call('/api/admin/booking-routing/geocode-company', 'POST'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ status: 'ZERO_RESULTS', predictions: [] })))); + const res = await call('/api/admin/booking-routing/geocode-company', 'POST'); + const body = await res.json() as { data: { reason: string } }; + expect(body.data.reason).toBe('not_found'); + expect((await cfg()).companyLat).toBeCloseTo(30.2672, 4); + }); +}); + +describe("PUT '/api/admin/booking-routing/service-origin' — set, replay, clear", () => { + it('a repeated set leaves exactly one origin', async () => { + stubGoogle(29.7604, -95.3698, '500 Main, Houston, TX'); + await call('/api/admin/booking-routing/service-origin', 'PUT', { userId: USER, address: '500 Main, Houston' }, 'so-1'); + await call('/api/admin/booking-routing/service-origin', 'PUT', { userId: USER, address: '500 Main, Houston' }, 'so-1'); + const row = await user(); + expect(row.serviceOriginAddress).toBe('500 Main, Houston'); + expect(row.serviceOriginLat).toBeCloseTo(29.7604, 4); + }); + + it('clearing nulls the coordinates too, so the inspector really does inherit again', async () => { + stubGoogle(29.7604, -95.3698, '500 Main, Houston, TX'); + await call('/api/admin/booking-routing/service-origin', 'PUT', { userId: USER, address: '500 Main, Houston' }); + await call('/api/admin/booking-routing/service-origin', 'PUT', { userId: USER, address: null }); + const row = await user(); + expect(row.serviceOriginAddress).toBeNull(); + expect(row.serviceOriginLat).toBeNull(); + expect(row.serviceOriginLng).toBeNull(); + }); + + it('an address that will not geocode is still STORED, and reports that it has no coordinates', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ status: 'ZERO_RESULTS', predictions: [] })))); + const res = await call('/api/admin/booking-routing/service-origin', 'PUT', { userId: USER, address: 'nowhere at all' }); + const body = await res.json() as { data: { resolved: boolean; reason: string } }; + expect(body.data.resolved).toBe(false); + expect(body.data.reason).toBe('not_found'); + const row = await user(); + expect(row.serviceOriginAddress).toBe('nowhere at all'); + expect(row.serviceOriginLat).toBeNull(); + }); +}); From f569064ad029a9c19ae62418b35c885605490f18 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 01:19:37 +0800 Subject: [PATCH 66/77] fix(tests): the drift guard did not cover the table that broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test:workers` went red on `table users has no column named service_origin_address`, in four cmd-consumer / cmd-fixtures specs, after lint + test:unit + test:web had all gone green. The reasoning that let it through was wrong in a specific and reusable way: "`db.insert(users).values({ id, email, … })` only binds the columns you pass". It does not. Drizzle emits EVERY column of the table and nulls the rest, so a partial insert is exactly as exposed to hand-written-DDL drift as a full one — which is why `applyAdminCredential`, a five-field insert, parked on three columns it never mentions. The guard existed and pointed at the wrong tables. `inline-ddl-schema-sync` covered tenant_configs and inspection_results; `users` had its DDL copy-pasted into two workers specs with no assertion over either. Now there is one source (`USERS_TEST_DDL`), both specs import it, and the sync spec asserts coverage — proved RED first, naming exactly the three missing columns. `test:workers` is not in the pre-push three-suite run, so without this the next person to add a users column finds out from CI too. This is the third table to teach the same lesson and the first one to get the assertion in the same commit as the lesson. Also: two pinned `getTenantSlots` call-shape assertions in bookings-company-endpoints.spec.ts updated for the propertyZip argument, with the reason `null` and not `undefined` is passed written down at the assertion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- server/api/admin/admin-booking-routing.ts | 1 - server/api/admin/admin-service-areas.ts | 1 - server/lib/booking/booking-rules.ts | 4 ++-- server/lib/booking/eligibility.ts | 2 +- server/lib/booking/routing.ts | 2 +- server/lib/places/geocode.ts | 2 +- .../lib/validations/admin/booking-routing.ts | 2 +- tests/helpers/inline-ddl.ts | 16 +++++++++++++ .../bookings-company-endpoints.spec.ts | 9 +++++-- .../platform/inline-ddl-schema-sync.spec.ts | 24 +++++++++++++++++-- tests/workers/cmd-consumer.spec.ts | 6 ++--- tests/workers/cmd-fixtures.spec.ts | 13 +++++----- 12 files changed, 60 insertions(+), 22 deletions(-) diff --git a/server/api/admin/admin-booking-routing.ts b/server/api/admin/admin-booking-routing.ts index 21d96a320..cde00e3bd 100644 --- a/server/api/admin/admin-booking-routing.ts +++ b/server/api/admin/admin-booking-routing.ts @@ -244,5 +244,4 @@ const adminBookingRoutingRoutes = createApiRouter() }, 200); }); -export type AdminBookingRoutingApi = typeof adminBookingRoutingRoutes; export default adminBookingRoutingRoutes; diff --git a/server/api/admin/admin-service-areas.ts b/server/api/admin/admin-service-areas.ts index 9ced50b11..64d62d8d0 100644 --- a/server/api/admin/admin-service-areas.ts +++ b/server/api/admin/admin-service-areas.ts @@ -150,5 +150,4 @@ const adminServiceAreasRoutes = createApiRouter() return c.json({ success: true as const, data: { userId, zipPrefixes: unique } }, 200); }); -export type AdminServiceAreasApi = typeof adminServiceAreasRoutes; export default adminServiceAreasRoutes; diff --git a/server/lib/booking/booking-rules.ts b/server/lib/booking/booking-rules.ts index b582373be..0ed961720 100644 --- a/server/lib/booking/booking-rules.ts +++ b/server/lib/booking/booking-rules.ts @@ -18,9 +18,9 @@ import { epochMsToWallClockYmd, epochMsToWallClockHm, wallClockToEpochMs, resolv */ /** Why a slot is not offerable. `null` when it is. */ -export type BookingRuleBlockReason = 'min_lead' | 'same_day_cutoff'; +type BookingRuleBlockReason = 'min_lead' | 'same_day_cutoff'; -export interface BookingRules { +interface BookingRules { /** Hours of notice required. 0 = no lead requirement (the default). */ minLeadHours: number; /** Wall-clock `HH:MM` in the tenant zone, or null for no cutoff. */ diff --git a/server/lib/booking/eligibility.ts b/server/lib/booking/eligibility.ts index c29960e55..178be9bfc 100644 --- a/server/lib/booking/eligibility.ts +++ b/server/lib/booking/eligibility.ts @@ -37,7 +37,7 @@ export interface EligibilityOutcome { } /** Normalize a stored prefix or a submitted property ZIP the same way. */ -export function normalizeZip(raw: string | null | undefined): string { +function normalizeZip(raw: string | null | undefined): string { return (raw ?? '').trim().toUpperCase().replace(/\s+/g, ''); } diff --git a/server/lib/booking/routing.ts b/server/lib/booking/routing.ts index a09159bc7..5268a1f03 100644 --- a/server/lib/booking/routing.ts +++ b/server/lib/booking/routing.ts @@ -33,7 +33,7 @@ export function isRoutingStrategy(raw: unknown): raw is RoutingStrategy { } /** Why the requested strategy was not the one applied. */ -export type RoutingFallbackReason = +type RoutingFallbackReason = /** `closest`: the property has no lat/lng, so no distance exists. */ | 'property_ungeocoded' /** `closest`: fewer than two candidates have a service origin to measure from. */ diff --git a/server/lib/places/geocode.ts b/server/lib/places/geocode.ts index d9188e9f6..7b5bd6e2f 100644 --- a/server/lib/places/geocode.ts +++ b/server/lib/places/geocode.ts @@ -38,7 +38,7 @@ interface GoogleDetailsResult { } /** Shape the Google details payload into our stored fields. */ -export function toResolvedPlace(r: GoogleDetailsResult): ResolvedPlace { +function toResolvedPlace(r: GoogleDetailsResult): ResolvedPlace { const partOf = (type: string, useShort = false): string | null => { const c = r.address_components.find(x => x.types.includes(type)); return c ? (useShort ? c.short_name : c.long_name) : null; diff --git a/server/lib/validations/admin/booking-routing.ts b/server/lib/validations/admin/booking-routing.ts index e0cc542ae..6167c535b 100644 --- a/server/lib/validations/admin/booking-routing.ts +++ b/server/lib/validations/admin/booking-routing.ts @@ -36,7 +36,7 @@ export const ServiceOriginSchema = z.object({ }).openapi('ServiceOrigin'); /** Why a geocode produced no coordinates. Never a silent null. */ -export const GEOCODE_FAILURE_REASONS = ['no_api_key', 'no_address', 'not_found'] as const; +const GEOCODE_FAILURE_REASONS = ['no_api_key', 'no_address', 'not_found'] as const; export const BookingRoutingResponseSchema = z.object({ success: z.literal(true).describe('Always true on success.'), diff --git a/tests/helpers/inline-ddl.ts b/tests/helpers/inline-ddl.ts index 0253c8625..942de0af4 100644 --- a/tests/helpers/inline-ddl.ts +++ b/tests/helpers/inline-ddl.ts @@ -23,5 +23,21 @@ 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\', booking_conflict_policy TEXT NOT NULL DEFAULT \'advisory\', cancellation_policy TEXT, cancellation_clause_agreement_id TEXT, cancellation_clause_version INTEGER, cancellation_clause_attested_at INTEGER, deposit_policy TEXT, booking_routing_strategy TEXT NOT NULL DEFAULT \'first_available\', booking_min_lead_hours INTEGER NOT NULL DEFAULT 0, booking_same_day_cutoff_time TEXT, company_lat REAL, company_lng REAL, company_geocoded_at INTEGER, updated_at INTEGER);'; +/** + * `users` is here for the third time the same lesson was learned, and this one + * cost a CI-only failure: `applyAdminCredential` does + * `db.insert(users).values({...})` with a PARTIAL object, and drizzle still + * emits every column of the table — filling the rest with null. So a users + * column that exists in the Drizzle schema and not in this DDL parks the + * credential apply with `table users has no column named …`, and neither + * `test:unit` nor `test:web` can see it. + * + * The drift guard covered tenant_configs and inspection_results but not this + * table, which is why adding `service_origin_*` went green three gates deep. + * It covers users now. + */ +export const USERS_TEST_DDL = + '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, service_origin_address TEXT, service_origin_lat REAL, service_origin_lng REAL);'; + 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/unit/bookings/bookings-company-endpoints.spec.ts b/tests/unit/bookings/bookings-company-endpoints.spec.ts index c4802446c..1ec39213c 100644 --- a/tests/unit/bookings/bookings-company-endpoints.spec.ts +++ b/tests/unit/bookings/bookings-company-endpoints.spec.ts @@ -292,7 +292,12 @@ describe('GET /slots — aggregated tenant slots (IA-26)', () => { expect(body.data.slots).toHaveLength(3); expect(body.data.slots[0]).toEqual({ time: '08:00', available: true }); expect(body.data.slots[2]).toEqual({ time: '09:00', available: false }); - expect(getTenantSlots).toHaveBeenCalledWith(TENANT_ID, TEST_DATE, []); + // The 4th arg is the optional precomputed qualified set (the route does + // not precompute) and the 5th is the property ZIP. `null` rather than + // undefined is deliberate at the call site: the service distinguishes + // "no ZIP was supplied" from "the caller has not been updated", and + // reports the former as `geoSkipped: 'property_zip_unknown'`. + expect(getTenantSlots).toHaveBeenCalledWith(TENANT_ID, TEST_DATE, [], undefined, null); // inspectorIds must NOT be in the response (private field). expect(body.data.slots[0]).not.toHaveProperty('inspectorIds'); }); @@ -302,7 +307,7 @@ describe('GET /slots — aggregated tenant slots (IA-26)', () => { const app = buildApp(db, { getTenantSlots }); const res = await app.request(`/slots?tenant=${TENANT_SLUG}&date=${TEST_DATE}&serviceIds=svc-1,svc-2`, {}, FAKE_ENV); expect(res.status).toBe(200); - expect(getTenantSlots).toHaveBeenCalledWith(TENANT_ID, TEST_DATE, ['svc-1', 'svc-2']); + expect(getTenantSlots).toHaveBeenCalledWith(TENANT_ID, TEST_DATE, ['svc-1', 'svc-2'], undefined, null); }); it('filters slots by inspectorId when provided (client-choice flow)', async () => { diff --git a/tests/unit/platform/inline-ddl-schema-sync.spec.ts b/tests/unit/platform/inline-ddl-schema-sync.spec.ts index 5ecef6092..9c3f24f73 100644 --- a/tests/unit/platform/inline-ddl-schema-sync.spec.ts +++ b/tests/unit/platform/inline-ddl-schema-sync.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { getTableConfig } from 'drizzle-orm/sqlite-core'; -import { tenantConfigs, inspectionResults } from '../../../server/lib/db/schema'; -import { TENANT_CONFIGS_TEST_DDL, INSPECTION_RESULTS_TEST_DDL } from '../../helpers/inline-ddl'; +import { tenantConfigs, inspectionResults, users } from '../../../server/lib/db/schema'; +import { TENANT_CONFIGS_TEST_DDL, INSPECTION_RESULTS_TEST_DDL, USERS_TEST_DDL } from '../../helpers/inline-ddl'; /** * Drift guard for the hand-maintained workers-runtime DDL. @@ -46,6 +46,26 @@ describe('workers inline DDL stays in sync with the Drizzle schema', () => { ).toEqual([]); }); + it('users test DDL covers every Drizzle schema column', () => { + // Learned a THIRD time, and this one reached CI: adding + // `service_origin_*` to the users schema parked `applyAdminCredential` + // in real workerd with "table users has no column named + // service_origin_address". The reasoning that let it through was + // "drizzle only binds the columns you pass" — it does not. + // `db.insert(users).values({ id, email, … })` emits EVERY column of the + // table and nulls the rest, so a partial insert is exactly as exposed + // to this drift as a full one. lint, test:unit and test:web are all + // blind to it; this assertion is not. + const ddlColumns = ddlColumnNames(USERS_TEST_DDL); + const schemaColumns = getTableConfig(users).columns.map((c) => c.name); + const missing = schemaColumns.filter((name) => !ddlColumns.has(name)); + expect( + missing, + `tests/helpers/inline-ddl.ts is missing users column(s): ${missing.join(', ')}. ` + + 'Add them to USERS_TEST_DDL so the workers cmd-apply path does not park.', + ).toEqual([]); + }); + it('inspection_results test DDL covers every Drizzle schema column', () => { // Learned the hard way on the reports work: the DDL was copy-pasted into // four collab specs, the Drizzle table gained `report_id`, and the only diff --git a/tests/workers/cmd-consumer.spec.ts b/tests/workers/cmd-consumer.spec.ts index a90fc1378..91db9159f 100644 --- a/tests/workers/cmd-consumer.spec.ts +++ b/tests/workers/cmd-consumer.spec.ts @@ -4,7 +4,7 @@ import { env } from 'cloudflare:test'; import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; import { applyCmdEnvelope, handleCmdBatch } from '../../server/portal/cmd-consumer'; -import { TENANT_CONFIGS_TEST_DDL } from '../helpers/inline-ddl'; +import { TENANT_CONFIGS_TEST_DDL, USERS_TEST_DDL } from '../helpers/inline-ddl'; // Batch 2: the seed command delegates to the starter-content service, whose // real implementation touches 8 content tables — out of scope for the consumer @@ -47,9 +47,7 @@ async function seedSchema(): Promise { await b.DB.exec( "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, date_format TEXT, time_format TEXT);", - ); + await b.DB.exec(USERS_TEST_DDL); 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 b56877cbb..bf6ec567b 100644 --- a/tests/workers/cmd-fixtures.spec.ts +++ b/tests/workers/cmd-fixtures.spec.ts @@ -5,7 +5,7 @@ import update from '../fixtures/cmd-events/cmd-tenant-update-v1.json'; import quota from '../fixtures/cmd-events/cmd-tenant-sync-quota-v1.json'; import updateReplyto from '../fixtures/cmd-events/cmd-tenant-update-replyto-v1.json'; import seed from '../fixtures/cmd-events/cmd-tenant-seed-starter-content-v1.json'; -import { TENANT_CONFIGS_TEST_DDL } from '../helpers/inline-ddl'; +import { TENANT_CONFIGS_TEST_DDL, USERS_TEST_DDL } from '../helpers/inline-ddl'; // Batch 2: the seed fixture exercises the consumer pipeline, not the content // seeder (which touches 8 tables and has its own coverage) — stubbed here. @@ -33,11 +33,12 @@ describe('cmd golden fixtures — consumer can apply every fixture (A-21)', () = await b.DB.exec( "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);", ); - // 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, date_format TEXT, time_format TEXT);", - ); + // Full users DDL — the replyto fixture carries credentials, and the + // drizzle insert binds every column of the table even for a partial + // values() object. Shared with cmd-consumer.spec.ts and guarded by + // inline-ddl-schema-sync.spec.ts; it used to be a second copy here, + // which is how three columns went missing without a local gate noticing. + await b.DB.exec(USERS_TEST_DDL); 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);'); // The update fixture carries `name` → PortalProvider initializes From 2397b7cae1d5d37a4bbe3a5f3f9096cc810f8766 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 02:09:39 +0800 Subject: [PATCH 67/77] feat(calendar): the map from an OI entity to the event it created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calendar_external_links answers "does this thing already exist on that person's calendar, under which id" — the fact a push needs to update instead of duplicate, a cancel needs to delete the remote copy, and an import needs to recognise its own events and skip them. entity_type is 'inspection' | 'calendar_block'. Inspection events are deliberately out of v1. inspection_events.gcal_event_id already holds that mapping, and the obvious move is to backfill it and freeze the column — but it cannot be done correctly. user_id here is NOT NULL and names whose calendar holds the event, and the push that wrote gcal_event_id sent every tenant event to whichever user pressed the button without recording who that was. A backfill would have to invent the one fact this table exists to record, and the delete path would then issue DELETEs against the wrong person's calendar. One writer or none. No .references() — the legacy FKs on availability_overrides are frozen, not a pattern to copy. --- migrations/0044_amused_rick_jones.sql | 15 + migrations/meta/0044_snapshot.json | 11089 +++++++++++++++++++ migrations/meta/_journal.json | 7 + server/lib/calendar/external-links.ts | 137 + server/lib/db/schema/calendar.ts | 42 + server/lib/db/schema/index.ts | 2 +- tests/unit/calendar/external-links.spec.ts | 120 + 7 files changed, 11411 insertions(+), 1 deletion(-) create mode 100644 migrations/0044_amused_rick_jones.sql create mode 100644 migrations/meta/0044_snapshot.json create mode 100644 server/lib/calendar/external-links.ts create mode 100644 tests/unit/calendar/external-links.spec.ts diff --git a/migrations/0044_amused_rick_jones.sql b/migrations/0044_amused_rick_jones.sql new file mode 100644 index 000000000..e8f85ae66 --- /dev/null +++ b/migrations/0044_amused_rick_jones.sql @@ -0,0 +1,15 @@ +CREATE TABLE `calendar_external_links` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `user_id` text NOT NULL, + `provider` text NOT NULL, + `entity_type` text NOT NULL, + `entity_id` text NOT NULL, + `external_id` text NOT NULL, + `etag` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_calendar_external_links_entity` ON `calendar_external_links` (`tenant_id`,`provider`,`entity_type`,`entity_id`);--> statement-breakpoint +CREATE INDEX `idx_calendar_external_links_user` ON `calendar_external_links` (`tenant_id`,`user_id`,`provider`); \ No newline at end of file diff --git a/migrations/meta/0044_snapshot.json b/migrations/meta/0044_snapshot.json new file mode 100644 index 000000000..0ac54c699 --- /dev/null +++ b/migrations/meta/0044_snapshot.json @@ -0,0 +1,11089 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7646ab6d-5547-4f7a-ba68-0ce202d3a5c8", + "prevId": "36b2efa4-d9d2-48fd-9e10-3af455cd678c", + "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": {} + }, + "calendar_external_links": { + "name": "calendar_external_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "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": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "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_calendar_external_links_entity": { + "name": "uq_calendar_external_links_entity", + "columns": [ + "tenant_id", + "provider", + "entity_type", + "entity_id" + ], + "isUnique": true + }, + "idx_calendar_external_links_user": { + "name": "idx_calendar_external_links_user", + "columns": [ + "tenant_id", + "user_id", + "provider" + ], + "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_service_pay_splits": { + "name": "inspection_service_pay_splits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_service_id": { + "name": "inspection_service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrects_split_id": { + "name": "corrects_split_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "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_pay_split_line_user": { + "name": "uq_pay_split_line_user", + "columns": [ + "tenant_id", + "inspection_service_id", + "user_id" + ], + "isUnique": true, + "where": "corrects_split_id IS NULL" + }, + "idx_pay_split_user": { + "name": "idx_pay_split_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_pay_split_line": { + "name": "idx_pay_split_line", + "columns": [ + "tenant_id", + "inspection_service_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "deposit_required_cents": { + "name": "deposit_required_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deposit_overridden": { + "name": "is_deposit_overridden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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": {} + }, + "inspector_service_areas": { + "name": "inspector_service_areas", + "columns": { + "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 + }, + "zip_prefix": { + "name": "zip_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_service_areas_tenant": { + "name": "idx_inspector_service_areas_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_service_areas_user": { + "name": "idx_inspector_service_areas_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "uq_inspector_service_areas": { + "name": "uq_inspector_service_areas", + "columns": [ + "tenant_id", + "user_id", + "zip_prefix" + ], + "isUnique": true + } + }, + "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": {} + }, + "service_pay_rules": { + "name": "service_pay_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_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 + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deduction_cents": { + "name": "deduction_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_service_pay_rules_user": { + "name": "uq_service_pay_rules_user", + "columns": [ + "tenant_id", + "service_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "uq_service_pay_rules_default": { + "name": "uq_service_pay_rules_default", + "columns": [ + "tenant_id", + "service_id" + ], + "isUnique": true, + "where": "user_id IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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 + }, + "deposit_policy": { + "name": "deposit_policy", + "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'" + }, + "cancellation_policy": { + "name": "cancellation_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_agreement_id": { + "name": "cancellation_clause_agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_version": { + "name": "cancellation_clause_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_attested_at": { + "name": "cancellation_clause_attested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deposit_policy": { + "name": "deposit_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "booking_routing_strategy": { + "name": "booking_routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'first_available'" + }, + "booking_min_lead_hours": { + "name": "booking_min_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "booking_same_day_cutoff_time": { + "name": "booking_same_day_cutoff_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lat": { + "name": "company_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lng": { + "name": "company_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_geocoded_at": { + "name": "company_geocoded_at", + "type": "integer", + "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 + }, + "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 + }, + "service_origin_address": { + "name": "service_origin_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lat": { + "name": "service_origin_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lng": { + "name": "service_origin_lng", + "type": "real", + "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 63cd0e8aa..affde09a0 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1786030075090, "tag": "0043_clumsy_night_thrasher", "breakpoints": true + }, + { + "idx": 44, + "version": "6", + "when": 1786039744996, + "tag": "0044_amused_rick_jones", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/lib/calendar/external-links.ts b/server/lib/calendar/external-links.ts new file mode 100644 index 000000000..352d600bf --- /dev/null +++ b/server/lib/calendar/external-links.ts @@ -0,0 +1,137 @@ +/** + * The OI entity <-> provider event id map (`calendar_external_links`). + * + * Every caller goes through here so the uniqueness rule — one link per + * (tenant, provider, entity_type, entity_id) — is expressed once. `upsertLink` + * is keyed on exactly that tuple, which is what makes a second push an UPDATE + * of the same remote event rather than a second event on someone's calendar. + */ +import { and, eq, inArray } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { calendarExternalLinks } from '../db/schema'; +import type { CalendarProviderId } from './provider'; + +export type CalendarLinkEntityType = 'inspection' | 'calendar_block'; + +export type CalendarExternalLinkRow = typeof calendarExternalLinks.$inferSelect; + +export interface CalendarLinkKey { + tenantId: string; + provider: CalendarProviderId; + entityType: CalendarLinkEntityType; + entityId: string; +} + +/** + * Records (or refreshes) the remote id for one OI entity. Returns nothing: + * callers that need the row back should read it, so there is no second place + * that decides what a link row looks like. + */ +export async function upsertLink( + db: DrizzleD1Database, + input: CalendarLinkKey & { userId: string; externalId: string; etag?: string | null }, +): Promise { + const now = new Date(); + await db.insert(calendarExternalLinks).values({ + id: crypto.randomUUID(), + tenantId: input.tenantId, + userId: input.userId, + provider: input.provider, + entityType: input.entityType, + entityId: input.entityId, + externalId: input.externalId, + etag: input.etag ?? null, + createdAt: now, + updatedAt: now, + }).onConflictDoUpdate({ + target: [ + calendarExternalLinks.tenantId, + calendarExternalLinks.provider, + calendarExternalLinks.entityType, + calendarExternalLinks.entityId, + ], + // userId moves with the link: reassigning an inspection to another + // inspector re-points the row at whoever now owns the remote event. + set: { + userId: input.userId, + externalId: input.externalId, + etag: input.etag ?? null, + updatedAt: now, + }, + }); +} + +/** The link for one entity, or null when it was never pushed. */ +export async function getLink( + db: DrizzleD1Database, + key: CalendarLinkKey, +): Promise { + const row = await db.select().from(calendarExternalLinks) + .where(and( + eq(calendarExternalLinks.tenantId, key.tenantId), + eq(calendarExternalLinks.provider, key.provider), + eq(calendarExternalLinks.entityType, key.entityType), + eq(calendarExternalLinks.entityId, key.entityId), + )) + .get(); + return row ?? null; +} + +/** Drops the link for one entity. Idempotent — a missing row is not an error. */ +export async function deleteLink( + db: DrizzleD1Database, + key: CalendarLinkKey, +): Promise { + await db.delete(calendarExternalLinks).where(and( + eq(calendarExternalLinks.tenantId, key.tenantId), + eq(calendarExternalLinks.provider, key.provider), + eq(calendarExternalLinks.entityType, key.entityType), + eq(calendarExternalLinks.entityId, key.entityId), + )); +} + +/** + * Every external id this user has pushed to this provider. + * + * The import path asks this question once per sync and answers rule 2 — "skip + * events OI itself created" — from the resulting set. Asking per event would be + * N queries against a table whose whole purpose is to be small. + */ +export async function listOwnExternalIds( + db: DrizzleD1Database, + params: { tenantId: string; userId: string; provider: CalendarProviderId }, +): Promise> { + const rows = await db.select({ externalId: calendarExternalLinks.externalId }) + .from(calendarExternalLinks) + .where(and( + eq(calendarExternalLinks.tenantId, params.tenantId), + eq(calendarExternalLinks.userId, params.userId), + eq(calendarExternalLinks.provider, params.provider), + )) + .all(); + return new Set(rows.map((r) => r.externalId)); +} + +/** Links for many entities of one type, keyed by entity id. */ +export async function getLinksByEntityIds( + db: DrizzleD1Database, + params: { + tenantId: string; + provider: CalendarProviderId; + entityType: CalendarLinkEntityType; + entityIds: string[]; + }, +): Promise> { + const out = new Map(); + if (params.entityIds.length === 0) return out; + const rows = await db.select().from(calendarExternalLinks) + .where(and( + eq(calendarExternalLinks.tenantId, params.tenantId), + eq(calendarExternalLinks.provider, params.provider), + eq(calendarExternalLinks.entityType, params.entityType), + inArray(calendarExternalLinks.entityId, params.entityIds), + )) + .all(); + for (const r of rows) out.set(r.entityId, r); + return out; +} diff --git a/server/lib/db/schema/calendar.ts b/server/lib/db/schema/calendar.ts index 32580cdaa..39a7e1826 100644 --- a/server/lib/db/schema/calendar.ts +++ b/server/lib/db/schema/calendar.ts @@ -45,6 +45,48 @@ export const calendarConnectionReadCalendars = sqliteTable('calendar_connection_ index('idx_conn_read_cal_tenant').on(t.tenantId, t.connectionId), ]); +/** + * OI entity <-> provider event id. One row answers "does this OI thing already + * exist on that person's calendar, and under which id" — so a re-push updates + * instead of duplicating, a cancel can delete the remote copy, and an import can + * recognise its own events and skip them. + * + * `user_id` is the point of the table: an external event lives in ONE person's + * calendar, and both the update and the delete have to be issued against that + * person's credentials. A row that cannot name the user is worse than no row — + * it would send a DELETE to the wrong calendar. + * + * `entity_type` covers inspections and calendar blocks only. Inspection EVENTS + * are deliberately absent: `inspection_events.gcal_event_id` already held that + * mapping, and the push that wrote it sent every tenant event to whichever user + * pressed the button without ever recording who that was — so there is no + * `user_id` to migrate, and inventing one would make this table lie about the + * single fact it exists to record. The events surface earns a link row when a + * push path that knows its target user exists, not before. Two writers of one + * fact is how the roster column diverged. + * + * No `.references()` per Schema Rules; the neighbouring legacy FKs on + * `availability_overrides` are frozen, not a pattern. + */ +export const calendarExternalLinks = sqliteTable('calendar_external_links', { + id: text('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + userId: text('user_id').notNull(), + provider: text('provider', { enum: ['google', 'microsoft', 'apple'] }).notNull(), + entityType: text('entity_type', { enum: ['inspection', 'calendar_block'] }).notNull(), + entityId: text('entity_id').notNull(), + /** Provider event id (Google `event.id`). */ + externalId: text('external_id').notNull(), + /** Provider concurrency tag when it gives one; advisory, never required. */ + etag: text('etag'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}, (t) => [ + uniqueIndex('uq_calendar_external_links_entity') + .on(t.tenantId, t.provider, t.entityType, t.entityId), + index('idx_calendar_external_links_user').on(t.tenantId, t.userId, t.provider), +]); + export const calendarBlocks = sqliteTable('calendar_blocks', { id: text('id').primaryKey(), tenantId: text('tenant_id').notNull(), diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts index c6656d864..f8c72ba5b 100644 --- a/server/lib/db/schema/index.ts +++ b/server/lib/db/schema/index.ts @@ -54,7 +54,7 @@ export type { ReportPdf, NewReportPdf } from './report-pdf'; export { signingKeys, esignAuditLogs } from './esign'; export type { SigningKey, NewSigningKey, EsignAuditLog, NewEsignAuditLog } from './esign'; export { qboConnections, qboEntityMap, qboSyncErrors } from './qbo'; -export { calendarBlocks, calendarConnections, calendarConnectionReadCalendars } from './calendar'; +export { calendarBlocks, calendarConnections, calendarConnectionReadCalendars, calendarExternalLinks } from './calendar'; export { tenantCustomHolidays } from './holidays'; // Apprentice review-queue subsystem removed 2026-06-13. The physical // `apprentice_reviews` table is orphaned (D1 cannot drop tables) but all diff --git a/tests/unit/calendar/external-links.spec.ts b/tests/unit/calendar/external-links.spec.ts new file mode 100644 index 000000000..28977d78a --- /dev/null +++ b/tests/unit/calendar/external-links.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + upsertLink, + getLink, + deleteLink, + listOwnExternalIds, + getLinksByEntityIds, +} from '../../../server/lib/calendar/external-links'; +import { createTestDb, setupSchema } from '../db'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyDb = any; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const OTHER_TENANT = '00000000-0000-0000-0000-000000000002'; +const USER = '00000000-0000-0000-0000-000000000010'; +const OTHER_USER = '00000000-0000-0000-0000-000000000011'; + +describe('calendar_external_links store', () => { + let db: BetterSQLite3Database; + let sqlite: ReturnType['sqlite']; + + beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + sqlite = fixture.sqlite; + await setupSchema(sqlite); + }); + + afterEach(() => sqlite.close()); + + const key = { + tenantId: TENANT, + provider: 'google' as const, + entityType: 'inspection' as const, + entityId: 'insp-1', + }; + + it('records a link and reads it back', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'gcal-abc' }); + const row = await getLink(db as AnyDb, key); + expect(row?.externalId).toBe('gcal-abc'); + expect(row?.userId).toBe(USER); + }); + + /** + * The reason the table exists. A second push must UPDATE the same row, not + * append: a duplicate row means the next delete leaves an orphan event on + * someone's calendar, and the next import sees an id it no longer skips. + */ + it('a second push for the same entity updates in place instead of adding a row', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'gcal-abc' }); + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'gcal-def' }); + + const all = await db.select().from(schema.calendarExternalLinks).all(); + expect(all).toHaveLength(1); + expect(all[0]!.externalId).toBe('gcal-def'); + }); + + it('re-points user_id when the entity moves to another inspector', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'gcal-abc' }); + await upsertLink(db as AnyDb, { ...key, userId: OTHER_USER, externalId: 'gcal-xyz' }); + + const row = await getLink(db as AnyDb, key); + expect(row?.userId).toBe(OTHER_USER); + expect(row?.externalId).toBe('gcal-xyz'); + }); + + it('separates entity types that happen to share an id', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'from-inspection' }); + await upsertLink(db as AnyDb, { + ...key, entityType: 'calendar_block', userId: USER, externalId: 'from-block', + }); + + expect((await getLink(db as AnyDb, key))?.externalId).toBe('from-inspection'); + expect((await getLink(db as AnyDb, { ...key, entityType: 'calendar_block' }))?.externalId) + .toBe('from-block'); + }); + + it('scopes by tenant — the same entity id in another tenant is a different link', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'mine' }); + await upsertLink(db as AnyDb, { ...key, tenantId: OTHER_TENANT, userId: USER, externalId: 'theirs' }); + + expect((await getLink(db as AnyDb, key))?.externalId).toBe('mine'); + expect((await getLink(db as AnyDb, { ...key, tenantId: OTHER_TENANT }))?.externalId).toBe('theirs'); + }); + + it('deletes the link on cancel, and deleting again is a no-op', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'gcal-abc' }); + await deleteLink(db as AnyDb, key); + expect(await getLink(db as AnyDb, key)).toBeNull(); + await expect(deleteLink(db as AnyDb, key)).resolves.toBeUndefined(); + }); + + it('lists this user own external ids without leaking another user rows', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'mine-1' }); + await upsertLink(db as AnyDb, { + ...key, entityId: 'insp-2', userId: OTHER_USER, externalId: 'theirs-1', + }); + + const ids = await listOwnExternalIds(db as AnyDb, { + tenantId: TENANT, userId: USER, provider: 'google', + }); + expect([...ids]).toEqual(['mine-1']); + }); + + it('batches entity lookups into one map', async () => { + await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'g1' }); + await upsertLink(db as AnyDb, { ...key, entityId: 'insp-2', userId: USER, externalId: 'g2' }); + + const map = await getLinksByEntityIds(db as AnyDb, { + tenantId: TENANT, provider: 'google', entityType: 'inspection', + entityIds: ['insp-1', 'insp-2', 'insp-missing'], + }); + expect(map.get('insp-1')?.externalId).toBe('g1'); + expect(map.get('insp-2')?.externalId).toBe('g2'); + expect(map.has('insp-missing')).toBe(false); + }); +}); From c9ef83c2fd9a5f3e4b9f2ac8f313ca2ac7fadc1a Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 02:28:28 +0800 Subject: [PATCH 68/77] feat(calendar): call the push primitives that had no callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pushEvent/deleteEvent existed and nothing invoked them. google-export.ts is the wiring, and it owns the three things the primitives cannot decide: whose calendar (the lead in inspection_inspectors, via getInspectionRoster — never inspections.inspector_id), which instant, and create-vs-update (calendar_external_links, so a reschedule MOVES the entry already on the inspector's phone). Added to the provider contract: patchEvent, an exported CalendarPushEventInput, and a timeZone that actually reaches Google — the instant fixes the moment, the zone fixes how the entry renders. ExternalEventGoneError lets a hand-deleted event be repaired by a fresh create instead of failing forever. Production holds no rows with a non-NULL scheduled_start_ms, so the fallback rung is the one that runs: an HH:MM suffix on inspections.date read in the tenant zone. A bare civil date is skipped as NO_START_TIME. A wrong time on someone's phone is worse than an absent entry, so no 08:00 is invented. Retires POST /api/calendar/sync-events, syncEventsToGcal and createCalendarEvent. Not for double-creation — sync-events was id-tracked — but for pushing every tenant event to whoever pressed the button, never propagating a reschedule or a cancel, and guessing 30 minutes. inspection_events.gcal_event_id is frozen -- DEAD. Fixes a second live tz bug found on the way: booking-confirmation composed both the calendar push and the customer .ics invite as `${date}T${time}:00Z`, a wall clock labelled UTC. Both now read the stamped instant, and server/services/booking joins the lint:tz SCOPE so they stay that way. --- scripts/check-tz-safety.mjs | 6 + scripts/file-size-baseline.json | 5 +- scripts/idempotency-baseline.json | 5 +- server/api/calendar-blocks.ts | 6 + server/api/calendar.ts | 47 +-- server/api/inspections/core.ts | 10 + server/api/inspections/schedule.ts | 6 + server/lib/calendar/google-export.ts | 332 ++++++++++++++++++ server/lib/calendar/google.ts | 57 ++- server/lib/calendar/provider.ts | 31 +- server/lib/calendar/push-hooks.ts | 85 +++++ server/lib/db/schema/inspection/automation.ts | 9 + server/lib/google-calendar.ts | 181 +--------- .../services/booking/booking-confirmation.ts | 79 ++--- tests/e2e/calendar-connect.spec.ts | 8 +- tests/unit/calendar/calendar-api.spec.ts | 9 +- tests/unit/calendar/google-export.spec.ts | 219 ++++++++++++ tests/unit/calendar/google.spec.ts | 78 ++++ 18 files changed, 892 insertions(+), 281 deletions(-) create mode 100644 server/lib/calendar/google-export.ts create mode 100644 server/lib/calendar/push-hooks.ts create mode 100644 tests/unit/calendar/google-export.spec.ts diff --git a/scripts/check-tz-safety.mjs b/scripts/check-tz-safety.mjs index 9d19b385b..2af14f39b 100644 --- a/scripts/check-tz-safety.mjs +++ b/scripts/check-tz-safety.mjs @@ -69,6 +69,12 @@ const SCOPE = [ // `.toISOString().slice(0,10)` here shipped green before this line existed; // it was caught by a test, which is one gate later than it should have been. 'server/lib/booking', + // What a booking ANNOUNCES is calendar output: the inspector's calendar entry + // and the customer's .ics invite. Both used to recompose the slot time as + // `${date}T${time}:00Z` — a wall clock labelled UTC — so both landed hours off + // in every tenant zone but UTC, and disagreed with the scheduled_start_ms the + // office sees. Both now read the stamped instant. Scoped so they stay that way. + 'server/services/booking', ]; function collectFiles(path) { diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 28e742b5f..904106ec1 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -27,9 +27,8 @@ "server/api/auth.ts": 565, "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, + "server/api/inspections/core.ts": 544, "app/components/NewInspectionWizard.tsx": 539, "server/services/inspection/inspection-photo.service.ts": 531, "server/api/inspections/media-studio.ts": 530, @@ -40,10 +39,10 @@ "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/inspections/publish.ts": 505, "server/api/repair-builder.ts": 504, + "server/api/calendar.ts": 502, "app/routes/inspection-edit/action.server.ts": 501, "server/services/report-export-consumer.ts": 499, "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/services/inspection/inspection-core.service.ts": 465, diff --git a/scripts/idempotency-baseline.json b/scripts/idempotency-baseline.json index d772fad8c..9e552bab0 100644 --- a/scripts/idempotency-baseline.json +++ b/scripts/idempotency-baseline.json @@ -12,8 +12,8 @@ "run so it is never silently forgotten." ], "coverage": { - "declaredMutating": 316, - "resolvedMutating": 316 + "declaredMutating": 323, + "resolvedMutating": 323 }, "knownUnreachable": {}, "uncoveredByDesign": { @@ -159,7 +159,6 @@ "POST /api/availability/overrides", "POST /api/calendar/blocks", "POST /api/calendar/sync", - "POST /api/calendar/sync-events", "POST /api/concierge/confirm", "POST /api/contacts", "POST /api/contacts/import", diff --git a/server/api/calendar-blocks.ts b/server/api/calendar-blocks.ts index 71c61170e..bb04017fa 100644 --- a/server/api/calendar-blocks.ts +++ b/server/api/calendar-blocks.ts @@ -4,6 +4,7 @@ import { calendarBlocks, users } from '../lib/db/schema'; import { safeISODate } from '../lib/date'; import { requireRole } from '../lib/middleware/rbac'; import { createApiRouter } from '../lib/openapi-router'; +import { pushBlockAfterResponse, dropExternalAfterResponse } from '../lib/calendar/push-hooks'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { isAdminRole } from '../lib/auth/roles'; import { getDrizzle, type AppDrizzle } from '../lib/route-helpers'; @@ -179,6 +180,7 @@ const calendarBlockRoutes = createApiRouter() updatedAt: now, }).returning().get(); + pushBlockAfterResponse(c, tenantId, block.id); return c.json({ success: true as const, data: { block: serializeBlock(block) } }, 201); }) .openapi(listBlocksRoute, async (c) => { @@ -256,6 +258,7 @@ const calendarBlockRoutes = createApiRouter() .returning() .get(); + pushBlockAfterResponse(c, tenantId, block.id); return c.json({ success: true as const, data: { block: serializeBlock(block) } }, 200); }) .openapi(deleteBlockRoute, async (c) => { @@ -278,6 +281,9 @@ const calendarBlockRoutes = createApiRouter() await db.delete(calendarBlocks) .where(and(eq(calendarBlocks.tenantId, tenantId), eq(calendarBlocks.id, id))); + // Deleting the block here but leaving it on the owner's Google calendar + // would leave them blocked by time off they just cancelled. + dropExternalAfterResponse(c, tenantId, 'calendar_block', id); return c.json({ success: true as const }, 200); }); diff --git a/server/api/calendar.ts b/server/api/calendar.ts index 1bd4e1e96..1f71a3004 100644 --- a/server/api/calendar.ts +++ b/server/api/calendar.ts @@ -16,9 +16,8 @@ import { SuccessResponseSchema } from '../lib/validations/shared.schema'; import { logger } from '../lib/logger'; import { getBaseUrl } from '../lib/url'; import { withMcpMetadata } from '../lib/route-metadata-standards'; -import { getRedirectUri, syncEventsToGcal, createCalendarEvent } from '../lib/google-calendar'; +import { getRedirectUri } from '../lib/google-calendar'; import { - canPushEvents, capabilityFromScopes, createPkceChallenge, } from '../lib/calendar/provider'; @@ -351,48 +350,6 @@ const calendarRoutes = createApiRouter() data: { readCalendarIds: resolved.readCalendarIds, writeCalendarId: resolved.writeCalendarId }, }, 200); }) - /** - * POST /api/calendar/sync-events - * Pushes upcoming inspection events to Google Calendar (full-sync capability only). - */ - .post('/sync-events', async (c) => { - const jwtUser = c.get('user'); - if (!jwtUser) return c.json({ success: false, error: { message: 'Not authenticated' } }, 401); - - const tenantId = c.get('tenantId') as string; - const open = await loadOpenGoogleConnection( - c.env.DB, - tenantId, - jwtUser.sub, - c.env.JWT_SECRET, - c.env.JWT_SECRET_PREVIOUS, - ); - if (!open) { - return c.json({ success: false, error: { message: 'Google Calendar not connected' } }, 400); - } - if (!canPushEvents(open.connection.capabilities)) { - return c.json({ - success: false, - error: { message: 'Calendar connection does not include write access. Reconnect with full sync.' }, - }, 403); - } - - const oauthMode = await loadGoogleOAuthMode(c.env.DB, tenantId); - const oauthCreds = await resolveGoogleOAuthCredentials(c.env, tenantId, oauthMode); - if (!oauthCreds) { - return c.json({ success: false, error: { message: 'Google Calendar integration is not configured' } }, 400); - } - - const result = await syncEventsToGcal( - c.env.DB, - tenantId, - oauthCreds.clientId, - oauthCreds.clientSecret, - open.credentials.refreshToken, - open.connection.calendarId, - ); - return c.json({ success: true, data: result }); - }) /** * GET /api/calendar/connect?capability=…&provider=google * Redirects inspector to Google OAuth consent (PKCE S256). @@ -541,6 +498,4 @@ const calendarRoutes = createApiRouter() export type CalendarApi = typeof calendarRoutes; -export { createCalendarEvent }; - export default calendarRoutes; diff --git a/server/api/inspections/core.ts b/server/api/inspections/core.ts index 84cc329d2..43bb653d5 100644 --- a/server/api/inspections/core.ts +++ b/server/api/inspections/core.ts @@ -18,6 +18,7 @@ import { InspectionSchema, CreateInspectionSchema, UpdateInspectionSchema } from import { CreateInspectionFromWizardSchema } from '../../lib/validations/wizard.schema'; import { inspections as inspectionTable, inspectionResults } from '../../lib/db/schema'; import { datePatchValues } from '../../services/inspection/reschedule-date'; +import { pushInspectionAfterResponse } from '../../lib/calendar/push-hooks'; import { findPatchRefusal } from './patch-guards'; import { deleteInspectionCascade } from '../../services/inspection/inspection-cascade'; import { syncAssignmentsAndSplits } from '../../services/pay-split.service'; @@ -343,6 +344,15 @@ const coreRoutes = createApiRouter() }); } + // Keep the lead's own calendar in step with whatever this patch changed. + // One call covers all three cases it can produce: a moved date UPDATEs + // the existing entry, a new inspectorId moves it between calendars, and + // a cancel takes it off — pushInspectionToGoogle re-reads the row and + // decides, so the route does not have to enumerate them. + if ('date' in body || 'inspectorId' in body || 'status' in body) { + pushInspectionAfterResponse(c, tenantId, id); + } + if (body.status && body.status !== inspection.status) { auditFromContext(c, 'inspection.status_change', 'inspection', { entityId: id, diff --git a/server/api/inspections/schedule.ts b/server/api/inspections/schedule.ts index 5026d10cd..79c380b89 100644 --- a/server/api/inspections/schedule.ts +++ b/server/api/inspections/schedule.ts @@ -25,6 +25,7 @@ import { syncAssignmentsAndSplits } from '../../services/pay-split.service'; import { findScheduleConflicts } from '../../lib/schedule-conflicts'; import { resolveInternalHolidayEffect } from '../../lib/holidays/load-tenant-holidays'; import { epochMsToWallClockHm, epochMsToWallClockYmd, resolveTenantTimeZone } from '../../lib/tz'; +import { pushInspectionAfterResponse } from '../../lib/calendar/push-hooks'; import { withMcpMetadata } from '../../lib/route-metadata-standards'; import { getDrizzle } from '../../lib/route-helpers'; import { @@ -211,6 +212,11 @@ const scheduleRoutes = createApiRouter() }); } + // The dispatch board just moved someone's day. Mirror it onto their own + // calendar: the link table makes this an UPDATE of the entry already on + // their phone, and a reassignment takes it off the previous inspector's. + pushInspectionAfterResponse(c, tenantId, id); + auditFromContext(c, 'inspection.rescheduled', 'inspection', { entityId: id, metadata: { diff --git a/server/lib/calendar/google-export.ts b/server/lib/calendar/google-export.ts new file mode 100644 index 000000000..c627fb8e6 --- /dev/null +++ b/server/lib/calendar/google-export.ts @@ -0,0 +1,332 @@ +/** + * Push OI work onto the assigned person's own calendar, and take it back off + * when it moves or is cancelled. + * + * The provider primitives (`pushEvent` / `patchEvent` / `deleteEvent`) already + * existed and had no callers. This module is the wiring, and it owns the three + * decisions the primitives cannot make: + * + * 1. WHOSE calendar. The lead in `inspection_inspectors`, read through + * `getInspectionRoster` — never `inspections.inspector_id`, and never + * "whoever pressed the button", which is the defect that retired + * `POST /api/calendar/sync-events`. + * 2. WHICH instant. `scheduled_start_ms` when the row has one, else the wall + * clock carried on `inspections.date` read in the tenant zone. A row with + * neither is SKIPPED with a reason, not given an invented 08:00 — a wrong + * time on someone's phone is worse than an absent entry. + * 3. CREATE or UPDATE. `calendar_external_links` decides. A reschedule moves + * the entry the inspector already has rather than leaving a stale twin. + * + * Every entry point returns an outcome instead of throwing. Callers run this + * detached (`waitUntil`) behind a response that has already been sent, so the + * only useful thing a failure can do is be recorded and surfaced later. + */ +import { and, eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { inspections, calendarBlocks, tenantConfigs } from '../db/schema'; +import { getInspectionRoster } from '../inspection/roster'; +import { resolveTenantTimeZone, wallClockToEpochMs } from '../tz'; +import { logger } from '../logger'; +import { canPushEvents, ExternalEventGoneError } from './provider'; +import { getCalendarProvider } from './registry'; +import { loadOpenGoogleConnection } from './connection'; +import { loadGoogleOAuthMode, resolveGoogleOAuthCredentials } from './resolve-google-oauth'; +import { getLink, upsertLink, deleteLink, type CalendarLinkEntityType } from './external-links'; + +/** Re-export so callers hook up against one module rather than two. */ +export type CalendarLinkEntityTypeAlias = CalendarLinkEntityType; + +export interface CalendarExportEnv { + DB: D1Database; + TENANT_CACHE: KVNamespace; + JWT_SECRET: string; + JWT_SECRET_PREVIOUS?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; +} + +/** + * Why a push did not happen. Every one of these is a state a user can be in + * and can act on, which is why they are named rather than logged as a boolean. + */ +export type PushSkipReason = + | 'NOT_CONNECTED' + | 'NO_WRITE_CAPABILITY' + | 'OAUTH_NOT_CONFIGURED' + | 'NO_ASSIGNEE' + | 'NO_START_TIME' + | 'NOT_FOUND' + | 'CANCELLED' + | 'PUSH_FAILED'; + +export interface PushOutcome { + pushed: boolean; + reason?: PushSkipReason; + externalId?: string; + /** Provider message when reason is PUSH_FAILED — surfaced as last_sync_error. */ + error?: string; +} + +/** + * Fallback span for an inspection carrying no end and no duration. Three hours + * is the same figure the booking path uses for a specific-time slot, so a + * hand-created inspection and a booked one look the same on a calendar. It is a + * named constant precisely because the retired push hid a 30-minute guess. + */ +const DEFAULT_DURATION_MIN = 180; + +const PROVIDER = 'google' as const; + +async function tenantTimeZone(db: DrizzleD1Database, tenantId: string): Promise { + const row = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + return resolveTenantTimeZone(row?.defaultTimezone); +} + +const toMs = (v: unknown): number | null => + v instanceof Date ? v.getTime() : v == null ? null : Number(v); + +/** + * The instant an inspection starts, most authoritative first. + * + * Production currently holds no rows with a non-NULL `scheduled_start_ms`, so + * the second rung is not a theoretical fallback — it is the one that runs. + */ +function resolveStartMs( + row: { date: string; scheduledStartMs: unknown }, + tz: string, +): number | null { + const stamped = toMs(row.scheduledStartMs); + if (stamped != null && Number.isFinite(stamped)) return stamped; + // `date` is either a bare civil day or a day with an HH:MM suffix. Only the + // latter names a time; a bare day genuinely does not know when it starts. + const hm = row.date.length > 10 ? row.date.slice(11, 16) : null; + if (!hm || !/^\d{2}:\d{2}$/.test(hm)) return null; + return wallClockToEpochMs(row.date.slice(0, 10), hm, tz); +} + +interface WriteHandle { + clientId: string; + clientSecret: string; + refreshToken: string; + calendarId: string; +} + +/** + * The credentials and write target for one user, or the reason there are none. + * `availability_read` connections are refused here rather than at each call + * site, so read-only consent can never become a write. + */ +async function resolveWriteHandle( + env: CalendarExportEnv, + tenantId: string, + userId: string, +): Promise<{ handle: WriteHandle } | { reason: PushSkipReason }> { + const open = await loadOpenGoogleConnection( + env.DB, tenantId, userId, env.JWT_SECRET, env.JWT_SECRET_PREVIOUS, + ); + if (!open) return { reason: 'NOT_CONNECTED' }; + if (!canPushEvents(open.connection.capabilities)) return { reason: 'NO_WRITE_CAPABILITY' }; + const mode = await loadGoogleOAuthMode(env.DB, tenantId); + const creds = await resolveGoogleOAuthCredentials(env, tenantId, mode); + if (!creds) return { reason: 'OAUTH_NOT_CONFIGURED' }; + return { + handle: { + clientId: creds.clientId, + clientSecret: creds.clientSecret, + refreshToken: open.credentials.refreshToken, + // Single-write: the read set is for busy import; the write always + // goes to the connection's nominated calendar. + calendarId: open.connection.calendarId, + }, + }; +} + +interface EventShape { + summary: string; + location?: string; + description?: string; + start: Date; + end: Date; + timeZone: string; +} + +/** + * Create-or-update against the link table, repairing a link whose remote event + * the owner deleted by hand. + */ +async function writeThroughLink( + env: CalendarExportEnv, + db: DrizzleD1Database, + handle: WriteHandle, + key: { tenantId: string; entityType: CalendarLinkEntityType; entityId: string }, + userId: string, + event: EventShape, +): Promise { + const provider = getCalendarProvider(PROVIDER); + const linkKey = { ...key, provider: PROVIDER }; + const existing = await getLink(db, linkKey); + + // A reassignment leaves the entry on the PREVIOUS person's calendar. Take + // it off THERE — with that person's credentials and their write calendar, + // which is what deleteExternalForEntity resolves from the link row. Deleting + // with the incoming lead's handle would aim at the wrong calendar entirely. + if (existing && existing.userId !== userId) { + await deleteExternalForEntity(env, key.tenantId, key.entityType, key.entityId); + } + + if (existing && existing.userId === userId) { + try { + await provider.patchEvent({ ...handle, externalId: existing.externalId, event }); + await upsertLink(db, { ...linkKey, userId, externalId: existing.externalId }); + return { pushed: true, externalId: existing.externalId }; + } catch (e) { + if (!(e instanceof ExternalEventGoneError)) { + return { pushed: false, reason: 'PUSH_FAILED', error: e instanceof Error ? e.message : String(e) }; + } + // Fall through to a fresh create — the link was stale. + logger.info('[calendar] external event gone, recreating', { entityId: key.entityId }); + } + } + + try { + const externalId = await provider.pushEvent({ ...handle, event }); + await upsertLink(db, { ...linkKey, userId, externalId }); + return { pushed: true, externalId }; + } catch (e) { + return { pushed: false, reason: 'PUSH_FAILED', error: e instanceof Error ? e.message : String(e) }; + } +} + +/** + * Put one inspection on its lead inspector's calendar, or move/remove it to + * match the inspection's current state. + */ +export async function pushInspectionToGoogle( + env: CalendarExportEnv, + tenantId: string, + inspectionId: string, +): Promise { + const db = drizzle(env.DB); + const row = await db.select({ + date: inspections.date, + scheduledStartMs: inspections.scheduledStartMs, + scheduledEndMs: inspections.scheduledEndMs, + durationMin: inspections.durationMin, + propertyAddress: inspections.propertyAddress, + status: inspections.status, + }) + .from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) + .get(); + if (!row) return { pushed: false, reason: 'NOT_FOUND' }; + + const roster = await getInspectionRoster(db, tenantId, inspectionId); + const lead = roster.lead; + + // Cancelled or unassigned: the entry should not be on anyone's calendar. + if (row.status === 'cancelled' || !lead) { + await deleteExternalForEntity(env, tenantId, 'inspection', inspectionId); + return { pushed: false, reason: row.status === 'cancelled' ? 'CANCELLED' : 'NO_ASSIGNEE' }; + } + + const resolved = await resolveWriteHandle(env, tenantId, lead.id); + if ('reason' in resolved) return { pushed: false, reason: resolved.reason }; + + const tz = await tenantTimeZone(db, tenantId); + const startMs = resolveStartMs(row, tz); + if (startMs == null) return { pushed: false, reason: 'NO_START_TIME' }; + + const endStamp = toMs(row.scheduledEndMs); + const endMs = endStamp != null && endStamp > startMs + ? endStamp + : startMs + (row.durationMin ?? DEFAULT_DURATION_MIN) * 60_000; + + return writeThroughLink( + env, db, resolved.handle, + { tenantId, entityType: 'inspection', entityId: inspectionId }, + lead.id, + { + summary: `Inspection: ${row.propertyAddress}`, + location: row.propertyAddress, + start: new Date(startMs), + end: new Date(endMs), + timeZone: tz, + }, + ); +} + +/** + * Put one time-off / blocked-time row on its owner's calendar. Blocks carry + * civil times, so the instant is composed in the tenant zone; an all-day block + * spans the tenant's working window rather than a UTC midnight-to-midnight, + * which would land on the wrong day west of Greenwich. + */ +export async function pushBlockToGoogle( + env: CalendarExportEnv, + tenantId: string, + blockId: string, +): Promise { + const db = drizzle(env.DB); + const row = await db.select().from(calendarBlocks) + .where(and(eq(calendarBlocks.id, blockId), eq(calendarBlocks.tenantId, tenantId))) + .get(); + if (!row) return { pushed: false, reason: 'NOT_FOUND' }; + + const resolved = await resolveWriteHandle(env, tenantId, row.userId); + if ('reason' in resolved) return { pushed: false, reason: resolved.reason }; + + const tz = await tenantTimeZone(db, tenantId); + const startHm = row.allDay ? '00:00' : (row.startTime ?? '00:00'); + const endHm = row.allDay ? '23:59' : (row.endTime ?? '23:59'); + const startMs = wallClockToEpochMs(row.date, startHm, tz); + const endMs = wallClockToEpochMs(row.date, endHm, tz); + + return writeThroughLink( + env, db, resolved.handle, + { tenantId, entityType: 'calendar_block', entityId: blockId }, + row.userId, + { + summary: row.title, + ...(row.notes ? { description: row.notes } : {}), + start: new Date(startMs), + end: new Date(endMs > startMs ? endMs : startMs + 60_000), + timeZone: tz, + }, + ); +} + +/** + * Remove the remote event for one OI entity and forget the link. + * + * The link row is dropped even when the provider call fails. Keeping it would + * mean the next push tries to PATCH an event the owner cannot see, forever; + * dropping it means the worst case is one orphaned entry the owner can delete, + * and OI's next push creates a clean one. + */ +export async function deleteExternalForEntity( + env: CalendarExportEnv, + tenantId: string, + entityType: CalendarLinkEntityType, + entityId: string, +): Promise { + const db = drizzle(env.DB); + const linkKey = { tenantId, provider: PROVIDER, entityType, entityId }; + const link = await getLink(db, linkKey); + if (!link) return; + + const resolved = await resolveWriteHandle(env, tenantId, link.userId); + if (!('reason' in resolved)) { + try { + await getCalendarProvider(PROVIDER).deleteEvent({ + ...resolved.handle, externalId: link.externalId, + }); + } catch (e) { + logger.warn('[calendar] remote delete failed; dropping link anyway', { + tenantId, entityId, error: e instanceof Error ? e.message : String(e), + }); + } + } + await deleteLink(db, linkKey); +} diff --git a/server/lib/calendar/google.ts b/server/lib/calendar/google.ts index 490c5d9e5..85e15c128 100644 --- a/server/lib/calendar/google.ts +++ b/server/lib/calendar/google.ts @@ -7,13 +7,35 @@ import { type GoogleCalendarResponse, type GoogleEvent, } from '../google-calendar'; -import { capabilityToScopes } from './provider'; -import type { CalendarProvider, OAuthExchangeResult, BusyBlock, CalendarListEntry } from './provider'; +import { capabilityToScopes, ExternalEventGoneError } from './provider'; +import type { + CalendarProvider, + OAuthExchangeResult, + BusyBlock, + CalendarListEntry, + CalendarPushEventInput, +} from './provider'; function toRfc3339(d: Date): string { return d.toISOString(); } +/** + * The event body Google accepts. `timeZone` rides alongside an absolute + * `dateTime` on purpose: the instant fixes the moment, the zone fixes how the + * entry renders and recurs for the owner. + */ +function googleEventBody(event: CalendarPushEventInput): Record { + const zone = event.timeZone ? { timeZone: event.timeZone } : {}; + return { + summary: event.summary, + location: event.location, + description: event.description, + start: { dateTime: event.start.toISOString(), ...zone }, + end: { dateTime: event.end.toISOString(), ...zone }, + }; +} + async function accessTokenFor( clientId: string, clientSecret: string, @@ -156,13 +178,7 @@ export const googleCalendarProvider: CalendarProvider = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ - summary: event.summary, - location: event.location, - description: event.description, - start: { dateTime: event.start.toISOString() }, - end: { dateTime: event.end.toISOString() }, - }), + body: JSON.stringify(googleEventBody(event)), }, ); if (!res.ok) { @@ -174,6 +190,29 @@ export const googleCalendarProvider: CalendarProvider = { return created.id; }, + async patchEvent({ clientId, clientSecret, refreshToken, calendarId, externalId, event }): Promise { + const accessToken = await accessTokenFor(clientId, clientSecret, refreshToken); + const res = await fetch( + `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(externalId)}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(googleEventBody(event)), + }, + ); + // The owner may have deleted the entry by hand. That is not an error to + // shout about — it is a signal to the caller that the link is stale and + // a fresh create is the right repair. + if (res.status === 404 || res.status === 410) throw new ExternalEventGoneError(externalId); + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { error?: { message?: string } }; + throw new Error(err.error?.message ?? 'Failed to update calendar event'); + } + }, + async deleteEvent({ clientId, clientSecret, refreshToken, calendarId, externalId }): Promise { const accessToken = await accessTokenFor(clientId, clientSecret, refreshToken); const res = await fetch( diff --git a/server/lib/calendar/provider.ts b/server/lib/calendar/provider.ts index dc96b9514..303127196 100644 --- a/server/lib/calendar/provider.ts +++ b/server/lib/calendar/provider.ts @@ -20,12 +20,19 @@ export interface CalendarListEntry { primary: boolean; } -interface CalendarPushEventInput { +export interface CalendarPushEventInput { summary: string; location?: string; description?: string; start: Date; end: Date; + /** + * IANA zone the event belongs to (the tenant's). The instants above already + * pin the moment; this pins the zone the provider renders and recurs in, so + * an event does not drift an hour when the tenant crosses a DST boundary. + * Omitted only by callers that genuinely have no tenant zone. + */ + timeZone?: string; } export interface PkceChallenge { @@ -83,6 +90,20 @@ export interface CalendarProvider { calendarId: string; event: CalendarPushEventInput; }): Promise; + /** + * Updates an event this deployment previously created. Separate from + * pushEvent because a reschedule must MOVE the entry the inspector already + * has on their phone — creating a second one and deleting the first loses + * their notification state and any guest responses. + */ + patchEvent(params: { + clientId: string; + clientSecret: string; + refreshToken: string; + calendarId: string; + externalId: string; + event: CalendarPushEventInput; + }): Promise; deleteEvent(params: { clientId: string; clientSecret: string; @@ -92,6 +113,14 @@ export interface CalendarProvider { }): Promise; } +/** Thrown when the provider says the remote event is gone (404/410). */ +export class ExternalEventGoneError extends Error { + constructor(externalId: string) { + super(`External calendar event no longer exists: ${externalId}`); + this.name = 'ExternalEventGoneError'; + } +} + const GOOGLE_SCOPES: Record = { availability_read: [ 'https://www.googleapis.com/auth/calendar.freebusy', diff --git a/server/lib/calendar/push-hooks.ts b/server/lib/calendar/push-hooks.ts new file mode 100644 index 000000000..b8b0c2c7b --- /dev/null +++ b/server/lib/calendar/push-hooks.ts @@ -0,0 +1,85 @@ +/** + * The one-liners route handlers call to keep somebody's Google Calendar in step + * with what just changed in OI. + * + * All three run DETACHED. The user's answer is already sent by the time Google + * is contacted, so a slow or broken calendar can never make saving an + * inspection feel slow or fail — and nothing here is allowed to reject. + * + * The Hono seam lives here rather than in `google-export.ts` so the export + * logic stays callable from the cron sweep, which has no request context. + */ +import type { Context } from 'hono'; +import type { HonoConfig } from '../../types/hono'; +import { logger } from '../logger'; +import { + pushInspectionToGoogle, + pushBlockToGoogle, + deleteExternalForEntity, + type CalendarExportEnv, + type CalendarLinkEntityTypeAlias, +} from './google-export'; + +/** + * The bindings the export path needs, or null when this deployment cannot do a + * calendar write at all (no KV to read tenant secrets from, or no JWT_SECRET to + * unseal them with). Returning null rather than throwing keeps an unconfigured + * standalone deploy silent instead of noisy. + */ +function exportEnv(c: Context): CalendarExportEnv | null { + const env = c.env; + if (!env.TENANT_CACHE || !env.JWT_SECRET) return null; + return { + DB: env.DB, + TENANT_CACHE: env.TENANT_CACHE, + JWT_SECRET: env.JWT_SECRET, + ...(env.JWT_SECRET_PREVIOUS ? { JWT_SECRET_PREVIOUS: env.JWT_SECRET_PREVIOUS } : {}), + ...(env.GOOGLE_CLIENT_ID ? { GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID } : {}), + ...(env.GOOGLE_CLIENT_SECRET ? { GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET } : {}), + }; +} + +function detach(c: Context, label: string, work: () => Promise): void { + try { + c.executionCtx.waitUntil(work().catch((e) => { + logger.warn(`[calendar] ${label} failed`, { error: e instanceof Error ? e.message : String(e) }); + })); + } catch { + // No executionCtx (some test harnesses). The calendar is a mirror, not + // a source of truth — losing one refresh is recoverable by the sweep. + } +} + +/** Create/move/remove the inspection's entry on its lead inspector's calendar. */ +export function pushInspectionAfterResponse( + c: Context, + tenantId: string, + inspectionId: string, +): void { + const env = exportEnv(c); + if (!env) return; + detach(c, 'inspection push', () => pushInspectionToGoogle(env, tenantId, inspectionId)); +} + +/** Create/move the blocked-time entry on its owner's calendar. */ +export function pushBlockAfterResponse( + c: Context, + tenantId: string, + blockId: string, +): void { + const env = exportEnv(c); + if (!env) return; + detach(c, 'block push', () => pushBlockToGoogle(env, tenantId, blockId)); +} + +/** Take the entry back off the calendar and forget the link. */ +export function dropExternalAfterResponse( + c: Context, + tenantId: string, + entityType: CalendarLinkEntityTypeAlias, + entityId: string, +): void { + const env = exportEnv(c); + if (!env) return; + detach(c, 'external delete', () => deleteExternalForEntity(env, tenantId, entityType, entityId)); +} diff --git a/server/lib/db/schema/inspection/automation.ts b/server/lib/db/schema/inspection/automation.ts index 3c9b9bf82..e9b90be61 100644 --- a/server/lib/db/schema/inspection/automation.ts +++ b/server/lib/db/schema/inspection/automation.ts @@ -237,6 +237,15 @@ export const inspectionEvents = sqliteTable('inspection_events', { completedAt: integer('completed_at', { mode: 'timestamp_ms' }), resultsReceivedAt: integer('results_received_at', { mode: 'timestamp_ms' }), cancelledAt: integer('cancelled_at', { mode: 'timestamp_ms' }), + /** + * -- DEAD (2026-08-07, superseded by calendar_external_links) + * Held the Google event id for the tenant-wide push that pressed-the-button + * semantics made unsafe (no assignment boundary, no update, no delete). That + * push is retired; nothing reads or writes this column. It is NOT backfilled + * into calendar_external_links because it never recorded WHOSE calendar the + * event landed on, and that table's user_id is the fact it exists to hold. + * Frozen per the column-retirement rule — never reuse the name. + */ gcalEventId: text('gcal_event_id'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), }, (t) => [ diff --git a/server/lib/google-calendar.ts b/server/lib/google-calendar.ts index 3790ed59a..3d1b29a67 100644 --- a/server/lib/google-calendar.ts +++ b/server/lib/google-calendar.ts @@ -1,16 +1,16 @@ /** - * Google Calendar integration — OAuth token refresh + event push/sync. + * Google Calendar OAuth plumbing — endpoints, token refresh, and the event + * shape the provider layer parses. * - * Extracted from server/api/calendar.ts (pure movement). The route handlers in - * server/api/calendar.ts import these helpers; booking.service.ts imports - * createCalendarEvent (re-exported from api/calendar.ts to keep its path stable). + * The event WRITES used to live here too (`createCalendarEvent`, + * `syncEventsToGcal`). Both are gone. They pushed without an assignment + * boundary — every tenant event went to whichever user pressed the button — + * had no update or delete, so a reschedule or a cancellation never reached the + * calendar, and guessed a 30-minute duration. Writes now go through + * `lib/calendar/google-export.ts`, which resolves the lead through the roster + * and tracks each event id in `calendar_external_links`. */ -import { drizzle } from 'drizzle-orm/d1'; -import { eq } from 'drizzle-orm'; -import { logger } from './logger'; -import { EVENT_STATUS } from './status/event-status'; - export const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; export const GOOGLE_CALENDAR_API = 'https://www.googleapis.com/calendar/v3'; @@ -57,166 +57,3 @@ export async function refreshAccessToken(clientId: string, clientSecret: string, if (!res.ok) throw new Error(`Token refresh failed: ${data.error_description ?? data.error}`); return data.access_token; } - -/** - * Create a Google Calendar event for a confirmed booking. - */ -export async function createCalendarEvent( - clientId: string, - clientSecret: string, - refreshToken: string, - calendarId: string, - summary: string, - date: string, - address: string, -): Promise { - try { - const accessToken = await refreshAccessToken(clientId, clientSecret, refreshToken); - - const start = new Date(date); - const end = new Date(start.getTime() + 2 * 60 * 60 * 1000); - - const eventRes = await fetch( - `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - summary, - location: address, - start: { dateTime: start.toISOString() }, - end: { dateTime: end.toISOString() }, - }), - }, - ); - - if (!eventRes.ok) { - const err = await eventRes.json() as { error?: { message?: string } }; - logger.error('[calendar] Failed to create event', { detail: err.error?.message }); - } - } catch (e) { - logger.error('[calendar] createCalendarEvent error', {}, e instanceof Error ? e : undefined); - } -} - -/** - * Spec 4D.T11 — Google Calendar sync for inspection events. - * - * Lists every inspection event in the next 90 days (via EventService.listEventsByDateRange) - * and pushes each one as a separate calendar entry. Each event is summarised as - * "" with start = scheduledAt and - * end = scheduledAt + durationMin*60s. - * - * The function is best-effort: per-event push failures are logged but do not abort - * the loop. A returned summary lets callers report counts back to the user. A future - * enhancement should also persist a `gcalEventId` per inspection event so we can - * update / delete the remote entry when status changes — see TODO below. - */ -export async function syncEventsToGcal( - db: D1Database, - tenantId: string, - clientId: string, - clientSecret: string, - refreshToken: string, - calendarId: string, -): Promise<{ pushed: number; skipped: number; failed: number; totalEvents: number }> { - if (!clientId || !clientSecret || !refreshToken) { - logger.warn('[calendar] syncEventsToGcal missing credentials', { tenantId }); - return { pushed: 0, skipped: 0, failed: 0, totalEvents: 0 }; - } - - // Lazy import to avoid circular dependency between api/calendar.ts and services. - const { EventService } = await import('../services/event.service'); - const { eventTypes, inspections, inspectionEvents } = await import('./db/schema'); - const eventService = new EventService(db); - - const fromTs = Date.now(); - const toTs = fromTs + 90 * 24 * 60 * 60 * 1000; - const events = await eventService.listEventsByDateRange(tenantId, fromTs, toTs); - - if (events.length === 0) { - return { pushed: 0, skipped: 0, failed: 0, totalEvents: 0 }; - } - - let accessToken: string; - try { - accessToken = await refreshAccessToken(clientId, clientSecret, refreshToken); - } catch (e) { - logger.error('[calendar] syncEventsToGcal token refresh failed', { tenantId }, e instanceof Error ? e : undefined); - return { pushed: 0, skipped: 0, failed: events.length, totalEvents: events.length }; - } - - // Pre-load event-type names + inspection addresses to avoid an N+1 fetch loop. - const drizzleDb = drizzle(db); - const allTypes = await drizzleDb.select({ id: eventTypes.id, name: eventTypes.name }) - .from(eventTypes).where(eq(eventTypes.tenantId, tenantId)).all(); - const typeNameById = new Map(allTypes.map(t => [t.id as string, t.name as string])); - const allInspections = await drizzleDb.select({ id: inspections.id, propertyAddress: inspections.propertyAddress }) - .from(inspections).where(eq(inspections.tenantId, tenantId)).all(); - const addressById = new Map(allInspections.map(i => [i.id as string, (i.propertyAddress as string) || ''])); - - let pushed = 0, skipped = 0, failed = 0; - - for (const ev of events) { - // Skip cancelled / completed events — they shouldn't appear on the calendar. - if (ev.status === EVENT_STATUS.CANCELLED || ev.status === EVENT_STATUS.COMPLETED) { - skipped++; - continue; - } - // Idempotency: skip events already pushed (have gcal_event_id). - // Use PATCH endpoint instead in future polish — for now skip to avoid duplicates. - if (ev.gcalEventId) { - skipped++; - continue; - } - const typeName = typeNameById.get(ev.eventTypeId as string) || 'Inspection event'; - const address = addressById.get(ev.inspectionId as string) || ''; - const summary = address ? `${typeName} — ${address}` : typeName; - const start = new Date(ev.scheduledAt as Date); - const durationSec = ((ev.durationMin as number | null) ?? 30) * 60; - const end = new Date(start.getTime() + durationSec * 1000); - - try { - const res = await fetch( - `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - summary, - location: address || undefined, - description: (ev.notes as string | null) || undefined, - start: { dateTime: start.toISOString() }, - end: { dateTime: end.toISOString() }, - }), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => ({})) as { error?: { message?: string } }; - logger.error('[calendar] Failed to push event', { eventId: ev.id, detail: err.error?.message }); - failed++; - continue; - } - // Persist gcal_event_id for future PATCH/DELETE. - const created = await res.json().catch(() => ({})) as { id?: string }; - if (created.id) { - await drizzleDb.update(inspectionEvents) - .set({ gcalEventId: created.id }) - .where(eq(inspectionEvents.id, ev.id as string)); - } - pushed++; - } catch (e) { - logger.error('[calendar] syncEventsToGcal push error', { eventId: ev.id }, e instanceof Error ? e : undefined); - failed++; - } - } - - logger.info('[calendar] syncEventsToGcal complete', { tenantId, pushed, skipped, failed, totalEvents: events.length }); - return { pushed, skipped, failed, totalEvents: events.length }; -} diff --git a/server/services/booking/booking-confirmation.ts b/server/services/booking/booking-confirmation.ts index 7d4f4e3e9..22360b2dc 100644 --- a/server/services/booking/booking-confirmation.ts +++ b/server/services/booking/booking-confirmation.ts @@ -1,13 +1,11 @@ import type { Context } from 'hono'; import { eq } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; -import { users } from '../../lib/db/schema'; +import { users, inspections, tenantConfigs } from '../../lib/db/schema'; import { logger } from '../../lib/logger'; import { CredentialService } from '../credential.service'; -import { createCalendarEvent } from '../../api/calendar'; -import { loadOpenGoogleConnection } from '../../lib/calendar/connection'; -import { loadGoogleOAuthMode, resolveGoogleOAuthCredentials } from '../../lib/calendar/resolve-google-oauth'; -import { canPushEvents } from '../../lib/calendar/provider'; +import { pushInspectionAfterResponse } from '../../lib/calendar/push-hooks'; +import { resolveTenantTimeZone, wallClockToEpochMs } from '../../lib/tz'; import { getBookingHost, getBaseUrl } from '../../lib/url'; import type { HonoConfig } from '../../types/hono'; import type { PublicBookingSchema } from '../../lib/validations/booking.schema'; @@ -63,45 +61,44 @@ export async function dispatchBookingConfirmation( const windowLabel = windowLabelFor(body); const inspector = await db.select().from(users).where(eq(users.id, inspectorId)).get(); - const open = await loadOpenGoogleConnection( - c.env.DB, - tenantId, - inspectorId, - c.env.JWT_SECRET, - c.env.JWT_SECRET_PREVIOUS, - ); - if (open && canPushEvents(open.connection.capabilities)) { - const oauthMode = await loadGoogleOAuthMode(c.env.DB, tenantId); - const oauthCreds = await resolveGoogleOAuthCredentials(c.env, tenantId, oauthMode); - if (oauthCreds) { - const startDateTime = `${body.date}T${requestedTime}:00Z`; - await createCalendarEvent( - oauthCreds.clientId, - oauthCreds.clientSecret, - open.credentials.refreshToken, - open.connection.calendarId, - `Inspection: ${body.address}`, - startDateTime, - body.address, - ).catch(e => logger.error('Calendar sync failed', {}, e instanceof Error ? e : undefined)); - } - } + + // The calendar push goes through the tracked export path: it reads the + // instant that fulfillBooking already stamped in the TENANT zone, records + // the Google event id in calendar_external_links, and therefore updates + // rather than duplicates when the booking is later moved. The previous + // call composed `${body.date}T${requestedTime}:00Z` — a wall clock labelled + // UTC — which put the event on the inspector's calendar at the wrong hour + // for every tenant not actually in UTC. + pushInspectionAfterResponse(c, tenantId, inspectionId); const emailService = c.var.services.email; - // Sprint 1 C-10 — build the ICS event so the confirmation email - // carries a calendar invite the customer can import into Apple - // Calendar / Google Calendar. Duration defaults to 3 hours, with - // 4 hours for morning/afternoon windows and 9 hours for all-day. - const startMs = new Date(`${body.date}T${requestedTime}:00Z`).getTime(); - let durationHours: number; - switch (body.timeSlot) { - case 'all-day': durationHours = 9; break; - case 'morning': - case 'afternoon': durationHours = 4; break; - default: durationHours = 3; break; - } - const endMs = startMs + durationHours * 60 * 60 * 1000; + // Sprint 1 C-10 — the ICS invite the customer imports into Apple Calendar + // or Google Calendar. + // + // The instant comes from the row, not from re-deriving it here. fulfillBooking + // stamped scheduled_start_ms/end_ms by reading the slot time in the TENANT + // zone; this used to recompute it as `${body.date}T${requestedTime}:00Z`, + // labelling a wall clock as UTC, so the customer's invite landed hours off + // in every zone but UTC — and it disagreed with the row the office sees. + // One authority, and the window-length policy stops being duplicated too. + const booked = await db.select({ + scheduledStartMs: inspections.scheduledStartMs, + scheduledEndMs: inspections.scheduledEndMs, + }).from(inspections).where(eq(inspections.id, inspectionId)).get(); + + const tzRow = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const tenantTz = resolveTenantTimeZone(tzRow?.defaultTimezone); + + const stampedStart = booked?.scheduledStartMs instanceof Date ? booked.scheduledStartMs.getTime() : null; + const stampedEnd = booked?.scheduledEndMs instanceof Date ? booked.scheduledEndMs.getTime() : null; + // Only reached when the stamp write failed; still read in the tenant zone. + const startMs = stampedStart ?? wallClockToEpochMs(body.date, requestedTime, tenantTz); + const fallbackHours = body.timeSlot === 'all-day' ? 9 + : body.timeSlot === 'morning' || body.timeSlot === 'afternoon' ? 4 + : 3; + const endMs = stampedEnd ?? startMs + fallbackHours * 60 * 60 * 1000; // Booking-confirmation greeting falls back to the brand, never the // inspector's inbox — keeps the email looking professional even if a // legacy account is missing a display name. diff --git a/tests/e2e/calendar-connect.spec.ts b/tests/e2e/calendar-connect.spec.ts index fa4ef54d8..000ee66e9 100644 --- a/tests/e2e/calendar-connect.spec.ts +++ b/tests/e2e/calendar-connect.spec.ts @@ -111,13 +111,15 @@ test.describe('Calendar connect — capability gating', () => { expect(location).toContain('calendar.freebusy'); }); - test('POST /api/calendar/sync-events returns 403 for availability_read connection', async ({ request }) => { + // The tenant-wide push (POST /api/calendar/sync-events) is retired: no + // assignment boundary, no update, no delete. Its absence is the assertion. + test('the retired tenant-wide sync-events push is no longer routed', async ({ request }) => { const session = await loginSession(request); await seedConnection(request, session.tenantId, session.userId, 'availability_read'); const res = await request.post(`${BASE_URL}/api/calendar/sync-events`, { headers: authedHeaders(session.cookie), }); - expect(res.status()).toBe(403); + expect(res.status()).toBe(404); }); test('DELETE /api/calendar/disconnect removes calendar_connections row', async ({ request }) => { @@ -128,7 +130,7 @@ test.describe('Calendar connect — capability gating', () => { }); expect(del.ok()).toBe(true); - const sync = await request.post(`${BASE_URL}/api/calendar/sync-events`, { + const sync = await request.post(`${BASE_URL}/api/calendar/sync`, { headers: authedHeaders(session.cookie), }); expect(sync.status()).toBe(400); diff --git a/tests/unit/calendar/calendar-api.spec.ts b/tests/unit/calendar/calendar-api.spec.ts index 33e0a0872..ea02f1090 100644 --- a/tests/unit/calendar/calendar-api.spec.ts +++ b/tests/unit/calendar/calendar-api.spec.ts @@ -114,11 +114,14 @@ describe('calendar API — calendar_connections', () => { jwtSecret: JWT_SECRET, }); + // The tenant-wide push is RETIRED, not merely capability-gated. It sent + // every tenant event to whoever pressed the button, and had no update or + // delete. Its absence is the guard: a 404 here means nobody re-added an + // untracked push. The write-capability boundary now lives in + // google-export (see tests/unit/calendar/google-export.spec.ts). const { app, env } = buildApp(testDb, kv); const res = await app.request('/api/calendar/sync-events', { method: 'POST' }, env); - expect(res.status).toBe(403); - const body = await res.json() as { error: { message: string } }; - expect(body.error.message).toContain('write access'); + expect(res.status).toBe(404); }); it('callback persists encrypted credentials (not plaintext refresh token)', async () => { diff --git a/tests/unit/calendar/google-export.spec.ts b/tests/unit/calendar/google-export.spec.ts new file mode 100644 index 000000000..e7cdbc032 --- /dev/null +++ b/tests/unit/calendar/google-export.spec.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +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', async (orig) => ({ + ...(await orig>()), + drizzle: vi.fn(), +})); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +const pushEvent = vi.fn(async () => 'gcal-new'); +const patchEvent = vi.fn(async () => undefined); +const deleteEvent = vi.fn(async () => undefined); +vi.mock('../../../server/lib/calendar/registry', () => ({ + getCalendarProvider: () => ({ pushEvent, patchEvent, deleteEvent }), +})); + +const openConnection = vi.fn(); +vi.mock('../../../server/lib/calendar/connection', () => ({ + loadOpenGoogleConnection: (...a: unknown[]) => openConnection(...a), +})); +vi.mock('../../../server/lib/calendar/resolve-google-oauth', () => ({ + loadGoogleOAuthMode: async () => 'platform', + resolveGoogleOAuthCredentials: async () => ({ clientId: 'cid', clientSecret: 'csec' }), +})); + +import { + pushInspectionToGoogle, + deleteExternalForEntity, +} from '../../../server/lib/calendar/google-export'; +import { ExternalEventGoneError } from '../../../server/lib/calendar/provider'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const LEAD = '00000000-0000-0000-0000-000000000010'; +const OTHER = '00000000-0000-0000-0000-000000000011'; +const INSP = 'insp-1'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const env = { DB: {} as D1Database, TENANT_CACHE: {} as any, JWT_SECRET: 's' }; + +function connection(capability: 'events_read_write' | 'availability_read') { + return { + connection: { id: 'c1', calendarId: 'primary', capabilities: capability }, + credentials: { refreshToken: 'rt' }, + }; +} + +describe('pushInspectionToGoogle — link-tracked two-way push', () => { + let db: BetterSQLite3Database; + let sqlite: ReturnType['sqlite']; + + beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + sqlite = fixture.sqlite; + await setupSchema(sqlite); + + await db.insert(schema.tenants).values({ + id: TENANT, name: 'A', slug: 'a', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT, defaultTimezone: 'America/New_York', updatedAt: new Date(), + }); + await db.insert(schema.users).values([ + { id: LEAD, tenantId: TENANT, email: 'l@t.com', role: 'inspector', passwordHash: 'x', createdAt: new Date() }, + { id: OTHER, tenantId: TENANT, email: 'o@t.com', role: 'inspector', passwordHash: 'x', createdAt: new Date() }, + ]); + await db.insert(schema.inspections).values({ + id: INSP, tenantId: TENANT, propertyAddress: '1 Main St', + clientName: 'S', clientEmail: 's@t.com', date: '2026-06-01', + scheduledStartMs: new Date(Date.UTC(2026, 5, 1, 14, 0)), + durationMin: 120, + status: 'confirmed', paymentStatus: 'unpaid', price: 0, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + }); + await db.insert(schema.inspectionInspectors).values({ + id: 'ii-1', tenantId: TENANT, inspectionId: INSP, userId: LEAD, role: 'lead', + createdAt: new Date(), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); + openConnection.mockResolvedValue(connection('events_read_write')); + pushEvent.mockClear(); patchEvent.mockClear(); deleteEvent.mockClear(); + pushEvent.mockResolvedValue('gcal-new'); + }); + + afterEach(() => { sqlite.close(); vi.clearAllMocks(); }); + + const links = () => db.select().from(schema.calendarExternalLinks).all(); + + it('creates the event and records the link', async () => { + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toMatchObject({ pushed: true, externalId: 'gcal-new' }); + expect(pushEvent).toHaveBeenCalledTimes(1); + + const rows = await links(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + entityType: 'inspection', entityId: INSP, userId: LEAD, externalId: 'gcal-new', + }); + }); + + /** + * The whole point of the link table: a reschedule must MOVE the entry the + * inspector already has, not create a second one. + */ + it('a second push patches the same external id and adds no second row', async () => { + await pushInspectionToGoogle(env, TENANT, INSP); + pushEvent.mockClear(); + + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toMatchObject({ pushed: true, externalId: 'gcal-new' }); + expect(patchEvent).toHaveBeenCalledTimes(1); + expect(pushEvent).not.toHaveBeenCalled(); + expect(await links()).toHaveLength(1); + }); + + it('sends the tenant zone alongside the instant', async () => { + await pushInspectionToGoogle(env, TENANT, INSP); + expect(pushEvent.mock.calls[0]![0]).toMatchObject({ + event: expect.objectContaining({ timeZone: 'America/New_York' }), + }); + }); + + it('recreates when the owner deleted the remote event by hand', async () => { + await pushInspectionToGoogle(env, TENANT, INSP); + patchEvent.mockRejectedValueOnce(new ExternalEventGoneError('gcal-new')); + pushEvent.mockResolvedValueOnce('gcal-fresh'); + + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toMatchObject({ pushed: true, externalId: 'gcal-fresh' }); + const rows = await links(); + expect(rows).toHaveLength(1); + expect(rows[0]!.externalId).toBe('gcal-fresh'); + }); + + it('refuses to write through a read-only connection', async () => { + openConnection.mockResolvedValue(connection('availability_read')); + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toEqual({ pushed: false, reason: 'NO_WRITE_CAPABILITY' }); + expect(pushEvent).not.toHaveBeenCalled(); + }); + + it('does not push an inspection nobody leads, and clears any old link', async () => { + await pushInspectionToGoogle(env, TENANT, INSP); + await db.delete(schema.inspectionInspectors); + + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toEqual({ pushed: false, reason: 'NO_ASSIGNEE' }); + expect(deleteEvent).toHaveBeenCalledTimes(1); + expect(await links()).toHaveLength(0); + }); + + it('removes the event from the calendar when the inspection is cancelled', async () => { + await pushInspectionToGoogle(env, TENANT, INSP); + await db.update(schema.inspections).set({ status: 'cancelled' }); + + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toEqual({ pushed: false, reason: 'CANCELLED' }); + expect(deleteEvent).toHaveBeenCalledWith(expect.objectContaining({ externalId: 'gcal-new' })); + expect(await links()).toHaveLength(0); + }); + + /** + * A reassignment must take the job off the previous inspector's calendar. + * The delete has to be issued against THAT person's connection — deleting + * with the incoming lead's handle would aim at the wrong calendar. + */ + it('moves the event when the lead changes', async () => { + await pushInspectionToGoogle(env, TENANT, INSP); + await db.update(schema.inspectionInspectors).set({ userId: OTHER }); + pushEvent.mockResolvedValueOnce('gcal-other'); + + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toMatchObject({ pushed: true, externalId: 'gcal-other' }); + expect(deleteEvent).toHaveBeenCalledTimes(1); + + const rows = await links(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ userId: OTHER, externalId: 'gcal-other' }); + }); + + /** + * Production holds no rows with a non-NULL scheduled_start_ms, so the + * fallback rung is the one that actually runs. A bare civil date names no + * time and must be skipped with a reason rather than given an invented one. + */ + describe('when scheduled_start_ms is absent', () => { + beforeEach(async () => { + await db.update(schema.inspections).set({ scheduledStartMs: null, scheduledEndMs: null }); + }); + + it('derives the instant from a time suffix on date, in the tenant zone', async () => { + await db.update(schema.inspections).set({ date: '2026-06-01T09:30' }); + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out.pushed).toBe(true); + // 09:30 in America/New_York on 2026-06-01 (EDT, UTC-4) = 13:30Z. + const sent = pushEvent.mock.calls[0]![0] as unknown as { event: { start: Date } }; + expect(sent.event.start.toISOString()).toBe('2026-06-01T13:30:00.000Z'); + }); + + it('skips a bare civil date with a named reason rather than inventing a time', async () => { + const out = await pushInspectionToGoogle(env, TENANT, INSP); + expect(out).toEqual({ pushed: false, reason: 'NO_START_TIME' }); + expect(pushEvent).not.toHaveBeenCalled(); + }); + }); + + it('deleteExternalForEntity drops the link even when the provider refuses', async () => { + await pushInspectionToGoogle(env, TENANT, INSP); + deleteEvent.mockRejectedValueOnce(new Error('network')); + + await deleteExternalForEntity(env, TENANT, 'inspection', INSP); + expect(await links()).toHaveLength(0); + }); +}); diff --git a/tests/unit/calendar/google.spec.ts b/tests/unit/calendar/google.spec.ts index 4a70737c0..0d863082c 100644 --- a/tests/unit/calendar/google.spec.ts +++ b/tests/unit/calendar/google.spec.ts @@ -4,6 +4,7 @@ import { capabilityFromScopes, canPushEvents, createPkceChallenge, + ExternalEventGoneError, } from '../../../server/lib/calendar/provider'; import { getCalendarProvider } from '../../../server/lib/calendar/registry'; import { googleCalendarProvider } from '../../../server/lib/calendar/google'; @@ -85,3 +86,80 @@ describe('googleCalendarProvider.listBusy', () => { expect(String(freeBusyCall[0])).toContain('/freeBusy'); }); }); + +/** + * These assert the HTTP BODY, not the arguments handed to the provider. A test + * that stubs the provider proves the caller passed `timeZone`; only this proves + * Google is actually told about it. The two are not the same claim, and the + * first one passes happily while the wire drops the field. + */ +describe('googleCalendarProvider write path — what goes on the wire', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); + afterEach(() => { vi.stubGlobal('fetch', originalFetch); }); + + const creds = { clientId: 'cid', clientSecret: 'sec', refreshToken: 'rt', calendarId: 'primary' }; + const event = { + summary: 'Inspection: 1 Main St', + location: '1 Main St', + start: new Date('2026-06-01T13:30:00Z'), + end: new Date('2026-06-01T15:30:00Z'), + timeZone: 'America/New_York', + }; + + function bodyOf(call: unknown[]): Record { + return JSON.parse(String((call[1] as RequestInit).body)); + } + + it('pushEvent sends timeZone on both ends alongside the absolute instant', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'at' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'gcal-1' }), { status: 200 })); + + const id = await googleCalendarProvider.pushEvent({ ...creds, event }); + expect(id).toBe('gcal-1'); + + const body = bodyOf(fetchMock.mock.calls[1]); + expect(body.start).toEqual({ dateTime: '2026-06-01T13:30:00.000Z', timeZone: 'America/New_York' }); + expect(body.end).toEqual({ dateTime: '2026-06-01T15:30:00.000Z', timeZone: 'America/New_York' }); + }); + + it('patchEvent PATCHes the named event and carries the zone too', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'at' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'gcal-1' }), { status: 200 })); + + await googleCalendarProvider.patchEvent({ ...creds, externalId: 'gcal-1', event }); + + const call = fetchMock.mock.calls[1]; + expect((call[1] as RequestInit).method).toBe('PATCH'); + expect(String(call[0])).toContain('/events/gcal-1'); + expect(bodyOf(call).start).toEqual({ + dateTime: '2026-06-01T13:30:00.000Z', timeZone: 'America/New_York', + }); + }); + + it('patchEvent reports a hand-deleted event as gone rather than as a failure', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'at' }), { status: 200 })) + .mockResolvedValueOnce(new Response('{}', { status: 404 })); + + await expect(googleCalendarProvider.patchEvent({ ...creds, externalId: 'gone', event })) + .rejects.toBeInstanceOf(ExternalEventGoneError); + }); + + it('omits timeZone entirely when the caller has no tenant zone', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'at' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'g' }), { status: 200 })); + + const { timeZone: _drop, ...zoneless } = event; + await googleCalendarProvider.pushEvent({ ...creds, event: zoneless }); + expect(bodyOf(fetchMock.mock.calls[1]).start).toEqual({ dateTime: '2026-06-01T13:30:00.000Z' }); + }); +}); From 19fbae2faa5ecd5d3613e819b929cab5c203ee34 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 02:35:53 +0800 Subject: [PATCH 69/77] feat(calendar): the import rules that need per-event identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules 4 and 5 already shipped inside syncGoogleBusyOverrides. What was missing is everything that needs to know WHICH event a busy block is: rule 2 — skip events OI pushed itself. Without it the calendar round-trips: we write an inspection to Google, read it back as busy, and the inspector is unavailable for the job they are booked on. rule 3 — skip instances of a recurring series. singleEvents=true expands a weekly standup into dozens of separate busy blocks. rule 6 — no historical backfill. Connecting a calendar must not retroactively block already-accepted work. An event counts as new if EITHER created or updated is at/after connected_at. All three need the per-event externalId, so the sync route stops calling mergeBusyIntervals — it unions ranges into anonymous blocks and throws the id away, leaving the upsert keyed on a synthesised range string that churns rows on every sync. mergeBusyIntervals had no callers left and is deleted rather than kept as a second way to reduce busy blocks. Window stays at the shipped 30 days. The plan said 90; that triples provider cost and override churn for time inspectors are rarely booked into. Pinned by SYNC_WINDOW_DAYS and a test, so it is a decision. recurringEventId/created/updated now travel from the Google parser through BusyBlock, with a wire-level test — a rule test that builds its own literals proves the rule, not that the provider supplies what it reads. The button and the coming cron sweep call one function (importBusyForConnection), so "Sync now" cannot drift from automatic sync. --- server/api/calendar.ts | 66 +++----- server/lib/calendar/google-import.ts | 91 +++++++++++ server/lib/calendar/google.ts | 5 + server/lib/calendar/provider.ts | 9 ++ server/lib/calendar/sync-busy.ts | 34 ----- server/lib/calendar/sync-engine.ts | 120 +++++++++++++++ server/lib/google-calendar.ts | 11 ++ server/lib/validations/calendar.schema.ts | 9 +- tests/unit/calendar/google-import.spec.ts | 101 +++++++++++++ tests/unit/calendar/google.spec.ts | 34 +++++ tests/unit/calendar/listbusy-union.spec.ts | 92 ------------ tests/unit/calendar/sync-engine.spec.ts | 167 +++++++++++++++++++++ 12 files changed, 562 insertions(+), 177 deletions(-) create mode 100644 server/lib/calendar/google-import.ts create mode 100644 server/lib/calendar/sync-engine.ts create mode 100644 tests/unit/calendar/google-import.spec.ts delete mode 100644 tests/unit/calendar/listbusy-union.spec.ts create mode 100644 tests/unit/calendar/sync-engine.spec.ts diff --git a/server/api/calendar.ts b/server/api/calendar.ts index 1f71a3004..c0446a0ab 100644 --- a/server/api/calendar.ts +++ b/server/api/calendar.ts @@ -1,9 +1,6 @@ import { createRoute } from '@hono/zod-openapi'; import { createApiRouter } from '../lib/openapi-router'; -import { eq } from 'drizzle-orm'; -import { tenantConfigs } from '../lib/db/schema'; -import { resolveTenantTimeZone } from '../lib/tz'; -import { syncGoogleBusyOverrides, mergeBusyIntervals } from '../lib/calendar/sync-busy'; +import { importBusyForConnection } from '../lib/calendar/sync-engine'; import { resolveReadSet, saveReadSet, resolveReadCalendarIds } from '../lib/calendar/read-set'; import { SaveReadSetSchema } from '../lib/validations/calendar-read-set.schema'; import { AppError } from '../lib/errors'; @@ -139,69 +136,40 @@ const calendarRoutes = createApiRouter() return c.json({ success: false, error: { message: 'Google Calendar not connected' } }, 400); } - const provider = getCalendarProvider('google'); const oauthMode = await loadGoogleOAuthMode(c.env.DB, tenantId); const oauthCreds = await resolveGoogleOAuthCredentials(c.env, tenantId, oauthMode); if (!oauthCreds) { return c.json({ success: false, error: { message: 'Google Calendar integration is not configured' } }, 400); } - const timeMin = new Date(); - const timeMax = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); const db = getDrizzle(c); - // A-polish 10b — union busy across the multi-read calendar set (falls - // back to the write/primary calendar when no read set is configured). - const readCalendarIds = await resolveReadCalendarIds(db, { - tenantId, - connectionId: open.connection.id, - fallbackCalendarId: open.connection.calendarId, - }); - let busyBlocks; + // One code path for the button and the cron sweep. It consumes RAW + // listBusy output: mergeBusyIntervals unions overlapping ranges into + // anonymous blocks and discards the per-event externalId, without which + // OI cannot recognise the events it pushed itself, cannot see + // recurrence, and cannot key the upsert on anything stable. + let result; try { - const perCalendar = await Promise.all(readCalendarIds.map((calendarId) => - provider.listBusy({ - clientId: oauthCreds.clientId, - clientSecret: oauthCreds.clientSecret, - refreshToken: open.credentials.refreshToken, - calendarId, - range: { from: timeMin, to: timeMax }, - capability: open.connection.capabilities, - }), - )); - busyBlocks = mergeBusyIntervals(perCalendar.flat()); + result = await importBusyForConnection(db, open.connection, { + clientId: oauthCreds.clientId, + clientSecret: oauthCreds.clientSecret, + refreshToken: open.credentials.refreshToken, + }); } catch (e) { logger.error('[calendar] sync listBusy failed', { tenantId }, e instanceof Error ? e : undefined); return c.json({ success: false, error: { message: 'Failed to fetch Google Calendar busy blocks' } }, 500); } const inspectorId = jwtUser.sub; - - // A-polish 10 — store busy time as TIMED overrides in the tenant tz - // (delete-in-range + keyed upsert), so only the busy hours block slots - // and transparent events are carried but ignored. Replaces the old - // all-day blocking-date insert. - const tzRow = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) - .from(tenantConfigs) - .where(eq(tenantConfigs.tenantId, tenantId)) - .get(); - const tenantTz = resolveTenantTimeZone(tzRow?.defaultTimezone); - const { upserted } = await syncGoogleBusyOverrides( - db, - { - tenantId, - inspectorId, - tenantTz, - rangeFromMs: timeMin.getTime(), - rangeToMs: timeMax.getTime(), - }, - busyBlocks, - ); - await markCalendarSynced(c.env.DB, tenantId, inspectorId, 'google'); return c.json({ success: true, - data: { blockedDatesCreated: upserted, totalEvents: busyBlocks.length }, + data: { + blockedDatesCreated: result.upserted, + totalEvents: result.totalEvents, + skipped: result.skipped, + }, }, 200); }) .get('/status', async (c) => { diff --git a/server/lib/calendar/google-import.ts b/server/lib/calendar/google-import.ts new file mode 100644 index 000000000..ad89405fa --- /dev/null +++ b/server/lib/calendar/google-import.ts @@ -0,0 +1,91 @@ +/** + * Which provider events become OI busy time — the Spectora one-off semantics. + * + * Pure decision logic on purpose. The orchestration (credentials, read set, + * persistence) lives in `sync-engine.ts`; everything that is a RULE lives here, + * where it can be tested without a database or a network. + * + * The rules, in the order they are applied: + * + * 2. Skip events OI itself pushed. Without this the calendar round-trips: we + * write an inspection to Google, read it back as "busy", and the inspector + * then appears unavailable for the job they are booked on. The link table + * is what makes an event recognisable as ours. + * 3. Skip instances of a recurring series. `singleEvents=true` expands series + * into instances, so a weekly standup arrives as fifty separate busy + * blocks. Spectora imports one-off appointments only, and so do we in v1. + * 6. Skip events that predate the connection. Connecting a calendar should not + * retroactively block months of already-accepted work. An event is "new + * enough" if EITHER its creation or its last modification is at/after + * connect — an old event moved into the window is a real change. + * + * Rules 4 and 5 (keyed upsert, delete-in-range) are not here: they already + * shipped inside `syncGoogleBusyOverrides`. + * + * Blocks with no `externalId` come from the freeBusy endpoint, which reports + * anonymous ranges. None of these rules can be evaluated against them, so they + * are kept as-is — the coarse fallback stays coarse rather than silently + * dropping to nothing. + */ +import type { BusyBlock } from './provider'; + +export type ImportSkipReason = 'oi_originated' | 'recurring_instance' | 'before_connect'; + +export interface ImportFilterResult { + keep: BusyBlock[]; + skipped: Record; +} + +export interface ImportFilterOptions { + /** External ids this user has pushed to this provider (rule 2). */ + ownExternalIds: Set; + /** Epoch ms the connection was established (rule 6). */ + connectedAtMs: number; +} + +export function filterImportableBlocks( + blocks: BusyBlock[], + opts: ImportFilterOptions, +): ImportFilterResult { + const keep: BusyBlock[] = []; + const skipped: Record = { + oi_originated: 0, + recurring_instance: 0, + before_connect: 0, + }; + + for (const block of blocks) { + // Anonymous freeBusy range — no identity to judge, so no rule applies. + if (!block.externalId) { + keep.push(block); + continue; + } + if (opts.ownExternalIds.has(block.externalId)) { + skipped.oi_originated++; + continue; + } + if (block.recurringEventId) { + skipped.recurring_instance++; + continue; + } + // An event the provider gave no timestamps for cannot be shown to + // predate the connection, so it is kept. Fail toward blocking time: + // a spurious busy block is visible and correctable, a missed one + // silently double-books the inspector. + const touchedMs = latestTouch(block); + if (touchedMs != null && touchedMs < opts.connectedAtMs) { + skipped.before_connect++; + continue; + } + keep.push(block); + } + + return { keep, skipped }; +} + +function latestTouch(block: BusyBlock): number | null { + const stamps = [block.createdMs, block.updatedMs].filter( + (v): v is number => typeof v === 'number' && Number.isFinite(v), + ); + return stamps.length ? Math.max(...stamps) : null; +} diff --git a/server/lib/calendar/google.ts b/server/lib/calendar/google.ts index 85e15c128..b4e63c135 100644 --- a/server/lib/calendar/google.ts +++ b/server/lib/calendar/google.ts @@ -138,11 +138,16 @@ export const googleCalendarProvider: CalendarProvider = { const start = event.start?.dateTime ?? (event.start?.date ? `${event.start.date}T00:00:00.000Z` : null); const end = event.end?.dateTime ?? (event.end?.date ? `${event.end.date}T23:59:59.000Z` : null); if (start && end) { + const createdMs = event.created ? Date.parse(event.created) : NaN; + const updatedMs = event.updated ? Date.parse(event.updated) : NaN; blocks.push({ start, end, externalId: event.id, transparency: event.transparency === 'transparent' ? 'transparent' : 'opaque', + ...(event.recurringEventId ? { recurringEventId: event.recurringEventId } : {}), + ...(Number.isFinite(createdMs) ? { createdMs } : {}), + ...(Number.isFinite(updatedMs) ? { updatedMs } : {}), }); } } diff --git a/server/lib/calendar/provider.ts b/server/lib/calendar/provider.ts index 303127196..95a2b6927 100644 --- a/server/lib/calendar/provider.ts +++ b/server/lib/calendar/provider.ts @@ -10,6 +10,15 @@ export interface BusyBlock { // defaults transparency to 'opaque'. externalId?: string; transparency?: 'opaque' | 'transparent'; + /** + * Set when this block is one instance of a recurring series. Only the + * events path can know this; freeBusy ranges never carry it. + */ + recurringEventId?: string; + /** Epoch ms the provider created the event, when it reports one. */ + createdMs?: number; + /** Epoch ms the provider last modified the event, when it reports one. */ + updatedMs?: number; } /** A-polish 10b — one calendar from the provider's calendar list. */ diff --git a/server/lib/calendar/sync-busy.ts b/server/lib/calendar/sync-busy.ts index 438835297..89e973fe0 100644 --- a/server/lib/calendar/sync-busy.ts +++ b/server/lib/calendar/sync-busy.ts @@ -4,40 +4,6 @@ import { availabilityOverrides } from '../db/schema'; import { epochMsToRfc3339 } from '../tz'; import type { BusyBlock } from './provider'; -/** - * A-polish 10b.4 — union busy time across the multi-read calendar set. Drops - * transparent (free) events, then merges overlapping/adjacent [start, end) - * ranges into a minimal set of unioned busy blocks. The merged blocks are - * anonymous (opaque, no event id) — the sync helper synthesizes a stable id - * from the range, so a re-sync is idempotent. - */ -export function mergeBusyIntervals(blocks: BusyBlock[]): BusyBlock[] { - const ranges = blocks - .filter((b) => b.transparency !== 'transparent') - .map((b) => ({ startMs: new Date(b.start).getTime(), endMs: new Date(b.end).getTime(), start: b.start, end: b.end })) - .filter((b) => Number.isFinite(b.startMs) && Number.isFinite(b.endMs) && b.endMs > b.startMs) - .sort((a, b) => a.startMs - b.startMs); - - const merged: BusyBlock[] = []; - let cur: { startMs: number; endMs: number; start: string; end: string } | null = null; - for (const r of ranges) { - if (!cur) { - cur = { ...r }; - } else if (r.startMs <= cur.endMs) { - // Overlapping or touching → extend the current union. - if (r.endMs > cur.endMs) { - cur.endMs = r.endMs; - cur.end = r.end; - } - } else { - merged.push({ start: cur.start, end: cur.end, transparency: 'opaque' }); - cur = { ...r }; - } - } - if (cur) merged.push({ start: cur.start, end: cur.end, transparency: 'opaque' }); - return merged; -} - /** * A-polish 10.3 — persist a provider's busy blocks as timed availability_overrides. * diff --git a/server/lib/calendar/sync-engine.ts b/server/lib/calendar/sync-engine.ts new file mode 100644 index 000000000..81988ffbf --- /dev/null +++ b/server/lib/calendar/sync-engine.ts @@ -0,0 +1,120 @@ +/** + * One busy-import run for one connection. + * + * Extracted from the `POST /api/calendar/sync` handler so the cron sweep and + * the manual button do the SAME thing — a sweep that drifted from the button + * would make "click Sync now" a different feature from "sync automatically", + * which is exactly the confusion Phase D exists to remove. + * + * It consumes RAW `listBusy` output rather than `mergeBusyIntervals`. Merging + * unions overlapping ranges into anonymous blocks and throws the per-event + * `externalId` away — and without that id none of the import rules can run: + * OI cannot recognise its own pushed events, recurrence is invisible, and the + * keyed upsert degrades to synthesised range keys that churn on every sync. + */ +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { eq } from 'drizzle-orm'; +import { tenantConfigs } from '../db/schema'; +import { resolveTenantTimeZone } from '../tz'; +import { logger } from '../logger'; +import { syncGoogleBusyOverrides } from './sync-busy'; +import { resolveReadCalendarIds } from './read-set'; +import { getCalendarProvider } from './registry'; +import { listOwnExternalIds } from './external-links'; +import { filterImportableBlocks, type ImportSkipReason } from './google-import'; +import type { CalendarConnectionRow } from './connection'; +import type { BusyBlock } from './provider'; + +/** + * How far ahead a sync looks. Thirty days is the shipped behaviour; the plan's + * 90 would triple every sync's provider cost and override churn for time an + * inspector is rarely booked into. Changing it is a product decision with a + * measurable cost, not an implementation detail — hence a named constant. + */ +export const SYNC_WINDOW_DAYS = 30; + +export interface ImportResult { + /** Override rows written (one per surviving provider event). */ + upserted: number; + /** Events the provider returned, before the rules ran. */ + totalEvents: number; + skipped: Record; +} + +export interface ImportDeps { + clientId: string; + clientSecret: string; + refreshToken: string; +} + +/** + * Pull one connection's busy time into `availability_overrides`. + * + * Throws only when the PROVIDER fails; callers decide whether that is a 500, a + * logged cron failure, or a `last_sync_error`. Everything else is reported in + * the result. + */ +export async function importBusyForConnection( + db: DrizzleD1Database, + connection: CalendarConnectionRow, + deps: ImportDeps, + nowMs: number = Date.now(), +): Promise { + const tenantId = connection.tenantId; + const provider = getCalendarProvider('google'); + + const from = new Date(nowMs); + const to = new Date(nowMs + SYNC_WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const readCalendarIds = await resolveReadCalendarIds(db, { + tenantId, + connectionId: connection.id, + fallbackCalendarId: connection.calendarId, + }); + + const perCalendar = await Promise.all(readCalendarIds.map((calendarId) => + provider.listBusy({ + clientId: deps.clientId, + clientSecret: deps.clientSecret, + refreshToken: deps.refreshToken, + calendarId, + range: { from, to }, + capability: connection.capabilities, + }), + )); + const blocks: BusyBlock[] = perCalendar.flat(); + + const ownExternalIds = await listOwnExternalIds(db, { + tenantId, userId: connection.userId, provider: 'google', + }); + const connectedAtMs = connection.connectedAt instanceof Date + ? connection.connectedAt.getTime() + : Number(connection.connectedAt); + + const { keep, skipped } = filterImportableBlocks(blocks, { + ownExternalIds, + connectedAtMs: Number.isFinite(connectedAtMs) ? connectedAtMs : 0, + }); + + const tzRow = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const tenantTz = resolveTenantTimeZone(tzRow?.defaultTimezone); + + const { upserted } = await syncGoogleBusyOverrides( + db, + { + tenantId, + inspectorId: connection.userId, + tenantTz, + rangeFromMs: from.getTime(), + rangeToMs: to.getTime(), + }, + keep, + ); + + if (skipped.oi_originated || skipped.recurring_instance || skipped.before_connect) { + logger.info('[calendar] import filtered provider events', { tenantId, ...skipped }); + } + + return { upserted, totalEvents: blocks.length, skipped }; +} diff --git a/server/lib/google-calendar.ts b/server/lib/google-calendar.ts index 3d1b29a67..6468490de 100644 --- a/server/lib/google-calendar.ts +++ b/server/lib/google-calendar.ts @@ -40,6 +40,17 @@ export interface GoogleEvent { end?: { date?: string; dateTime?: string }; // 'transparent' = the event shows the owner as free (does not block). transparency?: string; + /** + * Present on every INSTANCE of a recurring series (singleEvents=true + * expands series into instances), naming the series it came from. Its + * presence is how the import tells a one-off appointment from a weekly + * standup — Spectora imports only the former. + */ + recurringEventId?: string; + /** RFC-3339 creation time; drives the do-not-backfill-before-connect rule. */ + created?: string; + /** RFC-3339 last-modified time; an old event edited after connect counts as new. */ + updated?: string; } export async function refreshAccessToken(clientId: string, clientSecret: string, refreshToken: string): Promise { diff --git a/server/lib/validations/calendar.schema.ts b/server/lib/validations/calendar.schema.ts index 3e3894e8b..f3c630789 100644 --- a/server/lib/validations/calendar.schema.ts +++ b/server/lib/validations/calendar.schema.ts @@ -6,8 +6,13 @@ import { createApiResponseSchema } from './shared.schema'; */ export const CalendarSyncResponseSchema = createApiResponseSchema( z.object({ - blockedDatesCreated: z.number().openapi({ example: 5 }).describe('TODO describe blockedDatesCreated field for the OpenInspection MCP integration'), - totalEvents: z.number().openapi({ example: 12 }).describe('TODO describe totalEvents field for the OpenInspection MCP integration'), + blockedDatesCreated: z.number().openapi({ example: 5 }).describe('Busy override rows written for this inspector in the sync window.'), + totalEvents: z.number().openapi({ example: 12 }).describe('Provider events returned in the sync window, before the import rules ran.'), + skipped: z.object({ + oi_originated: z.number().openapi({ example: 2 }).describe('Events OI itself pushed, skipped so a booking cannot block its own inspector.'), + recurring_instance: z.number().openapi({ example: 4 }).describe('Instances of a recurring series; v1 imports one-off events only.'), + before_connect: z.number().openapi({ example: 0 }).describe('Events last touched before the calendar was connected; no historical backfill.'), + }).describe('Counts of provider events the import rules excluded.'), }) ).openapi('CalendarSyncResponse'); diff --git a/tests/unit/calendar/google-import.spec.ts b/tests/unit/calendar/google-import.spec.ts new file mode 100644 index 000000000..2c479af05 --- /dev/null +++ b/tests/unit/calendar/google-import.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { filterImportableBlocks } from '../../../server/lib/calendar/google-import'; +import type { BusyBlock } from '../../../server/lib/calendar/provider'; + +const CONNECTED_AT = Date.UTC(2026, 5, 1, 12, 0); +const AFTER = Date.UTC(2026, 5, 2, 9, 0); +const BEFORE = Date.UTC(2026, 4, 20, 9, 0); + +function block(over: Partial = {}): BusyBlock { + return { + start: '2026-06-10T14:00:00Z', + end: '2026-06-10T16:00:00Z', + externalId: 'ev-1', + transparency: 'opaque', + createdMs: AFTER, + ...over, + }; +} + +const run = (blocks: BusyBlock[], ownIds: string[] = []) => + filterImportableBlocks(blocks, { + ownExternalIds: new Set(ownIds), + connectedAtMs: CONNECTED_AT, + }); + +describe('filterImportableBlocks — Spectora one-off import semantics', () => { + it('keeps an ordinary one-off event created after connect', () => { + const out = run([block()]); + expect(out.keep).toHaveLength(1); + expect(out.skipped).toEqual({ oi_originated: 0, recurring_instance: 0, before_connect: 0 }); + }); + + /** + * Rule 2. Without it the calendar round-trips: OI pushes an inspection to + * Google, reads it back as busy, and the inspector is then unavailable for + * the very job they are booked on. + */ + it('skips events OI itself pushed, so a booking cannot block its own inspector', () => { + const out = run([block({ externalId: 'ours' }), block({ externalId: 'theirs' })], ['ours']); + expect(out.keep.map((b) => b.externalId)).toEqual(['theirs']); + expect(out.skipped.oi_originated).toBe(1); + }); + + /** + * Rule 3. singleEvents=true expands a series into instances, so a weekly + * standup would otherwise arrive as dozens of separate busy blocks. + */ + it('skips instances of a recurring series', () => { + const out = run([ + block({ externalId: 'once' }), + block({ externalId: 'weekly-1', recurringEventId: 'series-a' }), + block({ externalId: 'weekly-2', recurringEventId: 'series-a' }), + ]); + expect(out.keep.map((b) => b.externalId)).toEqual(['once']); + expect(out.skipped.recurring_instance).toBe(2); + }); + + /** Rule 6 — connecting a calendar must not retroactively block accepted work. */ + it('skips events created before the connection', () => { + const out = run([block({ externalId: 'old', createdMs: BEFORE })]); + expect(out.keep).toHaveLength(0); + expect(out.skipped.before_connect).toBe(1); + }); + + it('keeps an old event that was edited after connect', () => { + const out = run([block({ externalId: 'moved', createdMs: BEFORE, updatedMs: AFTER })]); + expect(out.keep).toHaveLength(1); + expect(out.skipped.before_connect).toBe(0); + }); + + /** + * Fail toward blocking time. A spurious busy block is visible and + * correctable; a missed one silently double-books the inspector. + */ + it('keeps an event the provider gave no timestamps for', () => { + const out = run([block({ externalId: 'undated', createdMs: undefined })]); + expect(out.keep).toHaveLength(1); + }); + + /** + * freeBusy ranges are anonymous — no id, no recurrence, no timestamps. None + * of the rules can be evaluated, so the coarse fallback must stay coarse + * rather than silently filtering itself down to nothing. + */ + it('keeps anonymous freeBusy ranges untouched', () => { + const out = run([{ start: '2026-06-10T14:00:00Z', end: '2026-06-10T16:00:00Z' }]); + expect(out.keep).toHaveLength(1); + expect(out.skipped).toEqual({ oi_originated: 0, recurring_instance: 0, before_connect: 0 }); + }); + + it('carries transparency through so transparent events stay non-blocking', () => { + const out = run([block({ transparency: 'transparent' })]); + expect(out.keep[0]!.transparency).toBe('transparent'); + }); + + it('applies OI-origination before recurrence so the count is not double-attributed', () => { + const out = run([block({ externalId: 'ours', recurringEventId: 'series' })], ['ours']); + expect(out.skipped.oi_originated).toBe(1); + expect(out.skipped.recurring_instance).toBe(0); + }); +}); diff --git a/tests/unit/calendar/google.spec.ts b/tests/unit/calendar/google.spec.ts index 0d863082c..973c5ad06 100644 --- a/tests/unit/calendar/google.spec.ts +++ b/tests/unit/calendar/google.spec.ts @@ -85,6 +85,40 @@ describe('googleCalendarProvider.listBusy', () => { const freeBusyCall = fetchMock.mock.calls[1]; expect(String(freeBusyCall[0])).toContain('/freeBusy'); }); + + /** + * The import rules are decided on fields the PARSER has to carry through. + * A rule test that builds its own BusyBlock literals proves the rule, not + * that the provider ever supplies what the rule reads. + */ + it('carries recurringEventId and created/updated off the events endpoint', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'at' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + items: [{ + id: 'inst-1', + recurringEventId: 'series-a', + created: '2026-05-01T00:00:00Z', + updated: '2026-06-02T00:00:00Z', + start: { dateTime: '2026-07-14T10:00:00Z' }, + end: { dateTime: '2026-07-14T11:00:00Z' }, + }], + }), { status: 200 })); + + const blocks = await googleCalendarProvider.listBusy({ + clientId: 'cid', clientSecret: 'sec', refreshToken: 'rt', calendarId: 'primary', + range: { from: new Date('2026-07-14T00:00:00Z'), to: new Date('2026-07-15T00:00:00Z') }, + capability: 'events_read_write', + }); + + expect(blocks[0]).toMatchObject({ + externalId: 'inst-1', + recurringEventId: 'series-a', + createdMs: Date.parse('2026-05-01T00:00:00Z'), + updatedMs: Date.parse('2026-06-02T00:00:00Z'), + }); + }); }); /** diff --git a/tests/unit/calendar/listbusy-union.spec.ts b/tests/unit/calendar/listbusy-union.spec.ts deleted file mode 100644 index cf905ee68..000000000 --- a/tests/unit/calendar/listbusy-union.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * A-polish 10b.4 — union busy across the multi-read calendar set. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { createTestDb, setupSchema } from '../db'; -import { tenants, users, calendarConnectionReadCalendars } from '../../../server/lib/db/schema'; -import { mergeBusyIntervals } from '../../../server/lib/calendar/sync-busy'; -import { resolveReadCalendarIds } from '../../../server/lib/calendar/read-set'; -import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -import * as schema from '../../../server/lib/db/schema'; - -const iso = (h: number, m = 0) => `2026-07-20T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00Z`; - -describe('mergeBusyIntervals', () => { - it('merges overlapping intervals from different calendars into one', () => { - // primary busy 09:00-10:00, work busy 09:30-11:00 → union 09:00-11:00. - const merged = mergeBusyIntervals([ - { start: iso(9), end: iso(10) }, - { start: iso(9, 30), end: iso(11) }, - ]); - expect(merged).toHaveLength(1); - expect(merged[0].start).toBe(iso(9)); - expect(merged[0].end).toBe(iso(11)); - }); - - it('keeps disjoint intervals separate', () => { - const merged = mergeBusyIntervals([ - { start: iso(9), end: iso(10) }, - { start: iso(10, 30), end: iso(11) }, - ]); - expect(merged.map((b) => [b.start, b.end])).toEqual([ - [iso(9), iso(10)], - [iso(10, 30), iso(11)], - ]); - }); - - it('drops transparent (free) events from the union', () => { - const merged = mergeBusyIntervals([ - { start: iso(9), end: iso(10), transparency: 'opaque' }, - { start: iso(9, 30), end: iso(11), transparency: 'transparent' }, - ]); - expect(merged).toHaveLength(1); - expect(merged[0].end).toBe(iso(10)); // transparent 09:30-11:00 excluded - }); -}); - -const T = 't1'; -const CONN = 'conn-1'; - -describe('resolveReadCalendarIds', () => { - let db: BetterSQLite3Database; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let sqlite: any; - - beforeEach(async () => { - const fix = createTestDb(); - db = fix.db as BetterSQLite3Database; - sqlite = fix.sqlite; - await setupSchema(sqlite); - await db.insert(tenants).values({ - id: T, name: 'Co', slug: 'co', tier: 'free', status: 'active', - maxUsers: 5, deploymentMode: 'shared', createdAt: new Date(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - await db.insert(users).values({ - id: 'insp-1', tenantId: T, email: 'i@x.com', passwordHash: 'h', - role: 'inspector', name: 'I', createdAt: new Date(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - }); - afterEach(() => sqlite.close()); - - it('falls back to the write/primary calendar when no read set is configured', async () => { - const ids = await resolveReadCalendarIds(db as never, { - tenantId: T, connectionId: CONN, fallbackCalendarId: 'primary', - }); - expect(ids).toEqual(['primary']); - }); - - it('returns the configured read set when present', async () => { - const now = new Date(); - await db.insert(calendarConnectionReadCalendars).values([ - { id: 'r1', tenantId: T, connectionId: CONN, externalCalendarId: 'primary', summary: 'Me', accessRole: 'owner', createdAt: now, updatedAt: now }, - { id: 'r2', tenantId: T, connectionId: CONN, externalCalendarId: 'work', summary: 'Work', accessRole: 'writer', createdAt: now, updatedAt: now }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] as any); - const ids = await resolveReadCalendarIds(db as never, { - tenantId: T, connectionId: CONN, fallbackCalendarId: 'primary', - }); - expect(ids.sort()).toEqual(['primary', 'work']); - }); -}); diff --git a/tests/unit/calendar/sync-engine.spec.ts b/tests/unit/calendar/sync-engine.spec.ts new file mode 100644 index 000000000..acd8fdd42 --- /dev/null +++ b/tests/unit/calendar/sync-engine.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +const listBusy = vi.fn(); +vi.mock('../../../server/lib/calendar/registry', () => ({ + getCalendarProvider: () => ({ listBusy }), +})); + +import { importBusyForConnection, SYNC_WINDOW_DAYS } from '../../../server/lib/calendar/sync-engine'; +import { upsertLink } from '../../../server/lib/calendar/external-links'; +import type { CalendarConnectionRow } from '../../../server/lib/calendar/connection'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const USER = '00000000-0000-0000-0000-000000000010'; +const NOW = Date.UTC(2026, 5, 1, 12, 0); +const CONNECTED_AT = new Date(Date.UTC(2026, 4, 1, 0, 0)); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyDb = any; + +const connection = { + id: 'conn-1', + tenantId: TENANT, + userId: USER, + provider: 'google', + authType: 'oauth', + credentialsEnc: 'x', + credentialsDekEnc: 'x', + capabilities: 'events_read_write', + calendarId: 'primary', + connectedAt: CONNECTED_AT, + updatedAt: CONNECTED_AT, + lastSyncAt: null, +} as unknown as CalendarConnectionRow; + +const deps = { clientId: 'cid', clientSecret: 'sec', refreshToken: 'rt' }; + +function ev(over: Record = {}) { + return { + start: '2026-06-10T14:00:00Z', + end: '2026-06-10T16:00:00Z', + externalId: 'ev-1', + transparency: 'opaque', + createdMs: Date.UTC(2026, 5, 2), + ...over, + }; +} + +describe('importBusyForConnection', () => { + let db: BetterSQLite3Database; + let sqlite: ReturnType['sqlite']; + + beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + sqlite = fixture.sqlite; + await setupSchema(sqlite); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'A', slug: 'a', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT, defaultTimezone: 'America/New_York', updatedAt: new Date(), + }); + // availability_overrides carries legacy FKs to users/tenants. + await db.insert(schema.users).values({ + id: USER, tenantId: TENANT, email: 'i@t.com', role: 'inspector', + passwordHash: 'x', createdAt: new Date(), + }); + listBusy.mockReset(); + }); + + afterEach(() => { sqlite.close(); vi.clearAllMocks(); }); + + const overrides = () => db.select().from(schema.availabilityOverrides).all(); + + /** + * The reason the engine consumes raw listBusy output. mergeBusyIntervals + * unions ranges into ANONYMOUS blocks, and the persistence layer then keys + * its upsert on a synthesised `fb::` string — which changes the + * moment anyone nudges an event, so every sync churns rows instead of + * updating them. + */ + it('persists the provider event id, not a synthesised range key', async () => { + listBusy.mockResolvedValue([ev({ externalId: 'google-abc' })]); + const out = await importBusyForConnection(db as AnyDb, connection, deps, NOW); + + expect(out.upserted).toBe(1); + const rows = await overrides(); + expect(rows[0]!.externalId).toBe('google-abc'); + expect(rows[0]!.source).toBe('google'); + }); + + it('keeps two overlapping events as two identified rows rather than one merged blob', async () => { + listBusy.mockResolvedValue([ + ev({ externalId: 'a', start: '2026-06-10T14:00:00Z', end: '2026-06-10T16:00:00Z' }), + ev({ externalId: 'b', start: '2026-06-10T15:00:00Z', end: '2026-06-10T17:00:00Z' }), + ]); + await importBusyForConnection(db as AnyDb, connection, deps, NOW); + + const ids = (await overrides()).map((r) => r.externalId).sort(); + expect(ids).toEqual(['a', 'b']); + }); + + it('does not import an event OI pushed itself', async () => { + await upsertLink(db as AnyDb, { + tenantId: TENANT, provider: 'google', entityType: 'inspection', + entityId: 'insp-1', userId: USER, externalId: 'ours', + }); + listBusy.mockResolvedValue([ev({ externalId: 'ours' }), ev({ externalId: 'theirs' })]); + + const out = await importBusyForConnection(db as AnyDb, connection, deps, NOW); + expect(out.skipped.oi_originated).toBe(1); + expect((await overrides()).map((r) => r.externalId)).toEqual(['theirs']); + }); + + it('does not import recurring instances', async () => { + listBusy.mockResolvedValue([ev({ externalId: 'r1', recurringEventId: 'series' })]); + const out = await importBusyForConnection(db as AnyDb, connection, deps, NOW); + expect(out.skipped.recurring_instance).toBe(1); + expect(await overrides()).toHaveLength(0); + }); + + it('does not backfill events that predate the connection', async () => { + listBusy.mockResolvedValue([ev({ externalId: 'old', createdMs: Date.UTC(2026, 3, 1) })]); + const out = await importBusyForConnection(db as AnyDb, connection, deps, NOW); + expect(out.skipped.before_connect).toBe(1); + expect(await overrides()).toHaveLength(0); + }); + + /** + * The shipped window is 30 days. The plan proposed 90, which would triple + * every sync's provider cost and override churn; keeping 30 is a decision, + * so it is pinned rather than left to drift. + */ + it('asks the provider for the shipped 30-day window', async () => { + listBusy.mockResolvedValue([]); + await importBusyForConnection(db as AnyDb, connection, deps, NOW); + + expect(SYNC_WINDOW_DAYS).toBe(30); + const { range } = listBusy.mock.calls[0]![0] as { range: { from: Date; to: Date } }; + expect(range.from.getTime()).toBe(NOW); + expect(range.to.getTime() - range.from.getTime()).toBe(30 * 24 * 60 * 60 * 1000); + }); + + it('stores busy time as tenant-local wall clock, not UTC', async () => { + // 14:00Z on 2026-06-10 is 10:00 in America/New_York (EDT). + listBusy.mockResolvedValue([ev()]); + await importBusyForConnection(db as AnyDb, connection, deps, NOW); + + const row = (await overrides())[0]!; + expect(row.date).toBe('2026-06-10'); + expect(row.startTime).toBe('10:00'); + expect(row.endTime).toBe('12:00'); + }); + + it('reports the provider total separately from what survived the rules', async () => { + listBusy.mockResolvedValue([ + ev({ externalId: 'keep' }), + ev({ externalId: 'r', recurringEventId: 's' }), + ]); + const out = await importBusyForConnection(db as AnyDb, connection, deps, NOW); + expect(out).toMatchObject({ totalEvents: 2, upserted: 1 }); + }); +}); From 7f47d1102cd70cf086b9fc2441753a2dfce1ae81 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 03:17:07 +0800 Subject: [PATCH 70/77] feat(calendar): the inspector iCal feeds, and the two live bugs in them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The busy feed had shipped with two defects: 1. toUtcStamp composed `new Date(`${day}T${time}:00Z`)` — a wall clock labelled UTC. An 08:00 appointment in America/New_York was published to every subscriber as 20260601T080000Z. The correct instant is 20260601T120000Z. Not a formatting difference: subscribers were told the inspector was busy four hours before they are. Proven by a test asserting the instant, run against the old code first. 2. It filtered on inspections.inspector_id — the frozen legacy column — while every assignment write goes to inspection_inspectors. The fixture leaves the column NULL, exactly as real rows do, and a version that reads it returns an empty calendar for an inspector with work. Now reads the link table, lead role only. ics.service.ts joins the lint:tz SCOPE in this commit and not before — adding it earlier would have turned `npm run lint` red for everything else in flight. The gate's own comment claimed every real bug lives in its scope while this file sat outside it. New: /api/ics/inspector/:token — the inspector's own schedule WITH property addresses. Addressed by a sealed token, never the slug: the /inspector/ surface is unauthenticated and a slug is a name, so a guessable URL would publish someone's daily route. Deterministic, so no migration; the cost is that revocation is secret-wide, stated at the definition. IcsSubscribePanel replaces the ICS prose in ScheduleLinksPanel with the three feeds, each labelled with its audience and marked when the link is private. ManageOthersPicker extracted so settings-schedule.tsx went DOWN (437 -> 411), and the ics-links route lives in its own file so calendar.ts went down too (502 -> 471) rather than through its ratchet. Chrome walkthrough, light and dark, caught one more: the endpoint composed absolute URLs with getBaseUrl(c), which behind the in-process API mount resolves to the API worker's host — the copied link read http://127.0.0.1:8787/... and was dead on arrival. It returns paths now and the browser supplies its own origin. Verified end to end: valid token 200 VCALENDAR, tampered token 404. --- app/components/settings/IcsSubscribePanel.tsx | 127 ++++++++++++ .../settings/ManageOthersPicker.tsx | 44 ++++ .../settings/ScheduleLinksPanel.tsx | 21 +- app/routes/settings-schedule.tsx | 58 ++---- messages/en/settings-components.json | 13 +- messages/es-419/settings-components.json | 13 +- scripts/check-tz-safety.mjs | 6 + scripts/file-size-baseline.json | 4 +- server/api/calendar-ics-links.ts | 60 ++++++ server/api/calendar.ts | 2 + server/api/ics.ts | 40 ++++ server/lib/calendar/inspector-ics-token.ts | 46 +++++ server/services/ics.service.ts | 190 +++++++++++++----- tests/unit/calendar/ics-busy-feed.spec.ts | 184 ++++++++++++----- 14 files changed, 652 insertions(+), 156 deletions(-) create mode 100644 app/components/settings/IcsSubscribePanel.tsx create mode 100644 app/components/settings/ManageOthersPicker.tsx create mode 100644 server/api/calendar-ics-links.ts create mode 100644 server/lib/calendar/inspector-ics-token.ts diff --git a/app/components/settings/IcsSubscribePanel.tsx b/app/components/settings/IcsSubscribePanel.tsx new file mode 100644 index 000000000..2b586b96b --- /dev/null +++ b/app/components/settings/IcsSubscribePanel.tsx @@ -0,0 +1,127 @@ +import { Banner } from "@core/shared-ui"; +import { useCopyClipboard } from "~/hooks/useCopyClipboard"; +import { m } from "~/paraglide/messages"; + +/** + * Feed PATHS, not absolute URLs. The API is mounted in-process behind the RR + * server, so a URL built there carries the API worker's host rather than the + * one the browser is on — the copied link would be dead on arrival. The origin + * is added here, where it is known to be the user's. + */ +export interface IcsLinks { + /** Opaque busy blocks, public slug path. Null until the user has a slug. */ + busyPath: string | null; + /** The inspector's own schedule WITH addresses, sealed-token path. */ + schedulePath: string | null; + /** Every inspection in the company. Null for non-admins. */ + companyPath: string | null; +} + +interface FeedRow { + key: string; + label: string; + description: string; + url: string; + sensitive?: boolean; +} + +/** + * The subscribe catalog: three feeds, each with what it is FOR. + * + * The three exist because they have different audiences and therefore + * different payloads — that distinction is the useful content here, not the + * URLs, so every row states its audience and the schedule row says out loud + * that its link carries addresses. A copied calendar link gets pasted into + * shared calendars; someone doing that deserves to know which one is private. + */ +export function IcsSubscribePanel({ links }: { links: IcsLinks }) { + const { copied: copiedField, copy } = useCopyClipboard(); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + + const rows: FeedRow[] = [ + ...(links.companyPath + ? [{ + key: "company", + label: m.settings_icsfeeds_company_label(), + description: m.settings_icsfeeds_company_desc(), + url: `${origin}${links.companyPath}`, + sensitive: true, + }] + : []), + ...(links.busyPath + ? [{ + key: "busy", + label: m.settings_icsfeeds_busy_label(), + description: m.settings_icsfeeds_busy_desc(), + url: `${origin}${links.busyPath}`, + }] + : []), + ...(links.schedulePath + ? [{ + key: "schedule", + label: m.settings_icsfeeds_schedule_label(), + description: m.settings_icsfeeds_schedule_desc(), + url: `${origin}${links.schedulePath}`, + sensitive: true, + }] + : []), + ]; + + return ( +
+
+

+ {m.settings_icsfeeds_heading()} +

+

{m.settings_icsfeeds_intro()}

+
+ + {rows.length === 0 ? ( +

{m.settings_icsfeeds_none()}

+ ) : ( +
    + {rows.map((row) => ( +
  • +
    +

    {row.label}

    + {row.sensitive && ( + + {m.settings_icsfeeds_private_badge()} + + )} +
    +

    {row.description}

    +
    + + {row.url} + + +
    +
  • + ))} +
+ )} + + {m.settings_icsfeeds_privacy_note()} + +

+ {m.settings_schedlinks_ics_desc()}{" "} + + {m.settings_schedlinks_ics_learn()} + + . +

+
+ ); +} diff --git a/app/components/settings/ManageOthersPicker.tsx b/app/components/settings/ManageOthersPicker.tsx new file mode 100644 index 000000000..051ceaa57 --- /dev/null +++ b/app/components/settings/ManageOthersPicker.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from "react-router"; +import { m } from "~/paraglide/messages"; + +export interface SchedulingMember { + id: string; + email: string; + role: string; + createdAt: string; +} + +/** + * Admin-only switch for whose schedule the page is editing. Navigates rather + * than holding state so the choice survives a reload and can be linked to — + * `?inspectorId=` is what the loader reads. + */ +export function ManageOthersPicker({ + members, + managedInspectorId, +}: { + members: SchedulingMember[]; + managedInspectorId: string | null; +}) { + const navigate = useNavigate(); + return ( +
+ {m.settings_schedule_managing_for()} + +
+ ); +} diff --git a/app/components/settings/ScheduleLinksPanel.tsx b/app/components/settings/ScheduleLinksPanel.tsx index 03672fbda..8b8cd53c0 100644 --- a/app/components/settings/ScheduleLinksPanel.tsx +++ b/app/components/settings/ScheduleLinksPanel.tsx @@ -1,6 +1,12 @@ import { useCopyClipboard } from "~/hooks/useCopyClipboard"; import { m } from "~/paraglide/messages"; +/** + * The inspector's personal BOOKING link. Calendar subscription feeds moved to + * IcsSubscribePanel — they are a different job (reading your schedule, not + * taking bookings) and describing them in two places let the descriptions + * disagree about what each feed contains. + */ export function ScheduleLinksPanel({ tenant, slug, @@ -43,21 +49,6 @@ export function ScheduleLinksPanel({

)} -
-

{m.settings_schedlinks_ics_label()}

-

- {m.settings_schedlinks_ics_desc()}{" "} - - {m.settings_schedlinks_ics_learn()} - - . -

-
); } diff --git a/app/routes/settings-schedule.tsx b/app/routes/settings-schedule.tsx index 0438fc899..a65833b6a 100644 --- a/app/routes/settings-schedule.tsx +++ b/app/routes/settings-schedule.tsx @@ -1,4 +1,4 @@ -import { useLoaderData, useNavigate } from "react-router"; +import { useLoaderData } from "react-router"; import { SettingsCrumb } from "~/components/SettingsCrumb"; import type { Route } from "./+types/settings-schedule"; import { requireToken } from "~/lib/session.server"; @@ -24,6 +24,8 @@ import { } from "~/components/settings/CalendarConnectPanel"; import type { CalendarPickerData } from "~/components/settings/CalendarReadSetPicker"; import { ScheduleLinksPanel } from "~/components/settings/ScheduleLinksPanel"; +import { IcsSubscribePanel, type IcsLinks } from "~/components/settings/IcsSubscribePanel"; +import { ManageOthersPicker, type SchedulingMember } from "~/components/settings/ManageOthersPicker"; import { SectionNav } from "~/components/settings/SectionNav"; import { m } from "~/paraglide/messages"; @@ -42,13 +44,6 @@ interface DateOverride { endTime: string | null; } -interface Member { - id: string; - email: string; - role: string; - createdAt: string; -} - function civilToday(): string { return new Date().toISOString().slice(0, 10); } @@ -86,12 +81,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { const year = Number(start.slice(0, 4)); const weekStart = startOfCivilWeek(start); - const [availRes, overridesRes, membersRes, calendarStatusRes, blocksRes, configRes, previewRes, weekSummaryRes] = + const [availRes, overridesRes, membersRes, calendarStatusRes, icsLinksRes, blocksRes, configRes, previewRes, weekSummaryRes] = await Promise.all([ api.availability.index.$get({ query: inspectorId ? { inspectorId } : {} }).catch(() => null), api.availability.overrides.$get({ query: inspectorId ? { inspectorId } : {} }).catch(() => null), api.admin.members.$get().catch(() => null), api.calendar.status.$get().catch(() => null), + api.calendar["ics-links"].$get().catch(() => null), api.calendar.blocks .$get({ query: { @@ -124,10 +120,10 @@ export async function loader({ request, context }: Route.LoaderArgs) { overrides = (body.data ?? []) as DateOverride[]; } - let members: Member[] = []; + let members: SchedulingMember[] = []; if (membersRes?.ok) { const body = (await membersRes.json()) as Record; - members = (body.data ?? []) as Member[]; + members = (body.data ?? []) as SchedulingMember[]; } const calendarStatus = calendarStatusRes?.ok @@ -168,6 +164,11 @@ export async function loader({ request, context }: Route.LoaderArgs) { } } + const icsLinks: IcsLinks = icsLinksRes?.ok + ? ((await icsLinksRes.json()) as { data?: IcsLinks }).data + ?? { busyPath: null, schedulePath: null, companyPath: null } + : { busyPath: null, schedulePath: null, companyPath: null }; + let timeOffBlocks: TimeOffBlock[] = []; if (blocksRes?.ok) { const body = (await blocksRes.json()) as { data?: { blocks?: TimeOffBlock[] } }; @@ -218,6 +219,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { overrides, members, managedInspectorId: inspectorId ?? null, + icsLinks, timeOffBlocks, weekSummary, companyClosed: holidayRegion @@ -338,6 +340,7 @@ export default function SettingsSchedulePage() { { id: "time-off", label: m.settings_timeoff_heading() }, { id: "date-overrides", label: m.settings_dateoverrides_heading() }, { id: "schedule-links", label: m.settings_schedlinks_heading() }, + { id: "ics-feeds", label: m.settings_icsfeeds_heading() }, ]; return ( @@ -401,36 +404,9 @@ export default function SettingsSchedulePage() { +
+ +
); } - -function ManageOthersPicker({ - members, - managedInspectorId, -}: { - members: Member[]; - managedInspectorId: string | null; -}) { - const navigate = useNavigate(); - return ( -
- {m.settings_schedule_managing_for()} - -
- ); -} diff --git a/messages/en/settings-components.json b/messages/en/settings-components.json index b2f59b9bb..0fdedddb3 100644 --- a/messages/en/settings-components.json +++ b/messages/en/settings-components.json @@ -584,5 +584,16 @@ "settings_deposit_type_none": "No deposit", "settings_deposit_type_percent": "Percent of the price", "settings_deposit_type_fixed": "Fixed amount", - "settings_deposit_error_save": "Could not save the deposit." + "settings_deposit_error_save": "Could not save the deposit.", + "settings_icsfeeds_heading": "Calendar subscriptions", + "settings_icsfeeds_intro": "Subscribe any calendar app to these links and it stays up to date on its own. Each feed shows a different amount of detail — pick the one that matches who will see it.", + "settings_icsfeeds_none": "Your subscription links appear once your company and user slugs are configured.", + "settings_icsfeeds_private_badge": "Private link", + "settings_icsfeeds_privacy_note": "Anyone holding a link marked private can read that calendar without signing in. Share those only with people already allowed to see the work.", + "settings_icsfeeds_company_label": "Company inspections", + "settings_icsfeeds_company_desc": "Every inspection in the company, with addresses. For an office calendar.", + "settings_icsfeeds_busy_label": "My busy time", + "settings_icsfeeds_schedule_label": "My schedule", + "settings_icsfeeds_busy_desc": "When you are booked, and nothing else — no addresses, names, or emails. Safe to share with agents and partners.", + "settings_icsfeeds_schedule_desc": "Your own jobs with the property address, for your phone." } diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index 707ad7c4a..6a5460308 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -584,5 +584,16 @@ "settings_deposit_type_none": "Sin depósito", "settings_deposit_type_percent": "Porcentaje del precio", "settings_deposit_type_fixed": "Monto fijo", - "settings_deposit_error_save": "No se pudo guardar el depósito." + "settings_deposit_error_save": "No se pudo guardar el depósito.", + "settings_icsfeeds_heading": "Suscripciones de calendario", + "settings_icsfeeds_intro": "Suscribe cualquier aplicación de calendario a estos enlaces y se mantendrá actualizada sola. Cada fuente muestra un nivel de detalle distinto: elige la que corresponda a quién la verá.", + "settings_icsfeeds_none": "Tus enlaces de suscripción aparecerán cuando se configuren los identificadores de tu empresa y tu usuario.", + "settings_icsfeeds_private_badge": "Enlace privado", + "settings_icsfeeds_privacy_note": "Cualquier persona que tenga un enlace marcado como privado puede ver ese calendario sin iniciar sesión. Compártelos solo con quienes ya tienen permiso para ver el trabajo.", + "settings_icsfeeds_company_label": "Inspecciones de la empresa", + "settings_icsfeeds_company_desc": "Todas las inspecciones de la empresa, con direcciones. Para el calendario de la oficina.", + "settings_icsfeeds_busy_label": "Mi tiempo ocupado", + "settings_icsfeeds_schedule_label": "Mi agenda", + "settings_icsfeeds_busy_desc": "Cuándo estás ocupado y nada más: sin direcciones, nombres ni correos. Se puede compartir con agentes y socios.", + "settings_icsfeeds_schedule_desc": "Tus propios trabajos con la dirección de la propiedad, para tu teléfono." } diff --git a/scripts/check-tz-safety.mjs b/scripts/check-tz-safety.mjs index 2af14f39b..28587748b 100644 --- a/scripts/check-tz-safety.mjs +++ b/scripts/check-tz-safety.mjs @@ -75,6 +75,12 @@ const SCOPE = [ // in every tenant zone but UTC, and disagreed with the scheduled_start_ms the // office sees. Both now read the stamped instant. Scoped so they stay that way. 'server/services/booking', + // The inspector iCal feeds. toUtcStamp here composed `${day}T${time}:00Z` — + // a wall clock labelled UTC — so an 08:00 appointment in America/New_York was + // published to every subscriber as 08:00Z, four hours before it happens. The + // file was not in this list while the comment above claimed every real bug + // lives here; that is what let it survive. + 'server/services/ics.service.ts', ]; function collectFiles(path) { diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 904106ec1..6fc54eb27 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -39,17 +39,16 @@ "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/inspections/publish.ts": 505, "server/api/repair-builder.ts": 504, - "server/api/calendar.ts": 502, "app/routes/inspection-edit/action.server.ts": 501, "server/services/report-export-consumer.ts": 499, "app/components/collab/VersionHistoryPanel.tsx": 497, "server/api/admin/admin-config.ts": 472, + "server/api/calendar.ts": 472, "server/lib/compliance/erasure-orchestrator.ts": 472, "server/services/inspection/inspection-core.service.ts": 465, "app/components/inspection/PeopleEditor.tsx": 457, "app/components/editor/CostItemsPanel.tsx": 449, "server/portal/integration.routes.ts": 441, - "app/routes/settings-schedule.tsx": 437, "app/lib/collab/results-doc-connection.ts": 435, "server/api/inspections/media.ts": 435, "server/lib/middleware/di.ts": 434, @@ -58,6 +57,7 @@ "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, "app/routes/templates.tsx": 414, + "app/routes/settings-schedule.tsx": 413, "app/routes/calendar.tsx": 410, "server/services/agreement/signer-state.ts": 409, "app/lib/section-loaders.ts": 402 diff --git a/server/api/calendar-ics-links.ts b/server/api/calendar-ics-links.ts new file mode 100644 index 000000000..9a85d0be7 --- /dev/null +++ b/server/api/calendar-ics-links.ts @@ -0,0 +1,60 @@ +import { and, eq } from 'drizzle-orm'; +import { createApiRouter } from '../lib/openapi-router'; +import { tenantConfigs, tenants, users } from '../lib/db/schema'; +import { mintInspectorIcsToken } from '../lib/calendar/inspector-ics-token'; +import { isAdminRole } from '../lib/auth/roles'; +import { getDrizzle } from '../lib/route-helpers'; + +/** + * GET /api/calendar/ics-links + * + * The three subscribe feeds for the My Schedule catalog, as PATHS. + * + * Paths, not absolute URLs, and that is not a style choice: this API is + * mounted IN-PROCESS behind the React Router server, so `getBaseUrl(c)` + * here resolves to the API worker's own host (127.0.0.1:8787 in dev), not + * the origin the browser is on. Composing the absolute URL server-side + * produced a link that looked right and was dead the moment anyone pasted + * it into a calendar app. The origin is the browser's to supply; the server + * still decides everything that matters — the sealed token only it can + * mint, and the owner-only company feed. + */ +const calendarIcsLinkRoutes = createApiRouter() + .get('/ics-links', async (c) => { + const user = c.get('user'); + if (!user) return c.json({ success: false, error: { message: 'Not authenticated' } }, 401); + const tenantId = c.get('tenantId') as string; + const db = getDrizzle(c); + + const me = await db.select({ slug: users.slug }).from(users) + .where(and(eq(users.id, user.sub), eq(users.tenantId, tenantId))) + .get(); + const tenant = await db.select({ slug: tenants.slug }).from(tenants) + .where(eq(tenants.id, tenantId)).get(); + + // Busy feed: public slug, no PII in the body. Absent until the + // inspector has a slug — rendering a broken link is worse than none. + const busyPath = me?.slug && tenant?.slug + ? `/inspector/${tenant.slug}/${me.slug}/calendar.ics` + : null; + + // Schedule feed: carries addresses, so a sealed token rather than the + // guessable slug. + const schedulePath = c.env.JWT_SECRET + ? `/api/ics/inspector/${encodeURIComponent( + await mintInspectorIcsToken(tenantId, user.sub, c.env.JWT_SECRET), + )}` + : null; + + // Company feed: every inspection in the tenant. Owner/manager only. + let companyPath: string | null = null; + if (isAdminRole(c.get('userRole'))) { + const cfg = await db.select({ icsToken: tenantConfigs.icsToken }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + if (cfg?.icsToken) companyPath = `/api/ics/${cfg.icsToken}`; + } + + return c.json({ success: true, data: { busyPath, schedulePath, companyPath } }, 200); +}) + +export default calendarIcsLinkRoutes; diff --git a/server/api/calendar.ts b/server/api/calendar.ts index c0446a0ab..fe2869190 100644 --- a/server/api/calendar.ts +++ b/server/api/calendar.ts @@ -39,6 +39,7 @@ import { renderCalendarOAuthPopupLanding, } from '../lib/calendar/oauth-popup-landing'; import calendarBlockRoutes from './calendar-blocks'; +import calendarIcsLinkRoutes from './calendar-ics-links'; import calendarItemsRoutes from './calendar-items'; import type { Context } from 'hono'; import type { HonoConfig } from '../types/hono'; @@ -111,6 +112,7 @@ const syncRoute = createRoute(withMcpMetadata({ const calendarRoutes = createApiRouter() .route('/', calendarBlockRoutes) + .route('/', calendarIcsLinkRoutes) .route('/', calendarItemsRoutes) .openapi(disconnectRoute, async (c) => { const user = c.get('user'); diff --git a/server/api/ics.ts b/server/api/ics.ts index b0b9e52b7..0be1e10aa 100644 --- a/server/api/ics.ts +++ b/server/api/ics.ts @@ -6,10 +6,50 @@ import { tenantConfigs, contactRoleProfiles, inspectionPeople } from '../lib/db/ import { contacts } from '../lib/db/schema/contact'; import { PRIMARY_CLIENT_KEY } from '../lib/people/default-role-profiles'; import { logger } from '../lib/logger'; +import { IcsService } from '../services/ics.service'; +import { resolveInspectorIcsToken } from '../lib/calendar/inspector-ics-token'; import type { AppEnv } from '../types/hono'; const icsRoutes = new Hono<{ Bindings: AppEnv }>(); +/** + * GET /api/ics/inspector/:token + * + * One inspector's own schedule WITH property addresses, for their phone. + * + * Deliberately not hung off `/inspector///` like the busy feed: + * that surface is unauthenticated and `users.slug` is a name, so a guessable + * URL would publish an inspector's daily route. The token is sealed under the + * tenant AAD and resolves to (tenantId, userId) on its own — an unopenable or + * tampered token is indistinguishable from a missing one (404 either way). + * + * Registered BEFORE `/:token`; that route matches a single segment, so the two + * cannot collide, but order keeps the intent obvious. + */ +icsRoutes.get('/inspector/:token', async (c) => { + const { token } = c.req.param(); + if (!token || !c.env.JWT_SECRET) return c.text('Not found', 404); + + const resolved = await resolveInspectorIcsToken( + token, c.env.JWT_SECRET, c.env.JWT_SECRET_PREVIOUS, + ); + if (!resolved) return c.text('Not found', 404); + + const host = c.req.header('host') ?? 'openinspection'; + const ics = await new IcsService(c.env.DB, host) + .scheduleFeedForInspector(resolved.tenantId, resolved.userId); + + return new Response(ics, { + status: 200, + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + // Private: this body contains addresses. No shared-cache storage. + 'Cache-Control': 'private, max-age=300', + 'Content-Disposition': 'inline; filename="my-schedule.ics"', + }, + }); +}); + /** * GET /api/ics/:token * Public, token-based ICS subscription feed. diff --git a/server/lib/calendar/inspector-ics-token.ts b/server/lib/calendar/inspector-ics-token.ts new file mode 100644 index 000000000..995ed0a1e --- /dev/null +++ b/server/lib/calendar/inspector-ics-token.ts @@ -0,0 +1,46 @@ +/** + * Per-inspector subscription token for the schedule feed. + * + * The busy feed is addressed by `users.slug` and carries no PII, which is why a + * guessable URL is tolerable there. The SCHEDULE feed carries property + * addresses, and `/inspector/` is unauthenticated — a slug is a name, not a + * secret, so anyone who can guess "mike" would get an inspector's daily route. + * + * Same construction as the SMS opt-in link (`lib/sms/optin-token.ts`) and the + * same reason: no new table. The token is `~`, sealed + * under the tenant's AAD, so tampering with either half fails to open. It is + * deterministic, so the settings page can display it without a write. + * + * TRADE-OFF, stated deliberately: because it is derived rather than stored, + * revoking one inspector's link means rotating JWT_SECRET, which revokes every + * link. `tenant_configs.ics_token` can be rotated per tenant because it is a + * stored random value. If per-inspector revocation is ever needed, this becomes + * a `users` column and this module keeps its shape. + */ +import { sealToken, openToken } from '../config-crypto'; + +const DELIM = '~'; + +export async function mintInspectorIcsToken( + tenantId: string, userId: string, jwtSecret: string, +): Promise { + const sealed = await sealToken(userId, tenantId, jwtSecret); + return `${tenantId}${DELIM}${sealed}`; +} + +/** Returns { tenantId, userId } or null on any format / AAD / key mismatch. */ +export async function resolveInspectorIcsToken( + token: string, jwtSecret: string, jwtSecretPrevious?: string, +): Promise<{ tenantId: string; userId: string } | null> { + const idx = token.indexOf(DELIM); + if (idx <= 0) return null; + const tenantId = token.slice(0, idx); + const sealed = token.slice(idx + 1); + if (!tenantId || !sealed) return null; + try { + const userId = await openToken(sealed, tenantId, jwtSecret, jwtSecretPrevious); + return userId ? { tenantId, userId } : null; + } catch { + return null; + } +} diff --git a/server/services/ics.service.ts b/server/services/ics.service.ts index 227de4619..fb6df2f96 100644 --- a/server/services/ics.service.ts +++ b/server/services/ics.service.ts @@ -1,88 +1,184 @@ import { drizzle } from 'drizzle-orm/d1'; import { and, eq, ne } from 'drizzle-orm'; -import { users } from '../lib/db/schema/tenant'; -import { inspections } from '../lib/db/schema/inspection'; +import { users, tenantConfigs } from '../lib/db/schema'; +import { inspections, inspectionInspectors } from '../lib/db/schema'; +import { resolveTenantTimeZone, wallClockToEpochMs } from '../lib/tz'; /** - * Booking #7 Sprint C-2 — Busy-only iCal feed service. + * The per-inspector iCal feeds. * - * Powers `GET /inspector///calendar.ics` so partner agents can subscribe - * to an inspector's availability without ever seeing customer-facing PII - * (no addresses, names, or emails). Confirmed-only events; cancellations - * disappear from the feed so subscribers see the freed slot. + * Two feeds, one query shape, deliberately different payloads: * - * Uses the same drizzle-on-D1 pattern as UserService so unit tests can swap - * the underlying DB via the `drizzle-orm/d1` module mock. + * - **busy** (`/inspector///calendar.ics`) — opaque "Busy" + * blocks with no addresses, names or emails. Addressed by the public slug + * because there is nothing in it to protect. + * - **schedule** (`/api/ics/inspector/`) — the same appointments WITH + * the property address, for the inspector's own phone. Addressed by a + * sealed token, never the slug: `/inspector/` is unauthenticated and a slug + * is a name, so a guessable URL would hand out someone's daily route. + * + * WHO worked an inspection is read from `inspection_inspectors` through the + * roster join — never `inspections.inspector_id`, which is a frozen legacy + * column. Reading the column made this feed disagree with every other surface: + * an inspection assigned through the link table (which is all of them) simply + * did not appear. + * + * TIMES ARE INSTANTS. `scheduled_start_ms` when the row has one, otherwise the + * civil date read in the TENANT timezone. The previous version composed + * `new Date(`${day}T${time}:00Z`)`, which labels a wall clock as UTC: an 08:00 + * appointment in America/New_York was published to subscribers as 08:00Z — + * 04:00 local, four hours before it happens. */ + +/** Fallback window when a row carries no instant and no duration. */ +const DEFAULT_START_HM = '08:00'; +const DEFAULT_DURATION_MIN = 240; + +interface FeedRow { + id: string; + date: string; + scheduledStartMs: Date | null; + scheduledEndMs: Date | null; + durationMin: number | null; + propertyAddress: string; +} + export class IcsService { constructor(private db: D1Database, private host: string = 'openinspection') {} private getDrizzle() { return drizzle(this.db); } - private toUtcStamp(date: string, time: string): string { - // `date` is YYYY-MM-DD or full ISO timestamp from D1; slice to date-only. - const day = date.slice(0, 10); - return new Date(`${day}T${time}:00Z`) - .toISOString() - .replace(/[-:]/g, '') - .replace(/\.\d{3}/, ''); + private async tenantTimeZone(tenantId: string): Promise { + const row = await this.getDrizzle() + .select({ defaultTimezone: tenantConfigs.defaultTimezone }) + .from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, tenantId)) + .get(); + return resolveTenantTimeZone(row?.defaultTimezone); + } + + /** UTC epoch ms -> RFC-5545 UTC stamp (`YYYYMMDDTHHMMSSZ`). */ + private stamp(ms: number): string { + return new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + } + + /** + * The instant range for one row, in the tenant zone. Same ladder the Google + * push uses, so the calendar entry and the ICS feed can never disagree + * about when an inspection is. + */ + private window(row: FeedRow, tz: string): { startMs: number; endMs: number } { + const stamped = row.scheduledStartMs instanceof Date ? row.scheduledStartMs.getTime() : null; + const day = row.date.slice(0, 10); + const hm = row.date.length > 10 && /^\d{2}:\d{2}$/.test(row.date.slice(11, 16)) + ? row.date.slice(11, 16) + : DEFAULT_START_HM; + const startMs = stamped ?? wallClockToEpochMs(day, hm, tz); + + const stampedEnd = row.scheduledEndMs instanceof Date ? row.scheduledEndMs.getTime() : null; + const endMs = stampedEnd != null && stampedEnd > startMs + ? stampedEnd + : startMs + (row.durationMin ?? DEFAULT_DURATION_MIN) * 60_000; + return { startMs, endMs }; + } + + /** + * Inspections this user LEADS, via the link table. Helper assignments are + * excluded on purpose: these feeds answer "where do I have to be", and the + * lead is the person who owns the appointment. + */ + private async leadAssignments(tenantId: string, userId: string): Promise { + return this.getDrizzle().select({ + id: inspections.id, + date: inspections.date, + scheduledStartMs: inspections.scheduledStartMs, + scheduledEndMs: inspections.scheduledEndMs, + durationMin: inspections.durationMin, + propertyAddress: inspections.propertyAddress, + }) + .from(inspectionInspectors) + .innerJoin(inspections, eq(inspections.id, inspectionInspectors.inspectionId)) + .where(and( + eq(inspectionInspectors.tenantId, tenantId), + eq(inspectionInspectors.userId, userId), + eq(inspectionInspectors.role, 'lead'), + ne(inspections.status, 'cancelled'), + )) + .all() as Promise; } - private emptyCalendar(): string { + private wrap(name: string, events: string[]): string { return [ 'BEGIN:VCALENDAR', 'VERSION:2.0', - 'PRODID:-//OpenInspection//Inspector Busy//EN', + `PRODID:-//OpenInspection//${name}//EN`, 'CALSCALE:GREGORIAN', + ...events, 'END:VCALENDAR', ].join('\r\n'); } + private static escape(s: string): string { + return (s ?? '') + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); + } + /** - * Returns an RFC-5545 calendar showing the inspector as busy on every - * confirmed inspection. The body intentionally omits LOCATION / DESCRIPTION - * so subscribers see only opaque busy blocks — addresses, client names, - * and emails never leave the system. + * Opaque busy blocks for an inspector, by public slug. The body carries no + * LOCATION / DESCRIPTION so addresses, client names and emails never leave + * the system. Cancelled inspections drop out so subscribers see the slot + * freed. */ async busyFeedForInspector(tenantId: string, slug: string): Promise { - const db = this.getDrizzle(); - const user = await db.select({ id: users.id }).from(users) + const user = await this.getDrizzle().select({ id: users.id }).from(users) .where(and(eq(users.tenantId, tenantId), eq(users.slug, slug))) .get(); - if (!user) return this.emptyCalendar(); + if (!user) return this.wrap('Inspector Busy', []); - const rows = await db.select({ - id: inspections.id, - date: inspections.date, - }).from(inspections) - .where(and( - eq(inspections.tenantId, tenantId), - eq(inspections.inspectorId, user.id), - ne(inspections.status, 'cancelled'), - )) - .all(); + const tz = await this.tenantTimeZone(tenantId); + const rows = await this.leadAssignments(tenantId, user.id); const events = rows.map((r) => { - const start = this.toUtcStamp(r.date, '08:00'); - const end = this.toUtcStamp(r.date, '12:00'); + const { startMs, endMs } = this.window(r, tz); return [ 'BEGIN:VEVENT', `UID:${r.id}@${this.host}`, - `DTSTART:${start}`, - `DTEND:${end}`, + `DTSTART:${this.stamp(startMs)}`, + `DTEND:${this.stamp(endMs)}`, 'SUMMARY:Busy', 'TRANSP:OPAQUE', 'END:VEVENT', ].join('\r\n'); }); + return this.wrap('Inspector Busy', events); + } - return [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'PRODID:-//OpenInspection//Inspector Busy//EN', - 'CALSCALE:GREGORIAN', - ...events, - 'END:VCALENDAR', - ].join('\r\n'); + /** + * The inspector's own schedule, addresses included. Caller must have + * resolved the sealed token to (tenantId, userId) first — this method never + * sees a slug, so there is no path by which a guessed name reaches it. + */ + async scheduleFeedForInspector(tenantId: string, userId: string): Promise { + const tz = await this.tenantTimeZone(tenantId); + const rows = await this.leadAssignments(tenantId, userId); + + const events = rows.map((r) => { + const { startMs, endMs } = this.window(r, tz); + const address = IcsService.escape(r.propertyAddress ?? ''); + return [ + 'BEGIN:VEVENT', + `UID:${r.id}@${this.host}`, + `DTSTART:${this.stamp(startMs)}`, + `DTEND:${this.stamp(endMs)}`, + `SUMMARY:${address || 'Inspection'}`, + ...(address ? [`LOCATION:${address}`] : []), + 'TRANSP:OPAQUE', + 'END:VEVENT', + ].join('\r\n'); + }); + return this.wrap('Inspector Schedule', events); } } diff --git a/tests/unit/calendar/ics-busy-feed.spec.ts b/tests/unit/calendar/ics-busy-feed.spec.ts index a7f3f82ea..8a611ed50 100644 --- a/tests/unit/calendar/ics-busy-feed.spec.ts +++ b/tests/unit/calendar/ics-busy-feed.spec.ts @@ -9,8 +9,9 @@ import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; const TENANT = '00000000-0000-0000-0000-000000000001'; const USER = '00000000-0000-0000-0000-000000000010'; +const HELPER = '00000000-0000-0000-0000-000000000011'; -describe('IcsService.busyFeedForInspector — Sprint C-2', () => { +describe('IcsService — inspector feeds', () => { let svc: IcsService; let testDb: BetterSQLite3Database; let sqlite: ReturnType['sqlite']; @@ -25,27 +26,40 @@ describe('IcsService.busyFeedForInspector — Sprint C-2', () => { id: TENANT, name: 'A', slug: 'a', status: 'active', deploymentMode: 'shared', tier: 'free', createdAt: new Date(), }]); - await testDb.insert(schema.users).values([{ - id: USER, tenantId: TENANT, email: 'm@t.com', name: 'Mike', - role: 'inspector', slug: 'mike', passwordHash: 'x', - createdAt: new Date(), - }]); + await testDb.insert(schema.tenantConfigs).values({ + tenantId: TENANT, defaultTimezone: 'America/New_York', updatedAt: new Date(), + }); + await testDb.insert(schema.users).values([ + { + id: USER, tenantId: TENANT, email: 'm@t.com', name: 'Mike', + role: 'inspector', slug: 'mike', passwordHash: 'x', createdAt: new Date(), + }, + { + id: HELPER, tenantId: TENANT, email: 'h@t.com', name: 'Helper', + role: 'inspector', slug: 'helper', passwordHash: 'x', createdAt: new Date(), + }, + ]); await testDb.insert(schema.inspections).values([ { - id: 'i1', tenantId: TENANT, inspectorId: USER, - propertyAddress: '1 Main St', clientName: 'Sarah', clientEmail: 's@t.com', + id: 'i1', tenantId: TENANT, propertyAddress: '1 Main St', + clientName: 'Sarah', clientEmail: 's@t.com', date: '2026-06-01', status: 'confirmed', paymentStatus: 'unpaid', - price: 0, agreementRequired: false, paymentRequired: false, - createdAt: new Date(), + price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(), }, { - id: 'i2', tenantId: TENANT, inspectorId: USER, - propertyAddress: '2 Oak Ave', clientName: 'Bob', clientEmail: 'b@t.com', + id: 'i2', tenantId: TENANT, propertyAddress: '2 Oak Ave', + clientName: 'Bob', clientEmail: 'b@t.com', date: '2026-06-02', status: 'cancelled', paymentStatus: 'unpaid', - price: 0, agreementRequired: false, paymentRequired: false, - createdAt: new Date(), + price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(), }, ]); + // Assignment lives in the LINK TABLE. Note inspections.inspector_id is + // left NULL on purpose: it is the frozen legacy column, and a feed that + // reads it sees nothing here. + await testDb.insert(schema.inspectionInspectors).values([ + { id: 'ii1', tenantId: TENANT, inspectionId: 'i1', userId: USER, role: 'lead', createdAt: new Date() }, + { id: 'ii2', tenantId: TENANT, inspectionId: 'i2', userId: USER, role: 'lead', createdAt: new Date() }, + ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockDrizzle as any).mockReturnValue(testDb); @@ -57,46 +71,118 @@ describe('IcsService.busyFeedForInspector — Sprint C-2', () => { vi.clearAllMocks(); }); - it('emits BEGIN:VCALENDAR with confirmed inspections only and no PII', async () => { - const ics = await svc.busyFeedForInspector(TENANT, 'mike'); - expect(ics).toContain('BEGIN:VCALENDAR'); - expect(ics).toContain('END:VCALENDAR'); - expect(ics).toContain('SUMMARY:Busy'); - expect(ics).toContain('UID:i1@'); - expect(ics).not.toContain('UID:i2@'); - expect(ics).not.toContain('1 Main St'); - expect(ics).not.toContain('Sarah'); - expect(ics).not.toContain('s@t.com'); - expect(ics).not.toMatch(/LOCATION:/); - expect(ics).not.toMatch(/DESCRIPTION:/); - }); + describe('busyFeedForInspector', () => { + it('emits confirmed inspections only and no PII', async () => { + const ics = await svc.busyFeedForInspector(TENANT, 'mike'); + expect(ics).toContain('BEGIN:VCALENDAR'); + expect(ics).toContain('END:VCALENDAR'); + expect(ics).toContain('SUMMARY:Busy'); + expect(ics).toContain('UID:i1@'); + expect(ics).not.toContain('UID:i2@'); + expect(ics).not.toContain('1 Main St'); + expect(ics).not.toContain('Sarah'); + expect(ics).not.toContain('s@t.com'); + expect(ics).not.toMatch(/LOCATION:/); + expect(ics).not.toMatch(/DESCRIPTION:/); + }); - it('returns empty calendar for unknown slug', async () => { - const ics = await svc.busyFeedForInspector(TENANT, 'nonexistent'); - expect(ics).toContain('BEGIN:VCALENDAR'); - expect(ics).toContain('END:VCALENDAR'); - expect(ics).not.toContain('BEGIN:VEVENT'); - }); + /** + * This feed used to filter on `inspections.inspector_id` — the frozen + * legacy column — while every write goes to `inspection_inspectors`. + * The fixture leaves the column NULL, so a version that reads it + * produces an empty calendar for an inspector with real work. + */ + it('reads assignment from the link table, not the dead inspector_id column', async () => { + const ics = await svc.busyFeedForInspector(TENANT, 'mike'); + expect(ics).toContain('BEGIN:VEVENT'); + expect(ics).toContain('UID:i1@'); + }); - it('enforces tenant scope', async () => { - const OTHER = '00000000-0000-0000-0000-000000000099'; - await testDb.insert(schema.tenants).values({ - id: OTHER, name: 'O', slug: 'o', status: 'active', - deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + it('shows lead assignments only, not helper work', async () => { + await testDb.insert(schema.inspectionInspectors).values({ + id: 'ii3', tenantId: TENANT, inspectionId: 'i1', userId: HELPER, + role: 'helper', createdAt: new Date(), + }); + const ics = await svc.busyFeedForInspector(TENANT, 'helper'); + expect(ics).not.toContain('BEGIN:VEVENT'); + }); + + /** + * THE INSTANT, not merely the format. 08:00 in America/New_York on + * 2026-06-01 (EDT, UTC-4) is 12:00Z. The previous implementation + * composed `${day}T08:00:00Z` and published 08:00Z — 04:00 local, four + * hours before the appointment. Subscribers were told the inspector was + * busy at the wrong time of day. + */ + it('publishes the tenant-local start as the correct UTC instant', async () => { + const ics = await svc.busyFeedForInspector(TENANT, 'mike'); + expect(ics).toContain('DTSTART:20260601T120000Z'); + expect(ics).not.toContain('DTSTART:20260601T080000Z'); }); - const ics = await svc.busyFeedForInspector(OTHER, 'mike'); - expect(ics).toContain('BEGIN:VCALENDAR'); - expect(ics).not.toContain('BEGIN:VEVENT'); - }); - it('emits TRANSP:OPAQUE so subscribers see the slot as busy', async () => { - const ics = await svc.busyFeedForInspector(TENANT, 'mike'); - expect(ics).toContain('TRANSP:OPAQUE'); + it('prefers the stamped instant over the civil-date fallback', async () => { + await testDb.update(schema.inspections) + .set({ + scheduledStartMs: new Date(Date.UTC(2026, 5, 1, 17, 30)), + scheduledEndMs: new Date(Date.UTC(2026, 5, 1, 19, 0)), + }); + const ics = await svc.busyFeedForInspector(TENANT, 'mike'); + expect(ics).toContain('DTSTART:20260601T173000Z'); + expect(ics).toContain('DTEND:20260601T190000Z'); + }); + + it('returns an empty calendar for an unknown slug', async () => { + const ics = await svc.busyFeedForInspector(TENANT, 'nonexistent'); + expect(ics).toContain('BEGIN:VCALENDAR'); + expect(ics).not.toContain('BEGIN:VEVENT'); + }); + + it('enforces tenant scope', async () => { + const OTHER = '00000000-0000-0000-0000-000000000099'; + await testDb.insert(schema.tenants).values({ + id: OTHER, name: 'O', slug: 'o', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + const ics = await svc.busyFeedForInspector(OTHER, 'mike'); + expect(ics).toContain('BEGIN:VCALENDAR'); + expect(ics).not.toContain('BEGIN:VEVENT'); + }); + + it('emits TRANSP:OPAQUE so subscribers see the slot as busy', async () => { + expect(await svc.busyFeedForInspector(TENANT, 'mike')).toContain('TRANSP:OPAQUE'); + }); + + it('formats DTSTART/DTEND as UTC stamps (RFC 5545)', async () => { + const ics = await svc.busyFeedForInspector(TENANT, 'mike'); + expect(ics).toMatch(/DTSTART:\d{8}T\d{6}Z/); + expect(ics).toMatch(/DTEND:\d{8}T\d{6}Z/); + }); }); - it('formats DTSTART/DTEND as UTC stamps (RFC 5545)', async () => { - const ics = await svc.busyFeedForInspector(TENANT, 'mike'); - expect(ics).toMatch(/DTSTART:\d{8}T\d{6}Z/); - expect(ics).toMatch(/DTEND:\d{8}T\d{6}Z/); + describe('scheduleFeedForInspector', () => { + it('carries the property address the busy feed withholds', async () => { + const ics = await svc.scheduleFeedForInspector(TENANT, USER); + expect(ics).toContain('SUMMARY:1 Main St'); + expect(ics).toContain('LOCATION:1 Main St'); + }); + + it('still excludes cancelled work and helper assignments', async () => { + const ics = await svc.scheduleFeedForInspector(TENANT, USER); + expect(ics).toContain('UID:i1@'); + expect(ics).not.toContain('UID:i2@'); + expect(await svc.scheduleFeedForInspector(TENANT, HELPER)).not.toContain('BEGIN:VEVENT'); + }); + + it('uses the same corrected instant as the busy feed', async () => { + expect(await svc.scheduleFeedForInspector(TENANT, USER)) + .toContain('DTSTART:20260601T120000Z'); + }); + + it('escapes ICS control characters in an address', async () => { + await testDb.update(schema.inspections) + .set({ propertyAddress: '1 Main St, Apt; 2' }); + const ics = await svc.scheduleFeedForInspector(TENANT, USER); + expect(ics).toContain('LOCATION:1 Main St\\, Apt\\; 2'); + }); }); }); From b44e2753888d192e164d3a1d56947a6c5e9fd61c Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 03:43:58 +0800 Subject: [PATCH 71/77] feat(calendar): sync on a schedule, and say why when it does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cron half calls importBusyForConnection — the same function the "Sync now" button calls. A sweep with its own copy of the import would drift, and synced-automatically would quietly become a different feature from synced-when-I-ask. last_sync_error is the only new column (last_synced_at already exists as last_sync_at). It earns its place because the freshness badge cannot tell "nothing changed" from "nobody has reached Google in three days" — both look like an old timestamp. The usual cause is a revoked token and the inspector is the only one who can fix it, so the reason is surfaced in the connect panel next to the reconnect button. Two invariants the tests pin by deletion: - a FAILED sync must not advance last_sync_at. That column vouches for data we hold, and a failed attempt refreshed nothing; the badge stays stale AND gains a reason, which is the honest pair. - a SUCCESSFUL sync must clear last_sync_error, or a recovered connection keeps prompting for the failure it already survived. Stalest-first with a per-tick cap is the fairness mechanism: it spreads work across tenants without a per-tenant loop, because a tenant swept this tick sorts to the back of the next one. Both are pinned by tests. No new router. The plan called for calendar-sync.ts, but POST /api/calendar/sync already triggers a sync and /status already reports connection state — a second router would have been a second way to do one thing, which is the failure this whole phase keeps working around. The sweep body lives in lib/calendar/sync-sweep.ts and scheduled.ts gains ten lines. settings-schedule.tsx crossed its cap again on the way, so the loader's envelope-unwrapping moved to lib/settings/calendar-section.server.ts; the route is 369 lines now, down from 437 when this batch started. --- .../settings/CalendarConnectPanel.tsx | 14 +- app/lib/settings/calendar-section.server.ts | 95 + app/routes/settings-schedule.tsx | 65 +- messages/en/settings-components.json | 3 +- messages/es-419/settings-components.json | 3 +- migrations/0045_fat_trauma.sql | 1 + migrations/meta/0045_snapshot.json | 11096 ++++++++++++++++ migrations/meta/_journal.json | 7 + scripts/file-size-baseline.json | 1 - server/lib/calendar/connection.ts | 27 +- server/lib/calendar/status.ts | 5 + server/lib/calendar/sync-sweep.ts | 126 + server/lib/db/schema/calendar.ts | 11 + server/scheduled.ts | 19 + tests/unit/calendar/calendar-api.spec.ts | 31 +- tests/unit/calendar/sync-sweep.spec.ts | 178 + 16 files changed, 11622 insertions(+), 60 deletions(-) create mode 100644 app/lib/settings/calendar-section.server.ts create mode 100644 migrations/0045_fat_trauma.sql create mode 100644 migrations/meta/0045_snapshot.json create mode 100644 server/lib/calendar/sync-sweep.ts create mode 100644 tests/unit/calendar/sync-sweep.spec.ts diff --git a/app/components/settings/CalendarConnectPanel.tsx b/app/components/settings/CalendarConnectPanel.tsx index 69990f5b1..3a7d89528 100644 --- a/app/components/settings/CalendarConnectPanel.tsx +++ b/app/components/settings/CalendarConnectPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useFetcher, useRevalidator, useSearchParams } from "react-router"; -import { RadioCardGroup } from "@core/shared-ui"; +import { Banner, RadioCardGroup } from "@core/shared-ui"; import { GoogleSignInButton } from "~/components/GoogleSignInButton"; import { CalendarGlyph } from "~/components/settings/CalendarGlyph"; import { calendarOAuthErrorToast } from "~/lib/calendar-oauth-errors"; @@ -22,12 +22,15 @@ export function CalendarConnectPanel({ connected, capability: connectedCapability, oauthConfigured, + lastSyncError, disabled = false, picker = null, }: { connected: boolean; capability: CalendarCapability | null; oauthConfigured: boolean; + /** Why the last sync attempt failed; null once one succeeds. */ + lastSyncError?: string | null; disabled?: boolean; picker?: CalendarPickerData | null; }) { @@ -150,6 +153,15 @@ export function CalendarConnectPanel({

) : connected ? (
+ {/* A stale freshness badge cannot distinguish "nothing changed" from + "nobody has been able to reach Google for three days". The common + cause is a revoked token, and reconnecting is something only this + person can do — so the reason belongs next to the button. */} + {lastSyncError && ( + + {m.settings_calconnect_sync_error({ reason: lastSyncError })} + + )}
{m.settings_conn_status_connected()} diff --git a/app/lib/settings/calendar-section.server.ts b/app/lib/settings/calendar-section.server.ts new file mode 100644 index 000000000..bd8bbefa5 --- /dev/null +++ b/app/lib/settings/calendar-section.server.ts @@ -0,0 +1,95 @@ +import type { CalendarCapability } from "~/components/settings/CalendarConnectPanel"; +import type { CalendarPickerData } from "~/components/settings/CalendarReadSetPicker"; +import type { IcsLinks } from "~/components/settings/IcsSubscribePanel"; + +const NO_LINKS: IcsLinks = { busyPath: null, schedulePath: null, companyPath: null }; + +/** + * The subset of a fetch response this module uses. Deliberately structural + * rather than `Response`: hono/client returns a ClientResponse, which is + * Response-shaped for reading purposes but not assignable to it. + */ +interface ReadableRes { + ok: boolean; + json: () => Promise; +} + +export interface CalendarSection { + connected: boolean; + capability: CalendarCapability | null; + oauthConfigured: boolean; + lastSyncError: string | null; + picker: CalendarPickerData | null; +} + +/** + * Everything the My Schedule page needs to know about the Google connection, + * shaped in one place. + * + * It lives here rather than inline in the route because three separate + * endpoints (status, read-set, ics-links) each need their envelope unwrapped + * and defaulted, and that parsing is the bulk of the route's loader without + * being any of the route's actual concerns. + * + * Every leg is best-effort: a Google hiccup hides the picker, it does not fail + * the settings page. + */ +export async function loadCalendarSection( + statusRes: ReadableRes | null, + // Fetched by the caller so it joins the page's single Promise.all rather + // than adding a serial round trip. + icsLinksRes: ReadableRes | null, + readSet: () => Promise, + /** Managing someone else's schedule: the picker is owner-only, so skip it. */ + managingOther: boolean, +): Promise<{ calendar: CalendarSection; icsLinks: IcsLinks }> { + const status = statusRes?.ok + ? ((await statusRes.json()) as { + data?: { + connected?: boolean; + capability?: CalendarCapability | null; + oauthConfigured?: boolean; + lastSyncError?: string | null; + }; + }).data + : null; + + let picker: CalendarPickerData | null = null; + if (status?.connected && !managingOther) { + const res = await readSet(); + if (res?.ok) { + const d = ((await res.json()) as { + data?: { + connected?: boolean; + connectionId?: string; + writeCalendarId?: string; + readCalendarIds?: string[]; + calendars?: CalendarPickerData["calendars"]; + }; + }).data; + if (d?.connected && d.connectionId) { + picker = { + connectionId: d.connectionId, + writeCalendarId: d.writeCalendarId ?? "", + readCalendarIds: d.readCalendarIds ?? [], + calendars: d.calendars ?? [], + }; + } + } + } + + const icsLinks = icsLinksRes?.ok + ? ((await icsLinksRes.json()) as { data?: IcsLinks }).data ?? NO_LINKS + : NO_LINKS; + + return { + calendar: { + connected: status?.connected ?? false, + capability: status?.capability ?? null, + oauthConfigured: status?.oauthConfigured ?? false, + lastSyncError: status?.lastSyncError ?? null, + picker, + }, + icsLinks, + }; +} diff --git a/app/routes/settings-schedule.tsx b/app/routes/settings-schedule.tsx index a65833b6a..df70d1ec7 100644 --- a/app/routes/settings-schedule.tsx +++ b/app/routes/settings-schedule.tsx @@ -18,13 +18,10 @@ import { AvailabilityHeatmapWeek, type HeatmapDay, } from "~/components/settings/AvailabilityHeatmapWeek"; -import { - CalendarConnectPanel, - type CalendarCapability, -} from "~/components/settings/CalendarConnectPanel"; -import type { CalendarPickerData } from "~/components/settings/CalendarReadSetPicker"; +import { CalendarConnectPanel } from "~/components/settings/CalendarConnectPanel"; +import { loadCalendarSection } from "~/lib/settings/calendar-section.server"; import { ScheduleLinksPanel } from "~/components/settings/ScheduleLinksPanel"; -import { IcsSubscribePanel, type IcsLinks } from "~/components/settings/IcsSubscribePanel"; +import { IcsSubscribePanel } from "~/components/settings/IcsSubscribePanel"; import { ManageOthersPicker, type SchedulingMember } from "~/components/settings/ManageOthersPicker"; import { SectionNav } from "~/components/settings/SectionNav"; import { m } from "~/paraglide/messages"; @@ -126,48 +123,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { members = (body.data ?? []) as SchedulingMember[]; } - const calendarStatus = calendarStatusRes?.ok - ? ((await calendarStatusRes.json()) as { - data?: { - connected?: boolean; - capability?: CalendarCapability | null; - oauthConfigured?: boolean; - }; - }).data - : null; - - // A-polish 10b — the read-set / write-target picker data. Owner-only (the - // endpoint uses the current user's connection), so skip it when managing - // someone else's schedule. Best-effort: a Google hiccup just hides the picker. - let calendarPicker: CalendarPickerData | null = null; - if (calendarStatus?.connected && !inspectorId) { - const readSetRes = await api.calendar["read-set"].$get().catch(() => null); - if (readSetRes?.ok) { - const body = (await readSetRes.json()) as { - data?: { - connected?: boolean; - connectionId?: string; - writeCalendarId?: string; - readCalendarIds?: string[]; - calendars?: CalendarPickerData["calendars"]; - }; - }; - const d = body.data; - if (d?.connected && d.connectionId) { - calendarPicker = { - connectionId: d.connectionId, - writeCalendarId: d.writeCalendarId ?? "", - readCalendarIds: d.readCalendarIds ?? [], - calendars: d.calendars ?? [], - }; - } - } - } - - const icsLinks: IcsLinks = icsLinksRes?.ok - ? ((await icsLinksRes.json()) as { data?: IcsLinks }).data - ?? { busyPath: null, schedulePath: null, companyPath: null } - : { busyPath: null, schedulePath: null, companyPath: null }; + const { calendar, icsLinks } = await loadCalendarSection( + calendarStatusRes, + icsLinksRes, + () => api.calendar["read-set"].$get().catch(() => null), + inspectorId !== undefined, + ); let timeOffBlocks: TimeOffBlock[] = []; if (blocksRes?.ok) { @@ -225,12 +186,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { companyClosed: holidayRegion ? { holidayRegion, holidayPublicPolicy, upcomingClosed } : null, - calendar: { - connected: calendarStatus?.connected ?? false, - capability: calendarStatus?.capability ?? null, - oauthConfigured: calendarStatus?.oauthConfigured ?? false, - picker: calendarPicker, - }, + calendar, }; } @@ -362,6 +318,7 @@ export default function SettingsSchedulePage() { connected={data.calendar.connected} capability={data.calendar.capability} oauthConfigured={data.calendar.oauthConfigured} + lastSyncError={data.calendar.lastSyncError} disabled={data.managedInspectorId !== null} picker={data.calendar.picker} /> diff --git a/messages/en/settings-components.json b/messages/en/settings-components.json index 0fdedddb3..5ad6c317c 100644 --- a/messages/en/settings-components.json +++ b/messages/en/settings-components.json @@ -595,5 +595,6 @@ "settings_icsfeeds_busy_label": "My busy time", "settings_icsfeeds_schedule_label": "My schedule", "settings_icsfeeds_busy_desc": "When you are booked, and nothing else — no addresses, names, or emails. Safe to share with agents and partners.", - "settings_icsfeeds_schedule_desc": "Your own jobs with the property address, for your phone." + "settings_icsfeeds_schedule_desc": "Your own jobs with the property address, for your phone.", + "settings_calconnect_sync_error": "Google Calendar sync last failed: {reason} Reconnect below if this keeps happening." } diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index 6a5460308..d8beb469e 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -595,5 +595,6 @@ "settings_icsfeeds_busy_label": "Mi tiempo ocupado", "settings_icsfeeds_schedule_label": "Mi agenda", "settings_icsfeeds_busy_desc": "Cuándo estás ocupado y nada más: sin direcciones, nombres ni correos. Se puede compartir con agentes y socios.", - "settings_icsfeeds_schedule_desc": "Tus propios trabajos con la dirección de la propiedad, para tu teléfono." + "settings_icsfeeds_schedule_desc": "Tus propios trabajos con la dirección de la propiedad, para tu teléfono.", + "settings_calconnect_sync_error": "La última sincronización con Google Calendar falló: {reason} Vuelve a conectar abajo si esto continúa." } diff --git a/migrations/0045_fat_trauma.sql b/migrations/0045_fat_trauma.sql new file mode 100644 index 000000000..82cfb306d --- /dev/null +++ b/migrations/0045_fat_trauma.sql @@ -0,0 +1 @@ +ALTER TABLE `calendar_connections` ADD `last_sync_error` text; \ No newline at end of file diff --git a/migrations/meta/0045_snapshot.json b/migrations/meta/0045_snapshot.json new file mode 100644 index 000000000..2a2afa572 --- /dev/null +++ b/migrations/meta/0045_snapshot.json @@ -0,0 +1,11096 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0d7f060e-3721-41d8-8f82-b0703b562ff1", + "prevId": "7646ab6d-5547-4f7a-ba68-0ce202d3a5c8", + "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 + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "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": {} + }, + "calendar_external_links": { + "name": "calendar_external_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "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": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "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_calendar_external_links_entity": { + "name": "uq_calendar_external_links_entity", + "columns": [ + "tenant_id", + "provider", + "entity_type", + "entity_id" + ], + "isUnique": true + }, + "idx_calendar_external_links_user": { + "name": "idx_calendar_external_links_user", + "columns": [ + "tenant_id", + "user_id", + "provider" + ], + "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_service_pay_splits": { + "name": "inspection_service_pay_splits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_service_id": { + "name": "inspection_service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrects_split_id": { + "name": "corrects_split_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "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_pay_split_line_user": { + "name": "uq_pay_split_line_user", + "columns": [ + "tenant_id", + "inspection_service_id", + "user_id" + ], + "isUnique": true, + "where": "corrects_split_id IS NULL" + }, + "idx_pay_split_user": { + "name": "idx_pay_split_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_pay_split_line": { + "name": "idx_pay_split_line", + "columns": [ + "tenant_id", + "inspection_service_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "deposit_required_cents": { + "name": "deposit_required_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deposit_overridden": { + "name": "is_deposit_overridden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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": {} + }, + "inspector_service_areas": { + "name": "inspector_service_areas", + "columns": { + "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 + }, + "zip_prefix": { + "name": "zip_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_service_areas_tenant": { + "name": "idx_inspector_service_areas_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_service_areas_user": { + "name": "idx_inspector_service_areas_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "uq_inspector_service_areas": { + "name": "uq_inspector_service_areas", + "columns": [ + "tenant_id", + "user_id", + "zip_prefix" + ], + "isUnique": true + } + }, + "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": {} + }, + "service_pay_rules": { + "name": "service_pay_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_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 + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deduction_cents": { + "name": "deduction_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_service_pay_rules_user": { + "name": "uq_service_pay_rules_user", + "columns": [ + "tenant_id", + "service_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "uq_service_pay_rules_default": { + "name": "uq_service_pay_rules_default", + "columns": [ + "tenant_id", + "service_id" + ], + "isUnique": true, + "where": "user_id IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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 + }, + "deposit_policy": { + "name": "deposit_policy", + "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'" + }, + "cancellation_policy": { + "name": "cancellation_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_agreement_id": { + "name": "cancellation_clause_agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_version": { + "name": "cancellation_clause_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_attested_at": { + "name": "cancellation_clause_attested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deposit_policy": { + "name": "deposit_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "booking_routing_strategy": { + "name": "booking_routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'first_available'" + }, + "booking_min_lead_hours": { + "name": "booking_min_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "booking_same_day_cutoff_time": { + "name": "booking_same_day_cutoff_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lat": { + "name": "company_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lng": { + "name": "company_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_geocoded_at": { + "name": "company_geocoded_at", + "type": "integer", + "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 + }, + "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 + }, + "service_origin_address": { + "name": "service_origin_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lat": { + "name": "service_origin_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lng": { + "name": "service_origin_lng", + "type": "real", + "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 affde09a0..113bd3c1d 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1786039744996, "tag": "0044_amused_rick_jones", "breakpoints": true + }, + { + "idx": 45, + "version": "6", + "when": 1786044032810, + "tag": "0045_fat_trauma", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 6fc54eb27..642deaa94 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -57,7 +57,6 @@ "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, "app/routes/templates.tsx": 414, - "app/routes/settings-schedule.tsx": 413, "app/routes/calendar.tsx": 410, "server/services/agreement/signer-state.ts": 409, "app/lib/section-loaders.ts": 402 diff --git a/server/lib/calendar/connection.ts b/server/lib/calendar/connection.ts index 5aae94eab..612db13fb 100644 --- a/server/lib/calendar/connection.ts +++ b/server/lib/calendar/connection.ts @@ -146,8 +146,33 @@ export async function markCalendarSynced( provider: CalendarProviderId = 'google', ): Promise { const drizzleDb = drizzle(db); + // Clearing lastSyncError is half the job. Left behind, a fixed connection + // would keep showing the reconnect prompt for the failure it recovered from. await drizzleDb.update(calendarConnections) - .set({ lastSyncAt: new Date() }) + .set({ lastSyncAt: new Date(), lastSyncError: null }) + .where(and( + eq(calendarConnections.tenantId, tenantId), + eq(calendarConnections.userId, userId), + eq(calendarConnections.provider, provider), + )); +} + +/** + * Records why the latest sync attempt failed. Deliberately does NOT touch + * lastSyncAt: that column vouches for the freshness of data we actually hold, + * and a failed attempt did not refresh anything. The badge stays stale AND + * gains a reason, which is the honest pair. + */ +export async function markCalendarSyncFailed( + db: D1Database, + tenantId: string, + userId: string, + message: string, + provider: CalendarProviderId = 'google', +): Promise { + const drizzleDb = drizzle(db); + await drizzleDb.update(calendarConnections) + .set({ lastSyncError: message.slice(0, 500) }) .where(and( eq(calendarConnections.tenantId, tenantId), eq(calendarConnections.userId, userId), diff --git a/server/lib/calendar/status.ts b/server/lib/calendar/status.ts index be91839ae..dfa6ba7a2 100644 --- a/server/lib/calendar/status.ts +++ b/server/lib/calendar/status.ts @@ -13,5 +13,10 @@ export async function getGoogleCalendarStatus( capability: connection?.capabilities ?? null, provider: 'google' as const, oauthConfigured: await isGoogleOAuthConfigured(env, tenantId), + lastSyncAt: connection?.lastSyncAt instanceof Date ? connection.lastSyncAt.getTime() : null, + // NULL once a sync succeeds. Surfaced because a stale freshness badge + // cannot say whether nothing changed or nobody could reach Google — + // and a revoked token is only fixable by the person who sees this. + lastSyncError: connection?.lastSyncError ?? null, }; } diff --git a/server/lib/calendar/sync-sweep.ts b/server/lib/calendar/sync-sweep.ts new file mode 100644 index 000000000..5fcb640ba --- /dev/null +++ b/server/lib/calendar/sync-sweep.ts @@ -0,0 +1,126 @@ +/** + * The cron half of calendar sync — so inspectors stop pressing "Sync now". + * + * It calls `importBusyForConnection`, the same function the button calls. That + * is the point: a sweep with its own copy of the import would drift, and + * "synced automatically" would quietly become a different feature from "synced + * when I ask". + * + * Two properties this owes the rest of the system: + * + * - **It never throws.** It runs inside `scheduled()` alongside unrelated + * jobs; one tenant's revoked Google token must not stop agreement expiry. + * Every failure is recorded on the connection and counted. + * - **It records WHY.** A stale badge cannot distinguish "nothing changed" + * from "we have not reached Google in three days". `last_sync_error` is the + * difference, and the inspector is the only one who can fix a revoked token. + */ +import { drizzle } from 'drizzle-orm/d1'; +import { and, asc, eq, isNull, lt, or } from 'drizzle-orm'; +import { calendarConnections } from '../db/schema'; +import { logger } from '../logger'; +import { importBusyForConnection } from './sync-engine'; +import { loadOpenGoogleConnection, markCalendarSynced, markCalendarSyncFailed } from './connection'; +import { loadGoogleOAuthMode, resolveGoogleOAuthCredentials } from './resolve-google-oauth'; + +export interface CalendarSweepEnv { + DB: D1Database; + TENANT_CACHE: KVNamespace; + JWT_SECRET: string; + JWT_SECRET_PREVIOUS?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; +} + +/** A connection is due when its last success is older than this. */ +export const SYNC_INTERVAL_MS = 15 * 60 * 1000; + +/** + * Ceiling per tick. The cron fires every five minutes, and each connection + * costs at least one Google round trip per calendar in its read set — an + * unbounded sweep would let one large tenant monopolise the invocation's + * wall-clock budget. Stalest-first ordering means the ones skipped this tick + * are first in line on the next. + */ +export const MAX_CONNECTIONS_PER_TICK = 25; + +export interface SweepResult { + attempted: number; + succeeded: number; + failed: number; +} + +export async function sweepCalendarSyncs( + env: CalendarSweepEnv, + nowMs: number = Date.now(), +): Promise { + const db = drizzle(env.DB); + const dueBefore = new Date(nowMs - SYNC_INTERVAL_MS); + + // Stalest first, never-synced ahead of everything. This is the fairness + // mechanism: it spreads work across tenants without a per-tenant loop, + // because a tenant swept this tick sorts to the back for the next. + const due = await db.select().from(calendarConnections) + .where(and( + eq(calendarConnections.provider, 'google'), + or( + isNull(calendarConnections.lastSyncAt), + lt(calendarConnections.lastSyncAt, dueBefore), + ), + )) + .orderBy(asc(calendarConnections.lastSyncAt)) + .limit(MAX_CONNECTIONS_PER_TICK) + .all(); + + const result: SweepResult = { attempted: due.length, succeeded: 0, failed: 0 }; + + for (const row of due) { + try { + // Re-open through the normal path so a connection whose credentials + // no longer decrypt is treated as not-connected rather than as a + // sync failure that would be retried forever. + const open = await loadOpenGoogleConnection( + env.DB, row.tenantId, row.userId, env.JWT_SECRET, env.JWT_SECRET_PREVIOUS, + ); + if (!open) { + await markCalendarSyncFailed( + env.DB, row.tenantId, row.userId, + 'Calendar credentials could not be read. Reconnect Google Calendar.', + ); + result.failed++; + continue; + } + + const mode = await loadGoogleOAuthMode(env.DB, row.tenantId); + const creds = await resolveGoogleOAuthCredentials(env, row.tenantId, mode); + if (!creds) { + // A deployment-level gap, not this inspector's problem — do not + // stamp an error they cannot act on. + continue; + } + + await importBusyForConnection(db, open.connection, { + clientId: creds.clientId, + clientSecret: creds.clientSecret, + refreshToken: open.credentials.refreshToken, + }, nowMs); + + await markCalendarSynced(env.DB, row.tenantId, row.userId); + result.succeeded++; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + result.failed++; + try { + await markCalendarSyncFailed(env.DB, row.tenantId, row.userId, message); + } catch { + // Recording the failure failed too. Nothing further to try; the + // next tick re-attempts the sync itself. + } + logger.warn('[cron:calendar] connection sync failed', { + tenantId: row.tenantId, userId: row.userId, error: message, + }); + } + } + + return result; +} diff --git a/server/lib/db/schema/calendar.ts b/server/lib/db/schema/calendar.ts index 39a7e1826..1bf14a46e 100644 --- a/server/lib/db/schema/calendar.ts +++ b/server/lib/db/schema/calendar.ts @@ -22,6 +22,17 @@ export const calendarConnections = sqliteTable('calendar_connections', { * sync-freshness badge on the calendar Team chips. */ lastSyncAt: integer('last_sync_at', { mode: 'timestamp_ms' }), + /** + * Why the most recent sync attempt failed, or NULL when the last attempt + * succeeded. Cleared on every success, so it always describes the CURRENT + * state rather than accumulating history. + * + * It exists because the freshness badge cannot tell "nothing changed" from + * "we have not been able to reach Google for three days" — both look like + * an old lastSyncAt. A revoked token is the common case and the inspector + * is the only person who can fix it, so the reason has to reach them. + */ + lastSyncError: text('last_sync_error'), }, (t) => [ uniqueIndex('uq_calendar_connections_user_provider').on(t.userId, t.provider), index('idx_calendar_connections_tenant_user').on(t.tenantId, t.userId), diff --git a/server/scheduled.ts b/server/scheduled.ts index edd6b3c2a..54d149bb4 100644 --- a/server/scheduled.ts +++ b/server/scheduled.ts @@ -51,6 +51,10 @@ export interface ScheduledEnv { /** Shared Messaging Service SID for managed_shared tenants (Task 8 send gate). */ TWILIO_SHARED_MESSAGING_SERVICE_SID?: string; TENANT_CACHE?: KVNamespace; + /** Platform Google OAuth client, for the calendar sync sweep. Tenants with + * their OWN client are resolved from encrypted tenant secrets instead. */ + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; // Core -> portal user-sync transport (A-13/A-14). Producer binding to the // sync queue; the outbox sweeper republishes pending rows through it. // Optional — sweeper is a no-op when missing (standalone). @@ -314,6 +318,21 @@ export async function scheduled( } } + // 5d. Pull each connected inspector's Google busy time on a schedule, so + // "Sync now" stops being something anyone has to remember. Body lives + // in lib/calendar/sync-sweep; it never throws and records its own + // per-connection failures as last_sync_error. + if (env.TENANT_CACHE && env.JWT_SECRET) { + const { sweepCalendarSyncs } = await import('./lib/calendar/sync-sweep'); + const swept = await sweepCalendarSyncs({ + DB: env.DB, TENANT_CACHE: env.TENANT_CACHE, JWT_SECRET: env.JWT_SECRET, + ...(env.JWT_SECRET_PREVIOUS ? { JWT_SECRET_PREVIOUS: env.JWT_SECRET_PREVIOUS } : {}), + ...(env.GOOGLE_CLIENT_ID ? { GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID } : {}), + ...(env.GOOGLE_CLIENT_SECRET ? { GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET } : {}), + }); + if (swept.attempted > 0) logger.info('[cron] calendar sync sweep', swept); + } + // 6. Track I-a GDPR retention sweep (spec §7) — final destruction of // past-window signed-agreement signatures (signature_base64 -> NULL + // purged_at marker). Keeps the esign_audit_logs chain. Idempotent, diff --git a/tests/unit/calendar/calendar-api.spec.ts b/tests/unit/calendar/calendar-api.spec.ts index ea02f1090..a7f6b7b3f 100644 --- a/tests/unit/calendar/calendar-api.spec.ts +++ b/tests/unit/calendar/calendar-api.spec.ts @@ -5,7 +5,7 @@ import * as schema from '../../../server/lib/db/schema'; import calendarRoutes from '../../../server/api/calendar'; import type { HonoConfig } from '../../../server/types/hono'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -import { upsertCalendarConnection } from '../../../server/lib/calendar/connection'; +import { upsertCalendarConnection, markCalendarSyncFailed } from '../../../server/lib/calendar/connection'; import { MockKV } from '../mocks'; vi.mock('drizzle-orm/d1', () => ({ @@ -227,6 +227,9 @@ describe('calendar API — calendar_connections', () => { const res = await app.request('/api/calendar/status', {}, env); expect(res.status).toBe(200); + // Exact-shape assertion on purpose: this payload is the contract the + // My Schedule panel reads, and a silently added or dropped field is + // exactly the drift a toMatchObject here would wave through. expect(await res.json()).toEqual({ success: true, data: { @@ -234,7 +237,33 @@ describe('calendar API — calendar_connections', () => { capability: 'availability_read', provider: 'google', oauthConfigured: true, + // Never synced yet: stale, with no reason to show for it. + lastSyncAt: null, + lastSyncError: null, }, }); }); + + it('status carries the reason a sync failed so the panel can prompt a reconnect', async () => { + await upsertCalendarConnection({ + db: {} as D1Database, + tenantId: TENANT, + userId: USER, + provider: 'google', + authType: 'oauth', + capability: 'events_read_write', + calendarId: 'primary', + credentials: { refreshToken: 'rt-status', scopes: ['calendar.events'] }, + jwtSecret: JWT_SECRET, + }); + await markCalendarSyncFailed({} as D1Database, TENANT, USER, 'invalid_grant: token revoked'); + + const { app, env } = buildApp(testDb, kv); + const res = await app.request('/api/calendar/status', {}, env); + const body = await res.json() as { data: { lastSyncError: string | null; lastSyncAt: number | null } }; + + expect(body.data.lastSyncError).toContain('invalid_grant'); + // A failed attempt refreshed nothing, so freshness must not advance. + expect(body.data.lastSyncAt).toBeNull(); + }); }); diff --git a/tests/unit/calendar/sync-sweep.spec.ts b/tests/unit/calendar/sync-sweep.spec.ts new file mode 100644 index 000000000..531793279 --- /dev/null +++ b/tests/unit/calendar/sync-sweep.spec.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema, toRawD1 } from '../db'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +vi.mock('drizzle-orm/d1', async (orig) => ({ + ...(await orig>()), + drizzle: vi.fn(), +})); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +const importBusy = vi.fn(); +vi.mock('../../../server/lib/calendar/sync-engine', () => ({ + importBusyForConnection: (...a: unknown[]) => importBusy(...a), +})); + +const openConn = vi.fn(); +vi.mock('../../../server/lib/calendar/connection', async (orig) => ({ + ...(await orig>()), + loadOpenGoogleConnection: (...a: unknown[]) => openConn(...a), +})); + +vi.mock('../../../server/lib/calendar/resolve-google-oauth', () => ({ + loadGoogleOAuthMode: async () => 'platform', + resolveGoogleOAuthCredentials: async () => ({ clientId: 'cid', clientSecret: 'sec' }), +})); + +import { + sweepCalendarSyncs, + SYNC_INTERVAL_MS, + MAX_CONNECTIONS_PER_TICK, +} from '../../../server/lib/calendar/sync-sweep'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const NOW = Date.UTC(2026, 5, 1, 12, 0); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyDb = any; + +describe('sweepCalendarSyncs', () => { + let db: BetterSQLite3Database; + let sqlite: ReturnType['sqlite']; + let env: Parameters[0]; + + async function addConnection(id: string, userId: string, lastSyncAt: Date | null) { + await db.insert(schema.calendarConnections).values({ + id, tenantId: TENANT, userId, provider: 'google', authType: 'oauth', + credentialsEnc: 'x', credentialsDekEnc: 'x', + capabilities: 'events_read_write', calendarId: 'primary', + connectedAt: new Date(NOW - 90 * 60 * 1000), + updatedAt: new Date(NOW - 90 * 60 * 1000), + lastSyncAt, + }); + } + + const rows = () => db.select().from(schema.calendarConnections).all(); + + beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + sqlite = fixture.sqlite; + await setupSchema(sqlite); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'A', slug: 'a', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); + env = { + DB: toRawD1(sqlite), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TENANT_CACHE: {} as any, + JWT_SECRET: 's', + }; + importBusy.mockReset().mockResolvedValue({ upserted: 1, totalEvents: 1, skipped: {} }); + openConn.mockReset().mockResolvedValue({ + connection: { id: 'c1', tenantId: TENANT, userId: 'u1', calendarId: 'primary', capabilities: 'events_read_write' }, + credentials: { refreshToken: 'rt' }, + }); + }); + + afterEach(() => { sqlite.close(); vi.clearAllMocks(); }); + + it('syncs a connection that has never synced', async () => { + await addConnection('c1', 'u1', null); + const out = await sweepCalendarSyncs(env, NOW); + expect(out).toMatchObject({ attempted: 1, succeeded: 1, failed: 0 }); + expect(importBusy).toHaveBeenCalledTimes(1); + }); + + it('leaves a connection synced inside the interval alone', async () => { + await addConnection('c1', 'u1', new Date(NOW - SYNC_INTERVAL_MS + 60_000)); + const out = await sweepCalendarSyncs(env, NOW); + expect(out.attempted).toBe(0); + expect(importBusy).not.toHaveBeenCalled(); + }); + + it('picks up a connection once the interval has elapsed', async () => { + await addConnection('c1', 'u1', new Date(NOW - SYNC_INTERVAL_MS - 1)); + expect((await sweepCalendarSyncs(env, NOW)).attempted).toBe(1); + }); + + it('stamps lastSyncAt and clears any previous error on success', async () => { + await addConnection('c1', 'u1', null); + await db.update(schema.calendarConnections).set({ lastSyncError: 'token revoked' }); + + await sweepCalendarSyncs(env, NOW); + const row = (await rows())[0]!; + expect(row.lastSyncError).toBeNull(); + expect(row.lastSyncAt).not.toBeNull(); + }); + + /** + * The reason last_sync_error exists. A stale badge alone cannot say whether + * nothing changed or nobody could reach Google. + */ + it('records the provider reason on failure', async () => { + await addConnection('c1', 'u1', null); + importBusy.mockRejectedValueOnce(new Error('invalid_grant: token has been revoked')); + + const out = await sweepCalendarSyncs(env, NOW); + expect(out).toMatchObject({ attempted: 1, succeeded: 0, failed: 1 }); + expect((await rows())[0]!.lastSyncError).toContain('invalid_grant'); + }); + + /** + * lastSyncAt vouches for data we actually hold. A failed attempt refreshed + * nothing, so it must not be allowed to look fresh. + */ + it('does not advance lastSyncAt when the sync failed', async () => { + const stamp = new Date(NOW - 2 * SYNC_INTERVAL_MS); + await addConnection('c1', 'u1', stamp); + importBusy.mockRejectedValueOnce(new Error('boom')); + + await sweepCalendarSyncs(env, NOW); + expect((await rows())[0]!.lastSyncAt?.getTime()).toBe(stamp.getTime()); + }); + + /** One tenant's broken token must not stop the rest of the sweep. */ + it('keeps going after one connection throws', async () => { + await addConnection('c1', 'u1', null); + await addConnection('c2', 'u2', null); + importBusy.mockRejectedValueOnce(new Error('boom')); + + const out = await sweepCalendarSyncs(env, NOW); + expect(out).toMatchObject({ attempted: 2, succeeded: 1, failed: 1 }); + }); + + it('flags a connection whose credentials no longer decrypt instead of retrying blindly', async () => { + await addConnection('c1', 'u1', null); + openConn.mockResolvedValueOnce(null); + + const out = await sweepCalendarSyncs(env, NOW); + expect(out).toMatchObject({ succeeded: 0, failed: 1 }); + expect((await rows())[0]!.lastSyncError).toMatch(/Reconnect Google Calendar/); + expect(importBusy).not.toHaveBeenCalled(); + }); + + it('caps how many connections one tick may take', async () => { + for (let i = 0; i < MAX_CONNECTIONS_PER_TICK + 5; i++) { + await addConnection(`c${i}`, `u${i}`, null); + } + const out = await sweepCalendarSyncs(env, NOW); + expect(out.attempted).toBe(MAX_CONNECTIONS_PER_TICK); + }); + + /** Stalest-first is the fairness mechanism; without it the cap starves rows. */ + it('takes the stalest connections first', async () => { + await addConnection('fresh', 'u1', new Date(NOW - SYNC_INTERVAL_MS - 1000)); + await addConnection('stale', 'u2', new Date(NOW - 10 * SYNC_INTERVAL_MS)); + await addConnection('never', 'u3', null); + + await sweepCalendarSyncs(env, NOW); + const order = openConn.mock.calls.map((c) => c[2]); + expect(order).toEqual(['u3', 'u2', 'u1']); + }); +}); From a259f256696b3956a81c5bd76f4df910c93d88d0 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 03:49:00 +0800 Subject: [PATCH 72/77] test(calendar): what only a running worker can prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing calendar-connect project rather than adding a spec file — one file is one playwright project here, and a new one would have meant a new project for four tests. The Google push and import are NOT asserted here: exercising them means calling Google, and the provider interface is exactly where they are stubbed in tests/unit/calendar. What a real worker adds is the part mocks cannot vouch for — that the sealed schedule token actually opens against the running crypto, that a tampered one is indistinguishable from a missing one (404, not 403), that ics-links hands back paths rather than the in-process API host, and that status carries the freshness pair the panel branches on. 29 passed, 2 skipped: the OAuth redirect needs GOOGLE_CLIENT_ID, and the busy feed is slug-addressed while the seed admin has no slug. Both name their blocker. --- tests/e2e/calendar-connect.spec.ts | 88 ++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/e2e/calendar-connect.spec.ts b/tests/e2e/calendar-connect.spec.ts index 000ee66e9..0bc1e6b22 100644 --- a/tests/e2e/calendar-connect.spec.ts +++ b/tests/e2e/calendar-connect.spec.ts @@ -135,4 +135,92 @@ test.describe('Calendar connect — capability gating', () => { }); expect(sync.status()).toBe(400); }); + + /** + * Failure state has to reach the person who can fix it. A revoked token + * shows up as an old freshness badge and nothing else unless this field + * survives the round trip. + */ + test('GET /api/calendar/status carries sync freshness and the last failure reason', async ({ request }) => { + const session = await loginSession(request); + await seedConnection(request, session.tenantId, session.userId, 'events_read_write'); + + const res = await request.get(`${BASE_URL}/api/calendar/status`, { + headers: { Cookie: session.cookie }, + }); + expect(res.status()).toBe(200); + const body = await res.json() as { + data: { connected: boolean; lastSyncAt: number | null; lastSyncError: string | null }; + }; + expect(body.data.connected).toBe(true); + // Present as explicit nulls, not absent — the panel branches on them. + expect(body.data).toHaveProperty('lastSyncAt'); + expect(body.data).toHaveProperty('lastSyncError'); + }); +}); + +/** + * The subscribe feeds, end to end through the real worker. + * + * The Google push and import cannot be exercised here without calling Google; + * the provider interface is where those are stubbed, and their round trip + * (link table create-then-patch, import rule filtering) is covered in + * tests/unit/calendar. What only a running worker can prove is what these + * assert: that the minted token actually opens, that a tampered one does not, + * and that the URLs handed to a user are usable ones. + */ +test.describe('Calendar subscribe feeds', () => { + test('ics-links returns PATHS, so the browser supplies the origin', async ({ request }) => { + const session = await loginSession(request); + const res = await request.get(`${BASE_URL}/api/calendar/ics-links`, { + headers: { Cookie: session.cookie }, + }); + expect(res.status()).toBe(200); + const { data } = await res.json() as { + data: { busyPath: string | null; schedulePath: string | null; companyPath: string | null }; + }; + + expect(data.schedulePath).toBeTruthy(); + // An absolute URL here would carry the in-process API worker's host + // rather than the one the user is on — a link that is dead on arrival. + for (const p of [data.busyPath, data.schedulePath, data.companyPath]) { + if (p !== null) expect(p.startsWith('/')).toBe(true); + } + expect(data.schedulePath).toContain('/api/ics/inspector/'); + }); + + test('the sealed schedule token opens; a tampered one is indistinguishable from missing', async ({ request }) => { + const session = await loginSession(request); + const links = await request.get(`${BASE_URL}/api/calendar/ics-links`, { + headers: { Cookie: session.cookie }, + }); + const { data } = await links.json() as { data: { schedulePath: string } }; + + // No cookie: a subscription feed is polled by a calendar app, not a + // signed-in browser. The token is the whole authorisation. + const good = await request.get(`${BASE_URL}${data.schedulePath}`); + expect(good.status()).toBe(200); + expect(good.headers()['content-type']).toContain('text/calendar'); + expect(await good.text()).toContain('BEGIN:VCALENDAR'); + + const tampered = await request.get(`${BASE_URL}${data.schedulePath.slice(0, -4)}XXXX`); + expect(tampered.status()).toBe(404); + }); + + test('the busy feed is public and carries no addresses', async ({ request }) => { + const session = await loginSession(request); + const links = await request.get(`${BASE_URL}/api/calendar/ics-links`, { + headers: { Cookie: session.cookie }, + }); + const { data } = await links.json() as { data: { busyPath: string | null } }; + test.skip(!data.busyPath, 'seed admin has no user slug; busy feed is slug-addressed'); + + const res = await request.get(`${BASE_URL}${data.busyPath!}`); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain('BEGIN:VCALENDAR'); + // The whole point of this feed being slug-addressable. + expect(body).not.toMatch(/^LOCATION:/m); + expect(body).not.toMatch(/^DESCRIPTION:/m); + }); }); From 3276f5059d297193f843ff73db75c660e13f28d4 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 04:07:56 +0800 Subject: [PATCH 73/77] fix(calendar): four things the full gate found that pre-commit does not run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-commit runs a subset; these only surface in the push-time chain, and three of the four are real: - tenant-scope: the booking-confirmation read of `inspections` filtered by id alone. The id does arrive from a tenant-scoped path, but a by-id-only read is a cross-tenant vector the moment anyone reuses the helper. Filtered, not baselined. - status-literals: google-export compared `status === 'cancelled'` instead of INSPECTION_STATUS.CANCELLED. - knip: getLinksByEntityIds had exactly one caller — its own test. That is the habit this whole phase exists to undo (a server primitive built and never called), so it is deleted rather than baselined. PushSkipReason unexported; it is reachable through PushOutcome. - i18n-glossary: the new es-419 copy used tú forms. This catalog is formal usted. --- messages/es-419/settings-components.json | 12 ++++----- server/lib/calendar/external-links.ts | 26 +------------------ server/lib/calendar/google-export.ts | 8 +++--- .../services/booking/booking-confirmation.ts | 6 +++-- tests/unit/calendar/external-links.spec.ts | 14 ---------- 5 files changed, 16 insertions(+), 50 deletions(-) diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index d8beb469e..6732902ae 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -586,15 +586,15 @@ "settings_deposit_type_fixed": "Monto fijo", "settings_deposit_error_save": "No se pudo guardar el depósito.", "settings_icsfeeds_heading": "Suscripciones de calendario", - "settings_icsfeeds_intro": "Suscribe cualquier aplicación de calendario a estos enlaces y se mantendrá actualizada sola. Cada fuente muestra un nivel de detalle distinto: elige la que corresponda a quién la verá.", - "settings_icsfeeds_none": "Tus enlaces de suscripción aparecerán cuando se configuren los identificadores de tu empresa y tu usuario.", + "settings_icsfeeds_intro": "Suscriba cualquier aplicación de calendario a estos enlaces y se mantendrá actualizada sola. Cada fuente muestra un nivel de detalle distinto: elija la que corresponda a quién la verá.", + "settings_icsfeeds_none": "Sus enlaces de suscripción aparecen cuando estén configurados los identificadores de su empresa y de su usuario.", "settings_icsfeeds_private_badge": "Enlace privado", - "settings_icsfeeds_privacy_note": "Cualquier persona que tenga un enlace marcado como privado puede ver ese calendario sin iniciar sesión. Compártelos solo con quienes ya tienen permiso para ver el trabajo.", + "settings_icsfeeds_privacy_note": "Cualquier persona que tenga un enlace marcado como privado puede ver ese calendario sin iniciar sesión. Compártalos solo con quienes ya tienen permiso para ver el trabajo.", "settings_icsfeeds_company_label": "Inspecciones de la empresa", "settings_icsfeeds_company_desc": "Todas las inspecciones de la empresa, con direcciones. Para el calendario de la oficina.", "settings_icsfeeds_busy_label": "Mi tiempo ocupado", "settings_icsfeeds_schedule_label": "Mi agenda", - "settings_icsfeeds_busy_desc": "Cuándo estás ocupado y nada más: sin direcciones, nombres ni correos. Se puede compartir con agentes y socios.", - "settings_icsfeeds_schedule_desc": "Tus propios trabajos con la dirección de la propiedad, para tu teléfono.", - "settings_calconnect_sync_error": "La última sincronización con Google Calendar falló: {reason} Vuelve a conectar abajo si esto continúa." + "settings_icsfeeds_busy_desc": "Cuándo está ocupado y nada más: sin direcciones, nombres ni correos. Se puede compartir con agentes y socios.", + "settings_icsfeeds_schedule_desc": "Sus propios trabajos con la dirección de la propiedad, para su teléfono.", + "settings_calconnect_sync_error": "La última sincronización con Google Calendar falló: {reason} Vuelva a conectar abajo si esto continúa." } diff --git a/server/lib/calendar/external-links.ts b/server/lib/calendar/external-links.ts index 352d600bf..fc42c4756 100644 --- a/server/lib/calendar/external-links.ts +++ b/server/lib/calendar/external-links.ts @@ -6,7 +6,7 @@ * is keyed on exactly that tuple, which is what makes a second push an UPDATE * of the same remote event rather than a second event on someone's calendar. */ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; import { calendarExternalLinks } from '../db/schema'; import type { CalendarProviderId } from './provider'; @@ -111,27 +111,3 @@ export async function listOwnExternalIds( .all(); return new Set(rows.map((r) => r.externalId)); } - -/** Links for many entities of one type, keyed by entity id. */ -export async function getLinksByEntityIds( - db: DrizzleD1Database, - params: { - tenantId: string; - provider: CalendarProviderId; - entityType: CalendarLinkEntityType; - entityIds: string[]; - }, -): Promise> { - const out = new Map(); - if (params.entityIds.length === 0) return out; - const rows = await db.select().from(calendarExternalLinks) - .where(and( - eq(calendarExternalLinks.tenantId, params.tenantId), - eq(calendarExternalLinks.provider, params.provider), - eq(calendarExternalLinks.entityType, params.entityType), - inArray(calendarExternalLinks.entityId, params.entityIds), - )) - .all(); - for (const r of rows) out.set(r.entityId, r); - return out; -} diff --git a/server/lib/calendar/google-export.ts b/server/lib/calendar/google-export.ts index c627fb8e6..0ffe8fbfc 100644 --- a/server/lib/calendar/google-export.ts +++ b/server/lib/calendar/google-export.ts @@ -28,6 +28,7 @@ import { inspections, calendarBlocks, tenantConfigs } from '../db/schema'; import { getInspectionRoster } from '../inspection/roster'; import { resolveTenantTimeZone, wallClockToEpochMs } from '../tz'; import { logger } from '../logger'; +import { INSPECTION_STATUS } from '../status/inspection-status'; import { canPushEvents, ExternalEventGoneError } from './provider'; import { getCalendarProvider } from './registry'; import { loadOpenGoogleConnection } from './connection'; @@ -50,7 +51,7 @@ export interface CalendarExportEnv { * Why a push did not happen. Every one of these is a state a user can be in * and can act on, which is why they are named rather than logged as a boolean. */ -export type PushSkipReason = +type PushSkipReason = | 'NOT_CONNECTED' | 'NO_WRITE_CAPABILITY' | 'OAUTH_NOT_CONFIGURED' @@ -226,9 +227,10 @@ export async function pushInspectionToGoogle( const lead = roster.lead; // Cancelled or unassigned: the entry should not be on anyone's calendar. - if (row.status === 'cancelled' || !lead) { + const cancelled = row.status === INSPECTION_STATUS.CANCELLED; + if (cancelled || !lead) { await deleteExternalForEntity(env, tenantId, 'inspection', inspectionId); - return { pushed: false, reason: row.status === 'cancelled' ? 'CANCELLED' : 'NO_ASSIGNEE' }; + return { pushed: false, reason: cancelled ? 'CANCELLED' : 'NO_ASSIGNEE' }; } const resolved = await resolveWriteHandle(env, tenantId, lead.id); diff --git a/server/services/booking/booking-confirmation.ts b/server/services/booking/booking-confirmation.ts index 22360b2dc..5ca769c93 100644 --- a/server/services/booking/booking-confirmation.ts +++ b/server/services/booking/booking-confirmation.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; import { users, inspections, tenantConfigs } from '../../lib/db/schema'; import { logger } from '../../lib/logger'; @@ -85,7 +85,9 @@ export async function dispatchBookingConfirmation( const booked = await db.select({ scheduledStartMs: inspections.scheduledStartMs, scheduledEndMs: inspections.scheduledEndMs, - }).from(inspections).where(eq(inspections.id, inspectionId)).get(); + }).from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) + .get(); const tzRow = await db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); diff --git a/tests/unit/calendar/external-links.spec.ts b/tests/unit/calendar/external-links.spec.ts index 28977d78a..00f114f41 100644 --- a/tests/unit/calendar/external-links.spec.ts +++ b/tests/unit/calendar/external-links.spec.ts @@ -4,7 +4,6 @@ import { getLink, deleteLink, listOwnExternalIds, - getLinksByEntityIds, } from '../../../server/lib/calendar/external-links'; import { createTestDb, setupSchema } from '../db'; import * as schema from '../../../server/lib/db/schema'; @@ -104,17 +103,4 @@ describe('calendar_external_links store', () => { }); expect([...ids]).toEqual(['mine-1']); }); - - it('batches entity lookups into one map', async () => { - await upsertLink(db as AnyDb, { ...key, userId: USER, externalId: 'g1' }); - await upsertLink(db as AnyDb, { ...key, entityId: 'insp-2', userId: USER, externalId: 'g2' }); - - const map = await getLinksByEntityIds(db as AnyDb, { - tenantId: TENANT, provider: 'google', entityType: 'inspection', - entityIds: ['insp-1', 'insp-2', 'insp-missing'], - }); - expect(map.get('insp-1')?.externalId).toBe('g1'); - expect(map.get('insp-2')?.externalId).toBe('g2'); - expect(map.has('insp-missing')).toBe(false); - }); }); From 6bb6197cc655134649d6f0e7ffd74e26c68438dd Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 05:26:04 +0800 Subject: [PATCH 74/77] fix(security): park a command fingerprint, not the payload cmd.tenant.update carries adminPasswordHash on password-change commands, and BOTH parking paths stored the message: the raw string on a parse failure and JSON.stringify(env) on an unknown type/version. So a malformed password-change command wrote an admin credential into parked_cmd_events -- a table nothing pruned, no erasure rule covered, and no PII heuristic flagged, because `envelope` and `reason` look like nothing. The row now holds a fingerprint: type, dataschema, command id, tenantseq, byte count, a SHA-256 of the exact bytes, and (on a parse failure) the names of the envelope fields that failed validation. Those answer the only question the table exists for -- portal and core disagree about a command shape -- and none of them is the payload. The fields are an allow-list read through primitive type guards, so a field added to `data` later is dropped because nothing reads it, not because someone remembered to name it. Rows parked before this change still hold whatever they held, so a data-only migration clears the payload out of them while keeping id/reason/received_at. Production had 0 parked rows; this closes the exposure rather than reporting it closed for new rows only. parked_cmd_events is also registered in ERASURE_OUT_OF_SCOPE with the history named, because an entry that only says "no PII here" invites restoring raw parking as a debugging convenience. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- migrations/0046_clear_parked_cmd_payloads.sql | 15 + migrations/meta/0046_snapshot.json | 11096 ++++++++++++++++ migrations/meta/_journal.json | 9 +- server/lib/compliance/erasure-manifest.ts | 7 + server/lib/sync-events/cmd-envelope.ts | 23 + server/portal/cmd-consumer.ts | 14 +- server/portal/parked-fingerprint.ts | 96 + tests/workers/parked-cmd-redaction.spec.ts | 120 + 8 files changed, 11370 insertions(+), 10 deletions(-) create mode 100644 migrations/0046_clear_parked_cmd_payloads.sql create mode 100644 migrations/meta/0046_snapshot.json create mode 100644 server/portal/parked-fingerprint.ts create mode 100644 tests/workers/parked-cmd-redaction.spec.ts diff --git a/migrations/0046_clear_parked_cmd_payloads.sql b/migrations/0046_clear_parked_cmd_payloads.sql new file mode 100644 index 000000000..8ba14a98f --- /dev/null +++ b/migrations/0046_clear_parked_cmd_payloads.sql @@ -0,0 +1,15 @@ +-- Data-only. Clear command payloads parked BEFORE the fingerprint change (#276). +-- +-- Both parking paths used to store the message itself, and `cmd.tenant.update` +-- carries `adminPasswordHash` on password-change commands, so any row written +-- before that change may hold an admin credential. Fixing the writer stops new +-- rows; it does not touch the ones already there. +-- +-- The ROW survives with its id / reason / received_at, which is the signal a +-- dead-letter row actually carries ("something parked, then, for that reason"). +-- Only the payload goes. `envelope` is NOT NULL, so it is replaced rather than +-- nulled, and the replacement is shaped like a fingerprint so a reader never +-- meets two formats. Idempotent: re-running writes the same value. +UPDATE parked_cmd_events +SET envelope = '{"v":1,"cleared":"payload removed; parked before the fingerprint change"}' +WHERE envelope NOT LIKE '{"v":1,%'; diff --git a/migrations/meta/0046_snapshot.json b/migrations/meta/0046_snapshot.json new file mode 100644 index 000000000..14af80257 --- /dev/null +++ b/migrations/meta/0046_snapshot.json @@ -0,0 +1,11096 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b138415a-e2a6-4f05-ad40-f88e3e191691", + "prevId": "0d7f060e-3721-41d8-8f82-b0703b562ff1", + "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 + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "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": {} + }, + "calendar_external_links": { + "name": "calendar_external_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "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": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "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_calendar_external_links_entity": { + "name": "uq_calendar_external_links_entity", + "columns": [ + "tenant_id", + "provider", + "entity_type", + "entity_id" + ], + "isUnique": true + }, + "idx_calendar_external_links_user": { + "name": "idx_calendar_external_links_user", + "columns": [ + "tenant_id", + "user_id", + "provider" + ], + "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_service_pay_splits": { + "name": "inspection_service_pay_splits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_service_id": { + "name": "inspection_service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrects_split_id": { + "name": "corrects_split_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "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_pay_split_line_user": { + "name": "uq_pay_split_line_user", + "columns": [ + "tenant_id", + "inspection_service_id", + "user_id" + ], + "isUnique": true, + "where": "corrects_split_id IS NULL" + }, + "idx_pay_split_user": { + "name": "idx_pay_split_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_pay_split_line": { + "name": "idx_pay_split_line", + "columns": [ + "tenant_id", + "inspection_service_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "deposit_required_cents": { + "name": "deposit_required_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deposit_overridden": { + "name": "is_deposit_overridden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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": {} + }, + "inspector_service_areas": { + "name": "inspector_service_areas", + "columns": { + "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 + }, + "zip_prefix": { + "name": "zip_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_service_areas_tenant": { + "name": "idx_inspector_service_areas_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_service_areas_user": { + "name": "idx_inspector_service_areas_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "uq_inspector_service_areas": { + "name": "uq_inspector_service_areas", + "columns": [ + "tenant_id", + "user_id", + "zip_prefix" + ], + "isUnique": true + } + }, + "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": {} + }, + "service_pay_rules": { + "name": "service_pay_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_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 + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deduction_cents": { + "name": "deduction_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_service_pay_rules_user": { + "name": "uq_service_pay_rules_user", + "columns": [ + "tenant_id", + "service_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "uq_service_pay_rules_default": { + "name": "uq_service_pay_rules_default", + "columns": [ + "tenant_id", + "service_id" + ], + "isUnique": true, + "where": "user_id IS NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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 + }, + "deposit_policy": { + "name": "deposit_policy", + "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'" + }, + "cancellation_policy": { + "name": "cancellation_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_agreement_id": { + "name": "cancellation_clause_agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_version": { + "name": "cancellation_clause_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancellation_clause_attested_at": { + "name": "cancellation_clause_attested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deposit_policy": { + "name": "deposit_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "booking_routing_strategy": { + "name": "booking_routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'first_available'" + }, + "booking_min_lead_hours": { + "name": "booking_min_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "booking_same_day_cutoff_time": { + "name": "booking_same_day_cutoff_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lat": { + "name": "company_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_lng": { + "name": "company_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_geocoded_at": { + "name": "company_geocoded_at", + "type": "integer", + "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 + }, + "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 + }, + "service_origin_address": { + "name": "service_origin_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lat": { + "name": "service_origin_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_origin_lng": { + "name": "service_origin_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "uq_users_tenant_email": { + "name": "uq_users_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "deleted_at IS NULL" + }, + "idx_users_tenant": { + "name": "idx_users_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_users_slug_per_tenant": { + "name": "idx_users_slug_per_tenant", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_slug": { + "name": "inspector_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_tenant_created": { + "name": "idx_audit_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_audit_entity": { + "name": "idx_audit_entity", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_tenant_id_tenants_id_fk": { + "name": "audit_logs_tenant_id_tenants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_test_results": { + "name": "integration_test_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_ok": { + "name": "is_ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_by_user_id": { + "name": "tested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_at": { + "name": "tested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_integration_test_tenant_target": { + "name": "idx_integration_test_tenant_target", + "columns": [ + "tenant_id", + "target", + "tested_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_tenant_user_created": { + "name": "idx_notifications_tenant_user_created", + "columns": [ + "tenant_id", + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_user_unread": { + "name": "idx_notifications_tenant_user_unread", + "columns": [ + "tenant_id", + "user_id", + "read_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_contact_created": { + "name": "idx_notifications_tenant_contact_created", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_tenant_id_tenants_id_fk": { + "name": "notifications_tenant_id_tenants_id_fk", + "tableFrom": "notifications", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parked_cmd_events": { + "name": "parked_cmd_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parked_cmd_events_received_at": { + "name": "idx_parked_cmd_events_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_cmd_events": { + "name": "processed_cmd_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cmd_type": { + "name": "cmd_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_reservations": { + "name": "slug_reservations", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_destruction_records": { + "name": "tenant_destruction_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_slug": { + "name": "tenant_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_deleted": { + "name": "rows_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_objects": { + "name": "r2_objects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_bytes": { + "name": "r2_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "kv_keys": { + "name": "kv_keys", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_destruction_tenant": { + "name": "idx_destruction_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_destruction_destroyed_at": { + "name": "idx_destruction_destroyed_at", + "columns": [ + "destroyed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_legal_versions": { + "name": "tenant_legal_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "doc": { + "name": "doc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_snapshot": { + "name": "body_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_material": { + "name": "is_material", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by_user_id": { + "name": "published_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tenant_legal_versions_doc_version": { + "name": "idx_tenant_legal_versions_doc_version", + "columns": [ + "tenant_id", + "doc", + "version" + ], + "isUnique": true + }, + "idx_tenant_legal_versions_latest": { + "name": "idx_tenant_legal_versions_latest", + "columns": [ + "tenant_id", + "doc", + "published_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_uploads": { + "name": "client_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_kind": { + "name": "uploaded_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_ref": { + "name": "uploaded_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_client_uploads_inspection": { + "name": "idx_client_uploads_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uq_discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 113bd3c1d..e94093ca2 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1786044032810, "tag": "0045_fat_trauma", "breakpoints": true + }, + { + "idx": 46, + "version": "6", + "when": 1786051480599, + "tag": "0046_clear_parked_cmd_payloads", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index 4e757b180..fc5d12d18 100644 --- a/server/lib/compliance/erasure-manifest.ts +++ b/server/lib/compliance/erasure-manifest.ts @@ -276,4 +276,11 @@ export const ERASURE_OUT_OF_SCOPE: ErasureOutOfScopeEntry[] = [ reason: 'free text a manager writes about a payout adjustment to a staff member — payroll audit trail, not consumer-DSAR scope' }, { table: 'service_pay_rules', column: 'user_id', reason: 'staff compensation rule — not consumer-DSAR scope' }, + // The portal->core dead-letter queue (#276). Registered although the PII + // heuristic flags neither column, because silence here is exactly how this + // one hid: `envelope` and `reason` look like nothing. + { table: 'parked_cmd_events', column: 'envelope', + reason: 'Fingerprint only (type/dataschema/id/seq/size/digest) — the command payload is never written, so no subject PII reaches this table. It WAS payload-bearing before #276, when a cmd.tenant.update that failed to parse wrote an admin password hash here. Naming that history is deliberate: an out-of-scope entry that only says "no PII" invites restoring raw parking as a debugging convenience.' }, + { table: 'parked_cmd_events', column: 'reason', + reason: 'Fixed diagnostic enum (parse-failed / unknown-type-or-version), not personal data.' }, ]; diff --git a/server/lib/sync-events/cmd-envelope.ts b/server/lib/sync-events/cmd-envelope.ts index bdfa07bde..c3ff6b9e4 100644 --- a/server/lib/sync-events/cmd-envelope.ts +++ b/server/lib/sync-events/cmd-envelope.ts @@ -104,6 +104,29 @@ export function parseCmdEnvelope(json: unknown): CmdEnvelope | null { return result.success ? result.data : null; } +/** + * Which ENVELOPE fields a message failed validation on — names only, so the + * dead-letter row can say WHY a message could not be read without holding the + * message. Two things make that safe structurally rather than by promise: + * values never leave this function, and the returned names are intersected with + * this schema's own top-level keys, so nothing from inside `data` (a + * `z.record(z.unknown())`, which never produces an issue of its own) can appear. + */ +export function cmdEnvelopeIssueFields(json: unknown): string[] { + let candidate: unknown = json; + if (typeof candidate === 'string') { + try { candidate = JSON.parse(candidate); } catch { return ['']; } + } + if (candidate === null || typeof candidate !== 'object') return ['']; + const result = cmdEnvelopeSchema.safeParse(candidate); + if (result.success) return []; + const known = new Set(Object.keys(cmdEnvelopeSchema.shape)); + const fields = result.error.issues + .map((issue) => String(issue.path[0] ?? '')) + .filter((name) => known.has(name)); + return [...new Set(fields)].sort(); +} + export function isKnownCmd(type: string, dataschema: string): boolean { const versions = KNOWN_CMD_TYPES[type]; return versions !== undefined && versions.includes(dataschema); diff --git a/server/portal/cmd-consumer.ts b/server/portal/cmd-consumer.ts index 15115dc7a..3dc539ce9 100644 --- a/server/portal/cmd-consumer.ts +++ b/server/portal/cmd-consumer.ts @@ -11,6 +11,7 @@ import { import type { SyncEnvelope } from '../lib/sync-events/envelope'; import { applySyncQuota, applyTenantUpdate, applySeedStarterContent, applyAiCaps } from './apply-commands'; import { applyCredentialIfFresh } from './admin-credential'; +import { parkedFingerprint } from './parked-fingerprint'; import { OutboxService, type OutboxRow } from './outbox.service'; /** A-21 batch 3 — R2 bindings the offboarding commands need. Optional: absent @@ -35,10 +36,10 @@ export interface CmdConsumerBuckets { export type CmdApplyResult = 'applied' | 'duplicate' | 'stale' | 'stale-credential-applied' | 'parked'; -const PARSE_FAIL_MAX = 2000; - type Db = ReturnType; +/** `envelope` holds a FINGERPRINT, never the message — see parked-fingerprint.ts + * for why, and for what a reader of this row can still answer. */ async function park(db: Db, id: string, envelope: string, reason: string): Promise { await db.insert(parkedCmdEvents) .values({ id, envelope, reason, receivedAt: new Date() }) @@ -56,13 +57,12 @@ export async function applyCmdEnvelope( const env = parseCmdEnvelope(raw); if (!env) { - const rawStr = typeof raw === 'string' ? raw : safeStringify(raw); - await park(db, crypto.randomUUID(), rawStr.slice(0, PARSE_FAIL_MAX), 'parse-failed'); + await park(db, crypto.randomUUID(), await parkedFingerprint(raw, null), 'parse-failed'); logger.warn('[cmd] parked unparseable envelope'); return 'parked'; } if (!isKnownCmd(env.type, env.dataschema)) { - await park(db, env.id, JSON.stringify(env), 'unknown-type-or-version'); + await park(db, env.id, await parkedFingerprint(raw, env), 'unknown-type-or-version'); logger.warn('[cmd] parked unknown command', { id: env.id, type: env.type, dataschema: env.dataschema }); return 'parked'; } @@ -308,10 +308,6 @@ async function emitReply( } } -function safeStringify(value: unknown): string { - try { return JSON.stringify(value) ?? String(value); } catch { return String(value); } -} - /** Mirror of portal's queue-loop backoff. */ function backoffSeconds(attempts: number): number { return Math.min(30 * 2 ** attempts, 3600); diff --git a/server/portal/parked-fingerprint.ts b/server/portal/parked-fingerprint.ts new file mode 100644 index 000000000..4fe09360a --- /dev/null +++ b/server/portal/parked-fingerprint.ts @@ -0,0 +1,96 @@ +/** + * What a parked command row holds: a FINGERPRINT, never the payload (#276). + * + * `cmd.tenant.update` carries `adminEmail` + `adminPasswordHash` SPARSELY — + * only password-change commands do (see `applyCredentialIfFresh`). Parking the + * message therefore wrote an admin credential into a table nothing pruned, no + * erasure rule covered, and no PII heuristic flagged: `envelope` and `reason` + * look like nothing. Both parking paths did it — the raw string on a parse + * failure, `JSON.stringify(env)` on an unknown type/version. + * + * The table exists so a human learns that portal and core disagree about a + * command shape. That question is answered by WHICH command, WHICH version, + * WHERE in the tenant sequence, HOW BIG it was, WHETHER the bytes match what + * the sender recorded, and — when it would not parse — WHICH envelope fields + * were wrong. None of those is the payload. + * + * The fields below are an ALLOW-LIST read through primitive type guards, not a + * redaction pass over the message: a field added to `data` tomorrow is dropped + * because nothing reads it, not because someone remembered to name it. + */ +import { cmdEnvelopeIssueFields, type CmdEnvelope } from '../lib/sync-events/cmd-envelope'; + +/** Longest echoed string. The claimed `type`/`dataschema` on an unparseable + * message are attacker-shaped input; the real values are far shorter. */ +const MAX_ECHOED = 200; + +export interface ParkedFingerprint { + /** Format marker — a reader must be able to tell a fingerprint from a + * pre-#276 raw envelope without guessing. */ + v: 1; + type: string | null; + dataschema: string | null; + cmdId: string | null; + tenantseq: number | null; + /** Size of the message as received. A skew often shows up as a size jump. */ + bytes: number; + /** Digest of the exact bytes, so the row can be matched against the + * sender's own record of what it published — the one question the payload + * could answer that the routing fields cannot. Not reversible, and not + * guessable: every envelope carries a UUID. */ + sha256: string; + /** Envelope field names that failed validation. Parse failures only. */ + invalidFields?: string[]; +} + +function safeStringify(value: unknown): string { + try { return JSON.stringify(value) ?? String(value); } catch { return String(value); } +} + +async function sha256Hex(input: string): Promise { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + return [...new Uint8Array(buf)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function echoString(source: Record, key: string): string | null { + const value = source[key]; + return typeof value === 'string' ? value.slice(0, MAX_ECHOED) : null; +} + +/** Routing fields a message CLAIMS when it did not parse. Read one by one and + * type-guarded, so a nested object under `type` cannot smuggle content out. */ +function claimedFields(text: string): Pick { + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { parsed = null; } + if (parsed === null || typeof parsed !== 'object') { + return { type: null, dataschema: null, cmdId: null, tenantseq: null }; + } + const record = parsed as Record; + const seq = record['tenantseq']; + return { + type: echoString(record, 'type'), + dataschema: echoString(record, 'dataschema'), + cmdId: echoString(record, 'id'), + tenantseq: typeof seq === 'number' && Number.isFinite(seq) ? seq : null, + }; +} + +/** + * Build the value written to `parked_cmd_events.envelope`. `parsed` is the + * validated envelope when there is one (unknown type/version) and null when + * there is not (parse failure); `raw` is the message exactly as delivered. + */ +export async function parkedFingerprint(raw: unknown, parsed: CmdEnvelope | null): Promise { + const text = typeof raw === 'string' ? raw : safeStringify(raw); + const base = { v: 1 as const, bytes: text.length, sha256: await sha256Hex(text) }; + const fingerprint: ParkedFingerprint = parsed + ? { + ...base, + type: parsed.type, + dataschema: parsed.dataschema, + cmdId: parsed.id, + tenantseq: parsed.tenantseq, + } + : { ...base, ...claimedFields(text), invalidFields: cmdEnvelopeIssueFields(raw) }; + return JSON.stringify(fingerprint); +} diff --git a/tests/workers/parked-cmd-redaction.spec.ts b/tests/workers/parked-cmd-redaction.spec.ts new file mode 100644 index 000000000..88ea2a9ba --- /dev/null +++ b/tests/workers/parked-cmd-redaction.spec.ts @@ -0,0 +1,120 @@ +/** + * A parked command must never hold the payload (#276). + * + * `cmd.tenant.update` carries `adminEmail` + `adminPasswordHash` SPARSELY — + * only password-change commands do (see `applyCredentialIfFresh`). Both parking + * paths used to write the message itself: the raw string on a parse failure and + * `JSON.stringify(env)` on an unknown type/version. So one malformed + * password-change command left an admin credential in a table nothing pruned, + * no erasure rule covered, and no PII heuristic flagged. + * + * Real workerd, because `applyCmdEnvelope` takes a real `D1Database`. The + * `parked_cmd_events` DDL is hand-declared the way every sibling spec in this + * directory declares the tables it touches. + */ +import { env } from 'cloudflare:test'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { applyCmdEnvelope } from '../../server/portal/cmd-consumer'; + +const b = env as unknown as { DB: D1Database }; + +/** The credential the parked row must not be able to reveal. */ +const SECRET_HASH = 'pbkdf2:100000:U0FMVFNBTFQ:SECRETHASHVALUE'; +const ADMIN_EMAIL = 'boss@example.com'; + +/** A password-change command whose dataschema version core does not know. */ +function credentialEnvelope(over: Record = {}) { + return { + specversion: '1.0', + id: 'cmd-parked-1', + type: 'io.inspectorhub.cmd.tenant.update', + source: 'portal', + time: '2026-08-06T00:00:00.000Z', + dataschema: 'cmd-tenant-update/v99', + tenantseq: 7, + data: { + tenantId: 'ct1', slug: 'ws-1', status: 'active', + adminEmail: ADMIN_EMAIL, adminPasswordHash: SECRET_HASH, + }, + ...over, + }; +} + +interface ParkedRow { id: string; envelope: string; reason: string } + +async function onlyParkedRow(): Promise { + const r = await b.DB.prepare('SELECT id, envelope, reason FROM parked_cmd_events').all(); + expect(r.results).toHaveLength(1); + return r.results[0]!; +} + +describe('parked commands never retain the payload', () => { + beforeAll(async () => { + 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);', + ); + }); + beforeEach(async () => { + await b.DB.exec('DELETE FROM parked_cmd_events;'); + }); + + it('stores no credential when a credential-bearing command fails to parse', async () => { + // Valid JSON, invalid envelope (no `specversion`) — the shape a real + // portal/core contract skew produces, and the path that parks the RAW + // string. This is the credential path. + const { specversion: _dropped, ...malformed } = credentialEnvelope(); + expect(await applyCmdEnvelope(b.DB, undefined, JSON.stringify(malformed))).toBe('parked'); + + const row = await onlyParkedRow(); + expect(row.reason).toBe('parse-failed'); + expect(row.envelope).not.toContain('SECRETHASHVALUE'); + expect(row.envelope).not.toContain('adminPasswordHash'); + expect(row.envelope).not.toContain(ADMIN_EMAIL); + }); + + it('stores no credential when a credential-bearing command has an unknown version', async () => { + expect(await applyCmdEnvelope(b.DB, undefined, credentialEnvelope())).toBe('parked'); + + const row = await onlyParkedRow(); + expect(row.reason).toBe('unknown-type-or-version'); + expect(row.envelope).not.toContain('SECRETHASHVALUE'); + expect(row.envelope).not.toContain('adminPasswordHash'); + expect(row.envelope).not.toContain(ADMIN_EMAIL); + }); + + it('still says which command skewed, and how to match it against the sender', async () => { + // The row exists so a human learns portal and core disagree about a + // command shape. Type, dataschema, id, sequence, size and a digest of + // the bytes say that; the payload never added diagnostic value that + // justified holding a secret. + await applyCmdEnvelope(b.DB, undefined, credentialEnvelope()); + const fp = JSON.parse((await onlyParkedRow()).envelope) as Record; + expect(fp).toMatchObject({ + type: 'io.inspectorhub.cmd.tenant.update', + dataschema: 'cmd-tenant-update/v99', + cmdId: 'cmd-parked-1', + tenantseq: 7, + }); + expect(fp['sha256']).toMatch(/^[0-9a-f]{64}$/); + expect(fp['bytes']).toBeGreaterThan(0); + }); + + it('still says WHY an unparseable envelope could not be read', async () => { + // Names of envelope fields that failed validation — never their values, + // and never a key from inside `data`. + const { specversion: _dropped, ...malformed } = credentialEnvelope(); + await applyCmdEnvelope(b.DB, undefined, JSON.stringify(malformed)); + const fp = JSON.parse((await onlyParkedRow()).envelope) as Record; + expect(fp['invalidFields']).toEqual(['specversion']); + // The claimed routing fields survive so the row is still attributable. + expect(fp).toMatchObject({ type: 'io.inspectorhub.cmd.tenant.update', cmdId: 'cmd-parked-1' }); + }); + + it('parks a fingerprint even for input that is not JSON at all', async () => { + expect(await applyCmdEnvelope(b.DB, undefined, 'not json at all {{')).toBe('parked'); + const fp = JSON.parse((await onlyParkedRow()).envelope) as Record; + expect(fp['type']).toBeNull(); + expect(fp['sha256']).toMatch(/^[0-9a-f]{64}$/); + expect(fp['invalidFields']).toEqual(['']); + }); +}); From b8ec83a64d9c3051e26ad4b3006b42d4366c637e Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 05:33:50 +0800 Subject: [PATCH 75/77] fix(privacy): redact audit metadata at write and scrub it on erasure audit_logs.metadata is a free-form JSON blob written with no redaction, absent from the erasure manifest, and invisible to the PII heuristic -- the same gap portal closed in audit_logs.details after counsel ruled that retaining such a column through an erasure is an incomplete DSAR. OI applied neither half of that ruling. Real content today includes recipient emails, an SMS recipient phone, and the property address on four inspection events. Write side: both insert sites now redact. The primary filter is on the VALUE -- a string that IS an email, a phone or an IP is removed wherever it appears and whatever the key is called, so a field added later is caught by what it holds rather than by someone having named it. A short key list covers the identifiers that have no detectable value shape (address, client/contact/recipient/signer name); it is knowingly incomplete, which is why the manifest rule and not the redactor is what makes the column safe to keep. Deliberately NOT portal's key list: matching `name`, `token` and a bare `ip` here would redact the tag/template/rating-system names that ARE the audit value of half these events, the rotation forensics in previousTokenHash, and every key containing the letters "ip" (zipPrefixes, description). Erasure side: a manifest rule plus the shared ANONYMIZE_AUDIT_PII SET the orchestrator executes, so historical rows and prose no pattern can see are scrubbed wholesale on a DSAR. The structured event -- action, entity_type, entity_id -- survives, which is what the row exists for. ip_address stays: it is the staff-action security trail, already declared out of scope. The orchestrator's repeated inspection-id lookup is memoized in the same pass; three steps now need it, and the file is at its size cap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --- server/lib/audit.ts | 64 ++++++- server/lib/compliance/anonymize-pii.ts | 21 +++ server/lib/compliance/erasure-manifest.ts | 13 ++ server/lib/compliance/erasure-orchestrator.ts | 66 +++---- server/portal/parked-fingerprint.ts | 2 +- .../privacy/audit-metadata-redaction.spec.ts | 166 ++++++++++++++++++ 6 files changed, 296 insertions(+), 36 deletions(-) create mode 100644 tests/unit/privacy/audit-metadata-redaction.spec.ts diff --git a/server/lib/audit.ts b/server/lib/audit.ts index 7ac492a04..f082f0263 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -139,6 +139,66 @@ interface AuditParams { executionCtx?: Pick | undefined; } +/** + * Metadata redaction — applied at BOTH insert sites in this file (#276). + * + * `metadata` is free-form JSON a caller composes, and callers do put subject + * identifiers in it: a recipient email on a report delivery, a phone on an SMS + * send, a property address on an inspection update. Portal's counsel ruled on + * the identical column (`audit_logs.details`) that carrying such a column + * through an erasure is an incomplete DSAR. This is the write-time half of the + * answer; the erasure half is the `audit_logs.metadata` anonymize rule in + * `compliance/erasure-manifest.ts`, and it is the half that is complete. + * + * The primary filter is on the VALUE, not the key: a string that IS an email + * address, a phone number or an IP is removed wherever it appears and whatever + * it is called. That is what holds when someone adds a field — a list of key + * names lets the next one through by construction. + * + * The short key list below covers what has no detectable value shape. A street + * address or a person's name is not recognisable as a string, so only the key + * can flag it, and dropping metadata wholesale is not available: these rows + * exist for what it says (the previous token hash on a portal_access rotation, + * the before/after capability sets on a role change). So the list is + * deliberately narrow, knowingly incomplete, and NOT the reason the column is + * safe to keep — the manifest rule is. + * + * It is deliberately not portal's list either. Portal matches `name`, `token` + * and a bare `ip`, which here would redact the tag / template / rating-system + * names that ARE the audit value of half these events, the rotation forensics + * in `previousTokenHash`, and every key containing the letters "ip" + * (`zipPrefixes`, `description`). + */ +const REDACTED = '[redacted]'; +/** Anchored so a business-object name (`templateName`, `libraryName`) is kept. */ +const IDENTITY_KEY = /address|(?:client|contact|recipient|signer|customer)_?name$/i; +const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; +const IPV4_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g; +const PHONE_RE = /(? = {}; + for (const [key, nested] of Object.entries(value as Record)) { + out[key] = IDENTITY_KEY.test(key) ? REDACTED : redactValue(nested); + } + return out; + } + return value; +} + +function redactAuditMetadata(metadata: Record | undefined): Record | null { + return metadata ? (redactValue(metadata) as Record) : null; +} + /** * Write an audit log entry. Uses waitUntil when executionCtx is provided * so it never blocks the response path. @@ -157,7 +217,7 @@ function writeAuditLog(params: AuditParams): void { action: rest.action, entityType: rest.entityType, entityId: rest.entityId ?? null, - metadata: rest.metadata ?? null, + metadata: redactAuditMetadata(rest.metadata), ipAddress: rest.ipAddress ?? null, createdAt: new Date(), }).then(() => {}).catch((e) => logger.error('[audit] write failed', {}, e instanceof Error ? e : undefined)); @@ -257,7 +317,7 @@ export async function writeAuditLogWithSlug(db: D1Database, params: AuditWithSlu action: params.action, entityType: params.entityType, entityId: params.entityId ?? null, - metadata: params.metadata ?? null, + metadata: redactAuditMetadata(params.metadata), ipAddress: params.ipAddress ?? null, inspectorSlug, createdAt: new Date(), diff --git a/server/lib/compliance/anonymize-pii.ts b/server/lib/compliance/anonymize-pii.ts index f71fe6e1f..7a64eac1a 100644 --- a/server/lib/compliance/anonymize-pii.ts +++ b/server/lib/compliance/anonymize-pii.ts @@ -58,3 +58,24 @@ export const ANONYMIZE_BOOKING_REQUEST_PII = { clientEmail: null, clientPhone: null, } as const; + +/** + * Free-text SET for `audit_logs` (#276). `metadata` is a JSON blob a caller + * composes, so it MAY embed a name, an address or a phrase about a person that + * no pattern can recognise; `audit.ts` strips the machine-detectable + * identifiers at write time, which is not the same as the column being clean. + * Portal's counsel rejected retaining the equivalent column through an erasure + * as an incomplete DSAR, so the whole value goes rather than parts of it — the + * one action that needs no judgement and has no false-negative rate. + * + * The column is nullable, so the convention above applies: NULL, not the + * sentinel. What survives is the structured event — `action`, `entity_type`, + * `entity_id` — which is the whole reason an audit row is worth keeping. + * `user_id` and `ip_address` are NOT here: they are the staff actor of a + * security trail, not consumer-DSAR scope (see the manifest). + * + * Shared so the erasure orchestrator and the log-retention sweep cannot drift. + */ +export const ANONYMIZE_AUDIT_PII = { + metadata: null, +} as const; diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index fc5d12d18..778862f5e 100644 --- a/server/lib/compliance/erasure-manifest.ts +++ b/server/lib/compliance/erasure-manifest.ts @@ -166,6 +166,19 @@ export const ERASURE_MANIFEST: ErasureRule[] = [ // spine of a signed, delivered document, and removing it would strand the // version chain that proves what was delivered. { table: 'reports', column: 'title', category: 'user.address', action: 'anonymize', legalBasis: 'art_17_3_e', retention: 'P6Y' }, + + // ── audit_logs (#276) ───────────────────────────────────────────────────── + // Free-form JSON a caller composes; it MAY embed names/emails/phones/ + // addresses. `audit.ts` now strips the machine-detectable identifiers at + // write time, but prose is not detectable at all and historical rows + // predate the redactor — so the column is SCRUBBED wholesale on an erasure, + // the same call portal's counsel made on the identical `details` column + // (retaining it through an erasure is an incomplete DSAR). The ROW stays: + // the security/accountability trail is the retention basis, and what makes + // it one is the structured event (action/entity), not the blob. + // `ip_address` stays too — staff-action security trail, declared out of + // scope below. + { table: 'audit_logs', column: 'metadata', category: 'user.freetext', action: 'anonymize', legalBasis: 'art_17_3_b' }, ]; /** diff --git a/server/lib/compliance/erasure-orchestrator.ts b/server/lib/compliance/erasure-orchestrator.ts index 340808424..52e4b2897 100644 --- a/server/lib/compliance/erasure-orchestrator.ts +++ b/server/lib/compliance/erasure-orchestrator.ts @@ -50,12 +50,14 @@ import { inspectionAccessTokens, inspectionRequests, reports, + auditLogs, erasureLog, } from '../db/schema'; import { ANONYMIZE_SIGNER_PII, ANONYMIZE_REQUEST_PII, ANONYMIZE_BOOKING_REQUEST_PII, + ANONYMIZE_AUDIT_PII, } from './anonymize-pii'; /** @@ -288,6 +290,18 @@ export async function runErasure( .all(); const subjectContactIds = (subjectContactRows as Array<{ id: string }>).map((c) => c.id); + /** Subject's inspection ids via `inspection_people` — there is no + * denormalized client column on `inspections`. Memoized: three steps + * below need the same list and it cannot change mid-run. */ + let inspIdCache: string[] | null = null; + async function subjectInspectionIds(): Promise { + if (inspIdCache) return inspIdCache; + if (subjectContactIds.length === 0) return (inspIdCache = []); + const rows = await db.select({ id: inspectionPeople.inspectionId }).from(inspectionPeople) + .where(and(eq(inspectionPeople.tenantId, tenantId), inArray(inspectionPeople.contactId, subjectContactIds))).all(); + return (inspIdCache = [...new Set((rows as Array<{ id: string }>).map((r) => r.id))]); + } + // A preference row is keyed on a contact id, and contact ids are reused. // Leaving these behind gives the NEXT person at that id the erased // subject's mute settings — invisibly, and in the direction that withholds @@ -356,20 +370,8 @@ export async function runErasure( // title text — an address can be spelled several ways, and a title that // happens to mention someone else's street is not this subject's data. await step('reports', 'anonymize', { legalBasis: 'art_17_3_e' }, async () => { - if (subjectContactIds.length === 0) return 0; - // Through inspection_people, NOT a column on `inspections`: the - // denormalized clientContactId was dropped when people became rows, and - // the schema comment says not to reintroduce it. - 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 inspIds = await subjectInspectionIds(); if (inspIds.length === 0) return 0; - const res = await db.update(reports) .set({ title: ANONYMIZED_TITLE }) .where(and(eq(reports.tenantId, tenantId), inArray(reports.inspectionId, inspIds))) @@ -389,14 +391,7 @@ export async function runErasure( // (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 inspIds = await subjectInspectionIds(); const invRows = await db.select({ id: invoices.id }).from(invoices) .where(and(eq(invoices.tenantId, tenantId), inArray(invoices.contactId, subjectContactIds))) .all(); @@ -416,24 +411,29 @@ export async function runErasure( return c; }); + // `metadata` is free-form JSON: audit.ts strips the machine-detectable + // identifiers at write, prose it cannot see at all, and older rows predate + // it — so the whole value goes. Located by entity id (inspections/contacts). + await step('audit_logs', 'anonymize', { legalBasis: 'art_17_3_b' }, async () => { + const targets = [...(await subjectInspectionIds()), ...subjectContactIds]; + if (targets.length === 0) return 0; + const c = changeCount(await db.update(auditLogs).set(ANONYMIZE_AUDIT_PII) + .where(and(eq(auditLogs.tenantId, tenantId), inArray(auditLogs.entityId, targets))).run()); + retainedCount += c; return c; // the event is retained, the free text is not + }); + // ── 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). ──── // - // Orphan cleanup FIRST: resolve the subject's contact id(s) and delete the - // `inspection_people` rows that reference them, so nothing dangles once the - // contact row itself is deleted below. Resolving the contact id(s) before - // the contacts delete (rather than joining contacts.email at delete time) - // means this step works even if run standalone/retried after the contacts - // row is already gone (idempotent: 0 contacts found -> 0 rows deleted). + // Orphan cleanup FIRST: delete the `inspection_people` rows referencing the + // subject, so nothing dangles once the contact row goes below. The ids were + // resolved BEFORE any delete, so a standalone re-run is idempotent (0 + // contacts found -> 0 rows deleted) rather than a no-op that misses rows. await step('inspection_people', 'delete', {}, async () => { - const subjectContacts = await db.select({ id: contacts.id }).from(contacts) - .where(and(eq(contacts.tenantId, tenantId), eq(contacts.email, subjectEmail))) - .all(); - const contactIds = (subjectContacts as Array<{ id: string }>).map((c) => c.id); - if (contactIds.length === 0) return 0; + if (subjectContactIds.length === 0) return 0; const res = await db.delete(inspectionPeople) - .where(and(eq(inspectionPeople.tenantId, tenantId), inArray(inspectionPeople.contactId, contactIds))) + .where(and(eq(inspectionPeople.tenantId, tenantId), inArray(inspectionPeople.contactId, subjectContactIds))) .run(); return changeCount(res); }); diff --git a/server/portal/parked-fingerprint.ts b/server/portal/parked-fingerprint.ts index 4fe09360a..485218e06 100644 --- a/server/portal/parked-fingerprint.ts +++ b/server/portal/parked-fingerprint.ts @@ -24,7 +24,7 @@ import { cmdEnvelopeIssueFields, type CmdEnvelope } from '../lib/sync-events/cmd * message are attacker-shaped input; the real values are far shorter. */ const MAX_ECHOED = 200; -export interface ParkedFingerprint { +interface ParkedFingerprint { /** Format marker — a reader must be able to tell a fingerprint from a * pre-#276 raw envelope without guessing. */ v: 1; diff --git a/tests/unit/privacy/audit-metadata-redaction.spec.ts b/tests/unit/privacy/audit-metadata-redaction.spec.ts new file mode 100644 index 000000000..12b47fd9d --- /dev/null +++ b/tests/unit/privacy/audit-metadata-redaction.spec.ts @@ -0,0 +1,166 @@ +/** + * `audit_logs.metadata` is free-form JSON, and callers do put subject + * identifiers in it (a recipient email on a report delivery, a phone on an SMS + * send, a property address on an inspection update). Portal's counsel ruled on + * the identical column (`audit_logs.details`) that carrying such a column + * through an erasure is an incomplete DSAR. Portal then closed it in two + * halves — redact at write, scrub on erasure. OI had neither (#276). + * + * These specs pin both halves, plus the thing redaction must not cost: the + * structured event (action / entity) is why the row exists. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { auditFromContext, writeAuditLogWithSlug } from '../../../server/lib/audit'; +import { ERASURE_MANIFEST } from '../../../server/lib/compliance/erasure-manifest'; +import { runErasure } from '../../../server/lib/compliance/erasure-orchestrator'; +import { createTestDb, setupSchema } from '../db'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type { Context } from 'hono'; +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 USER = '00000000-0000-0000-0000-000000000010'; +const SUBJECT = 'jane@example.com'; + +/** Minimal stand-in for the Hono context `auditFromContext` reads. */ +function fakeContext(metadata: Record) { + return { + env: { DB: {} as D1Database }, + get: (key: string) => (key === 'tenantId' ? TENANT : { sub: USER }), + req: { header: () => '203.0.113.9' }, + get executionCtx(): never { throw new Error('no execution context'); }, + __metadata: metadata, + } as unknown as Context; +} + +describe('audit metadata never becomes a PII store', () => { + let testDb: BetterSQLite3Database; + let sqlite: ReturnType['sqlite']; + + beforeEach(async () => { + const fixture = createTestDb(); + testDb = fixture.db; + sqlite = fixture.sqlite; + await setupSchema(sqlite); + await testDb.insert(schema.tenants).values({ + id: TENANT, name: 'A', slug: 'a', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(testDb); + }); + + afterEach(() => { + sqlite.close(); + vi.clearAllMocks(); + }); + + async function onlyAuditRow() { + const rows = await testDb.select().from(schema.auditLogs).all(); + expect(rows).toHaveLength(1); + return rows[0]!; + } + + it('redacts contact details written through writeAuditLogWithSlug', async () => { + await writeAuditLogWithSlug({} as D1Database, { + tenantId: TENANT, actorUserId: USER, + action: 'inspection.send_pdf', entityType: 'inspection', entityId: 'i1', + metadata: { note: `called Jane Doe at ${SUBJECT} / 555-0142` }, + }); + const serialized = JSON.stringify((await onlyAuditRow()).metadata); + expect(serialized).not.toContain(SUBJECT); + expect(serialized).not.toContain('555-0142'); + }); + + it('redacts contact details written through auditFromContext', async () => { + // The SECOND insert site. A redactor on only one of them is the same + // gap wearing a fix. + auditFromContext(fakeContext({}), 'inspection.send_sms', 'inspection', { + entityId: 'i1', + metadata: { recipient: '+1 (512) 555-0142', agentEmail: SUBJECT, ip: '203.0.113.9' }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const serialized = JSON.stringify((await onlyAuditRow()).metadata); + expect(serialized).not.toContain(SUBJECT); + expect(serialized).not.toContain('555-0142'); + expect(serialized).not.toContain('203.0.113.9'); + }); + + it('catches an identifier under a key nobody named', async () => { + // The point of filtering on the VALUE: a field added tomorrow is caught + // because of what it holds, not because someone listed its name. + await writeAuditLogWithSlug({} as D1Database, { + tenantId: TENANT, action: 'inspection.share_agent', entityType: 'inspection', + metadata: { someFutureField: SUBJECT }, + }); + expect(JSON.stringify((await onlyAuditRow()).metadata)).not.toContain(SUBJECT); + }); + + it('drops a property address, which has no detectable value shape', async () => { + await writeAuditLogWithSlug({} as D1Database, { + tenantId: TENANT, action: 'inspection.create', entityType: 'inspection', + metadata: { propertyAddress: '123 Oak St, Austin TX' }, + }); + expect(JSON.stringify((await onlyAuditRow()).metadata)).not.toContain('Oak St'); + }); + + it('keeps the structured event and the metadata that gives the row its value', async () => { + // Redaction must not cost the thing the row exists for. `name` here is a + // template, not a person, and `previousTokenHash` is the only durable + // answer to "the customer says their old link stopped opening". + await writeAuditLogWithSlug({} as D1Database, { + tenantId: TENANT, action: 'portal_access.rotated', entityType: 'inspection', entityId: 'i1', + metadata: { name: 'Standard Home Inspection', previousTokenHash: 'a3f1'.repeat(16), sectionCount: 12 }, + }); + const row = await onlyAuditRow(); + expect(row.action).toBe('portal_access.rotated'); + expect(row.entityType).toBe('inspection'); + expect(row.metadata).toEqual({ + name: 'Standard Home Inspection', previousTokenHash: 'a3f1'.repeat(16), sectionCount: 12, + }); + }); + + it('has an erasure rule for metadata', () => { + const keys = new Set(ERASURE_MANIFEST.map((r) => `${r.table}.${r.column}`)); + expect(keys.has('audit_logs.metadata')).toBe(true); + }); + + it('scrubs historical metadata on an erasure, keeping the event', async () => { + // Rows written before the redactor existed, and prose the redactor + // cannot see, are the reason the manifest rule is the real guarantee. + await testDb.insert(schema.contacts).values({ + id: 'c1', tenantId: TENANT, type: 'client', name: 'Jane Doe', + email: SUBJECT, createdAt: new Date(), + }); + await testDb.insert(schema.inspectionPeople).values({ + id: 'ip1', tenantId: TENANT, inspectionId: 'insp-1', + contactId: 'c1', roleProfileId: 'rp1', createdAt: new Date(), + }); + await testDb.insert(schema.auditLogs).values([ + { + id: 'a1', tenantId: TENANT, action: 'inspection.create', entityType: 'inspection', + entityId: 'insp-1', metadata: { note: 'legacy row: met Jane Doe at 123 Oak St' }, + ipAddress: '203.0.113.9', createdAt: new Date(), + }, + { + id: 'a2', tenantId: TENANT, action: 'template.create', entityType: 'template', + entityId: 'tpl-9', metadata: { name: 'Standard' }, createdAt: new Date(), + }, + ]); + + await runErasure(testDb, { tenantId: TENANT, subjectEmail: SUBJECT, retentionYears: 6 }); + + const rows = await testDb.select().from(schema.auditLogs).all(); + const subjectRow = rows.find((r) => r.id === 'a1')!; + expect(subjectRow.metadata).toBeNull(); + expect(subjectRow.action).toBe('inspection.create'); // the event survives + expect(subjectRow.entityId).toBe('insp-1'); + expect(subjectRow.ipAddress).toBe('203.0.113.9'); // staff security trail, out of scope + // Unrelated rows are untouched. + expect(rows.find((r) => r.id === 'a2')!.metadata).toEqual({ name: 'Standard' }); + }); +}); From c5d1b475a0eca7a2b3b1dda9e67d7f4f4a863fc1 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 06:29:46 +0800 Subject: [PATCH 76/77] docs(compliance): the delivery-confirmation LIA, written before the code OI #271 Task 1. An assessment written after the implementation is a justification for it, so this one is written first and is allowed to come out negative. It does, in part. Purpose and necessity pass for the narrow question ("was the deliverable received"), with no marketing or profiling purpose claimed -- which is why the engagement-analytics features are absent rather than merely unbuilt. The balancing test splits. It passes for a counter that records what the server actually observed. It FAILS for the shape that resolves every open to the primary report: the public renderer has no report identity today (app/routes.ts:49 is keyed on the inspection id, public-report.ts:80 documents the param as "Inspection id.", and report-view-props.ts:51 sets `reportId = data.inspectionId ?? ""`), so attributing an open to a specific deliverable manufactures a false statement about an identified person, for every open, by design. Section 3.4(b) says so and offers the honest alternative: key the row on the order and say so. The eight conditions in section 4 are conditions, not reassurances. Condition 3 is currently unmet, so the feature is not yet covered by its own assessment. Also recorded: no Art. 21 objection mechanism is designed, and the accuracy of the scanner filter is a heuristic the inspector-facing UI has to compensate for. Public repo because self-hosters run the same code and are controllers in their own right. --- docs/compliance/report-view-lia.md | 408 +++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 docs/compliance/report-view-lia.md diff --git a/docs/compliance/report-view-lia.md b/docs/compliance/report-view-lia.md new file mode 100644 index 000000000..ac2cd35af --- /dev/null +++ b/docs/compliance/report-view-lia.md @@ -0,0 +1,408 @@ +# Legitimate Interests Assessment — report delivery confirmation + +**Subject:** recording, server-side, that a report page was rendered to a +recipient who presented a valid portal access token. + +**Status:** written **before** the code (OI #271, Task 1 of the delivery +confirmation plan). Nothing described here is implemented yet — there is no +`report_views` table in the schema and no counter anywhere in `server/` or +`app/` as of 2026-08-07. This document is therefore an assessment of a +*proposal*, and it reaches a **split conclusion**: one shape of the feature +passes the balancing test and one does not. + +**Why this lives in the open-source repo.** Every deployment of +OpenInspection — hosted or self-hosted — runs the same code and performs the +same processing. A self-hoster is a controller in their own right and needs +the same assessment, so it ships with the software rather than with any one +operator's paperwork. Section 6 says what a self-hoster still has to do. + +**What this document is not.** It is not legal advice, and it is not a +conclusion about any specific deployment. It records the reasoning a +controller can adopt, adapt, or reject. + +--- + +## 0. What is actually proposed + +A bounded counter table. Per (recipient, deliverable): whether it has ever been +opened, when it was first opened, when it was last opened, and how many times. +Three integers and a foreign-key-free pair of scope columns. No row per view. + +The counter is written on the server, in the loader that renders the public +report page (`app/routes.ts:49` -> `app/routes/public/report-card-stack.tsx`), +when a request arrives carrying a valid per-recipient portal token. + +Deliberately absent: IP address, user agent, referrer, device or browser +fingerprint, per-section dwell time, scroll depth, and any record of *which +findings* were read. + +Deliberately absent by mechanism: any cookie, pixel, `localStorage` write, +`sendBeacon` call, or client-side listener. Nothing is stored on or read from +the recipient's device. The server records its own handling of its own +request. That distinction is the reason this assessment can be attempted at +all: the 2026 supervisory-authority position on email tracking pixels turns on +*terminal-equipment access*, and where that is engaged the answer is consent, +not legitimate interests. A design that avoids terminal-equipment access does +not thereby become lawful — it becomes eligible for the test below. + +--- + +## 1. Purpose test — is there a legitimate interest? + +**The interest.** An inspection company owes its client a report. It is the +deliverable the engagement exists to produce, and in many jurisdictions the +professional obligation attaches to *delivery*, not to sending. The company +has a real and present interest in knowing whether the document it is +contractually obliged to provide actually reached the person it was for. + +This is not a speculative or future interest. It is asserted every time an +inspector is asked "did they get it?" and today has no answer better than +"the email did not bounce". + +**Secondary interest.** Following up on a report that was delivered but never +opened. This is a weaker interest than the first — it is a business +convenience — and it is worth naming separately because it is the one that +would justify escalating collection later. It does not, on its own, justify +anything beyond the first. + +**Third-party interest.** The recipient also benefits, marginally: an +unopened report that the inspector notices and re-sends is a report the client +gets. This is real but small and should not be leaned on. + +**What is *not* claimed.** + +- **No marketing purpose.** Nothing here supports "clients who opened the + report within a day are warmer leads", segmentation, or any downstream use + of the counters outside the delivery question. +- **No profiling purpose.** No inference about the recipient — their level of + concern, their attention, their diligence, their state of mind — is claimed + as an interest, and no data that would support such an inference is + collected. + +This is not a disclaimer. It is the reason several otherwise-obvious features +are absent from the design. A "which sections did they read" panel, an +engagement score, a chart of opens over time, a per-section heatmap, and a +"most engaged clients" list are all things a product with three integers per +recipient cannot build. That is the point. Each of them would need its own +purpose, and none of the purposes above reaches them. + +**Conclusion, purpose test: passes** for the delivery question, and only for +the delivery question. + +--- + +## 2. Necessity test — is this the least intrusive way? + +The question is whether the interest in section 1 can be met with less. + +| Alternative | Why it does not answer the question | +|---|---| +| SMTP delivery status / bounce handling | Proves the mail server accepted the message. It is silent on whether a human ever saw it. This is already implemented and is exactly the gap being closed. | +| Ask the client to confirm | Requires the recipient's cooperation for the controller's own record-keeping, and the population that does not open the report is the same population that does not answer the follow-up. It answers the question only when the answer is already yes. | +| Email read receipt (MDN) | Requires the recipient to act on each one, is unsupported or silently disabled by most consumer clients, and is answered by the mail client rather than by the fact of reading. Less reliable *and* more intrusive to ask for. | +| Tracking pixel in the delivery email | Engages ePrivacy Art. 5(3): the recipient's mail client fetches a resource from the recipient's device. The 2026 supervisory position is that legitimate interests is not available here at all. Strictly more intrusive and legally worse. | +| Client-side beacon on the report page | Same objection, plus it would be the first client-side instrumentation in this product, inherited by every self-hosted deployment. | +| A row per view (event log) | Answers the same question and additionally produces a chronological record of when a named person read a document. Strictly more data for no additional answer. | + +Recording, server-side, that a token was used to render a page is the least +that answers "was it received". And within that, three counters is the least +that answers it — "first opened" is what a follow-up decision needs, "last +opened" distinguishes a re-read from a stale first open, and a count +distinguishes a glance from repeated reference. + +**One necessity claim that does not hold up.** "Last opened" and the count are +*useful*, not *necessary*, for the primary interest — "has this ever been +opened, and when first" is sufficient to answer "did they get it". They are +necessary only for the secondary interest (section 1) and for telling the +inspector something honest about a report the client keeps returning to. A +controller who wants the narrowest possible footing can drop both and keep +`first_opened_at`. This assessment covers all three, but the two extra +integers rest on the weaker interest and should be the first thing dropped if +the balancing in section 3 is ever revisited. + +**Conclusion, necessity test: passes** for `first_opened_at`; passes on the +secondary interest for `last_opened_at` and the count. + +--- + +## 3. Balancing test — are the recipient's interests overridden? + +### 3.1 Who the recipients are + +Not one population. The portal token is minted per recipient per order, and +report links are sent to at least three kinds of person: + +- **The client** — the consumer who engaged the company and paid for the + report. Strongest contractual nexus, strongest expectation. +- **The buyer's agent** — usually acting for the client, often the party who + referred the engagement. Business contact, but acting in a transaction the + client is party to. +- **The listing agent, and one-off shares** — a recipient who may never have + engaged the company at all and whose link exists because someone else chose + to share it. + +The balancing is not the same for all three, and the assessment must not be +written as if the client were the only recipient. The third group has the +weakest expectation: they did not enter into anything, and a record that they +opened a document is a record about a person who never dealt with the +controller. + +### 3.2 Reasonable expectations + +For the client: a business that emailed you a personalised link to a document +about your own property, which you paid that business to produce, being able +to tell that the link was used — this is within the range of what a reasonable +person expects. It is roughly what they expect of a courier's tracking page. + +For the agents: weaker, but a shared professional link is not private +correspondence, and "the sender can see the link was used" is not surprising. + +For all three: the expectation holds **only if they are told**. An +undisclosed open-tracking record is precisely the thing the 2026 pixel +decisions are about, and the fact that this implementation avoids the +technical trigger does not make an undisclosed record acceptable. Art. 13 +transparency is not a formality that runs alongside the balancing test here — +it is load-bearing *inside* it. Remove the disclosure and this assessment +fails. + +### 3.3 Impact on the recipient + +Low, and bounded by construction: + +- No identifier is created that did not already exist. The row hangs off an + access token the recipient was already issued. +- Nothing is stored on or read from their device, so there is no cross-site + or cross-context linkage and nothing survives on their machine. +- The data cannot support an inference about them beyond "opened / when / + how often". It cannot say what they read, how long they spent, or what they + cared about. +- It is bounded: reports by recipients, a handful per engagement. It is not a + log that grows with use. + +**The counter-argument, recorded rather than answered away:** a person can +reasonably feel monitored by being told that a business can see when they +opened a document, and some will read a report differently knowing it. That +feeling is not defeated by the data being small. What the design does about it +is to keep the record to the minimum that answers the delivery question, so +that the feeling is proportionate to a fact rather than to an unknown. It does +not eliminate it. + +### 3.4 Accuracy — where the balance actually gets difficult + +This is where the assessment stops being comfortable. Two distinct problems. + +**(a) The record can be wrong, and the design knows it.** + +Corporate mail-security gateways open every link in an inbound message. So do +prefetchers. The plan filters `HEAD` requests, `Purpose: prefetch` and +`Sec-Purpose: prefetch`/`prerender`, and the product's own non-human GETs: the +headless PDF pipeline, which arrives with `?render=` (declared at +`server/api/public-report.ts:84`) and `?print=1` (read in the report route's +loader at `app/routes/public/report-card-stack.tsx:61`), and the inspector's +own preview of their own report. Those filters are heuristics. A determined +scanner issues a plain `GET` and is indistinguishable from a reader. + +So the record will sometimes assert, of an identified person, that they opened +a document they never saw. That is inaccurate personal data (Art. 5(1)(d)), +and the harm is not abstract: an inspector who believes the client has read +the report behaves differently towards them. + +This does not sink the assessment, but it converts two things from good +practice into conditions: + +- The inspector-facing surface must never present "opened" as proof, and must + present "not opened" alongside the delivery status so the two failure + directions are distinguishable. +- The product must not "fix" the false-positive rate by adding client-side + confirmation. That trade — accuracy bought with terminal-equipment access — + moves the lawful basis to consent, and this assessment would no longer + cover the feature. + +**(b) The report identity problem — this part fails.** + +The public report surface has no report identity today. The route is +`report-view/:tenant/:id` (`app/routes.ts:49`), the public data endpoint +documents its `id` param as **"Inspection id."** +(`server/api/public-report.ts:80`), and the renderer's own prop adapter sets +`const reportId = data.inspectionId ?? "";` +(`app/components/portal/sections/report/report-view-props.ts:51`). Meanwhile an +order can carry several deliverables — `reports` rows of kind `primary` and +`ancillary` — so "the report page for this order" and "a report" are not the +same object. + +Two ways were proposed to give the counter a `report_id`: + +1. **Thread real report identity through the surface** — add a report + selector to the public route and payload so the page knows which + deliverable it is rendering. +2. **Resolve the primary report** via `resolvePrimaryReportId()` + (`server/lib/inspection/reports.ts:44`) and attribute every open to it. + +**Option 2 does not pass this balancing test.** It manufactures a specific +factual assertion about an identified person — "this recipient opened the +radon report" — from an observation that does not contain it. Unlike (a), +this is not an unavoidable heuristic error at the margin; it is a wrong +attribution generated deliberately, for every open, as the normal case. A +controller cannot rely on legitimate interests to create a record it knows to +be a guess dressed as a fact, when a truthful alternative is available at the +cost of engineering work. The recipient's interest in not having false +statements recorded about them is not outweighed by the controller's +convenience in shipping sooner. + +There is a third option, and it is the honest one if the renderer is not going +to change first: + +3. **Record what the system actually observed.** The observation is "this + recipient rendered the report page for this order". Key the row on + (tenant, inspection, access token) and do not carry a `report_id` at all. + The counter then makes a claim the system can support. When the renderer + gains report identity, the column can be added, and the older rows are + honestly order-scoped rather than retroactively mislabelled. + +**Never populate a `report_id` column with an inspection id.** That is not a +shortcut, it is a false record in a column whose name asserts otherwise, and +it is the specific failure this section exists to prevent. + +### 3.5 Balancing conclusion + +For a counter that records only what was observed, discloses itself, and is +paired with delivery status in the inspector's view: the recipient's interests +do **not** override the controller's. **Passes.** + +For a counter that attributes opens to a specific deliverable the system +cannot identify (option 2 above): **fails**, on accuracy. Not on volume, not +on sensitivity, and not on expectation — on the record being untrue. + +--- + +## 4. Conclusion, and the conditions it rests on + +**Legitimate interests is available for server-side report delivery +confirmation, provided all of the following hold.** These are conditions, not +recommendations. Each one is doing work in sections 1-3; if any is dropped, +the assessment has to be redone rather than cited. + +1. **Nothing is stored on or read from the recipient's device.** No cookie, + pixel, `localStorage`, `sendBeacon`, or client-side listener, on any + surface involved in delivery or rendering. This is what keeps ePrivacy + Art. 5(3) out of scope and is the load-bearing premise of the whole + assessment. + +2. **Only the counters are recorded.** No IP, user agent, referrer, device + fingerprint, section-level timing, or scroll position. Adding any + identifier not already necessary to serve the page reopens section 3.3. + +3. **The row records only what was observed.** Either the renderer genuinely + knows which deliverable it is showing, or the row is scoped to the order + and says so. See section 3.4(b). This condition is currently **unmet** — + the design choice is open. + +4. **The recipient is told, before the first open is counted.** The first + render is the one that creates the record, so a disclosure that appears + only on the report page arrives after the fact. The notice must therefore + ride the message that carries the link. As of today every report-link + notice class declares `channels: ['email']` + (`server/lib/notifications/classes.ts:118`, `:119`, `:149`, `:157`, + `:185`-`:188`), which bounds the problem to the email path — but a link an + inspector copies out and sends by hand is outside that system, and the + disclosure cannot reach it. + +5. **The disclosure cannot be edited away.** The delivery copy is + tenant-editable, and an editable default only seeds a per-tenant row — it + cannot carry a guarantee. The notice must be a system-rendered block (the + mechanism exists: `SystemBlockKind` in + `server/lib/email-templates/types.ts:17`, currently `'auditMetadata' | + 'attachmentManifest' | 'icsHint'`), not template text a tenant can delete. + A disclosure a tenant can remove is a disclosure this assessment cannot + rely on. + +6. **The inspector-facing surface pairs "opened" with delivery status** and + presents neither as proof. See section 3.4(a). + +7. **The row is catalogued for erasure in the same change that creates it,** + and the erasure orchestrator is wired to it. The manifest alone is not + enough: `runErasure` is a hand-written per-table sequence of fourteen steps + (`server/lib/compliance/erasure-orchestrator.ts:232`-`:445`), and a + manifest rule with no matching step is a rule that does not run. The + subject's rows must be removed before their access tokens are (`:345`), + because the token id is how the rows are found. The general form of this + trap is written up in `docs/compliance/erasure-heuristic-limits.md`. + +8. **The counters are used for the delivery question only.** No export into + analytics, no segmentation, no ranking of recipients. + +**Condition 3 is not satisfied by the current design.** Until the report +identity question is resolved in the direction of section 3.4 option 1 or +option 3, the feature is not covered by this assessment. That is the intended +outcome of writing the assessment first: it is allowed to say no to part of +the proposal. + +### Rights that follow from this basis + +Processing on legitimate interests carries the Art. 21 right to object, and +this design does not currently provide a way to exercise it. A recipient who +objects has, in practice, only the erasure path (condition 7), which is a +larger action than they asked for. This is a **residual weakness**, recorded +rather than resolved: the mitigation is that the data is minimal, that it dies +with the access token, and that revoking the token removes the recipient's +link and their counters together. A controller who receives an objection +should expect to honour it by revoking the token rather than by a mechanism +this software provides. + +--- + +## 5. What voids this assessment + +A future reader must not be able to extend the feature while pointing at this +document as cover. Each of the following makes this assessment +**inapplicable** — not "arguably still fine", not "a small delta". The lawful +basis changes, and the change is from legitimate interests to consent. + +- **Per-section or per-finding tracking.** Dwell time, scroll depth, which + sections were expanded, which photos were viewed. Its purpose is inference + about the reader, which section 1 explicitly does not claim, and its + implementation is necessarily client-side, which condition 1 forbids. +- **Any client-side instrumentation**, for any reason, including one added to + improve the accuracy of the counters. +- **A tracking pixel in the delivery email.** Directly the case the 2026 + supervisory position addresses, and the one basis it forecloses. +- **Recording IP address, user agent, referrer, or any device signal** + alongside the counters. +- **Replacing the counters with an event log** — a row per view is a + chronology of when a named person read a document, and section 2 concluded + it adds no answer. +- **Any secondary use** — marketing, lead scoring, segmentation, ranking, or + training anything. +- **Populating a report identifier with something that is not a report + identifier.** Section 3.4(b). + +If a change in this list is wanted, the correct move is a new assessment +reaching a new conclusion — most likely that consent is required — not an +amendment to this one. + +--- + +## 6. Note for self-hosted deployments + +If you run OpenInspection, you are the controller for the data your instance +processes, and this assessment is a starting point rather than a substitute +for your own. + +What you inherit: the design constraints. The absence of client-side tracking, +the three-counter shape, and the absence of IP and user-agent capture are +properties of the code, not of any operator's configuration. + +What is yours: the purpose (section 1 assumes you send reports to clients +under an engagement — if your use differs, the purpose test differs), the +disclosure (conditions 4 and 5 depend on the copy your deployment actually +sends, and your tenant-level template edits are yours), your jurisdiction, and +your record of having made the assessment. Art. 5(2) makes documenting the +reasoning an obligation in its own right; adopting this document is a +reasonable way to discharge it, and adopting it without reading section 4 is +not. + +--- + +**Assessment date:** 2026-08-07 +**Reassess when:** any item in section 5 is proposed, condition 3 is resolved, +or the report renderer gains per-report identity. From 225d33ebbd837c3dc8eb2561bc8eb30794b6f390 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 7 Aug 2026 06:32:37 +0800 Subject: [PATCH 77/77] docs(erasure): state what the PII heuristic cannot see, with the live example erasure-freetext-pii Task 3. The gate is green today (31 rules, 48 out-of-scope declarations) and that sentence is easy to read as "erasure covers the schema". It means only that no column whose NAME the gate was told to look for is unruled. The worked example is current, not hypothetical. `address` is absent from PII_HEURISTIC (check-erasure-manifest.mjs:52), so the gate never asks about inspections.property_address (schema/inspection/core.ts:13) or its nine geocoded siblings -- and `inspections` has no manifest rule, no out-of-scope entry, and none of runErasure's fourteen steps. Two tables away reports.title IS declared and IS executed. The failure mode is not an under-report. runErasure returns status:'completed' whenever no step threw (erasure-orchestrator.ts:453), writes it to erasure_log, and admin.service.ts:227 hands it straight to the caller. A gap nobody declared is indistinguishable from a gap that does not exist, and the accountability log records it as done. Also corrected: the manifest's justification for the reports.title rule says the title is human-written free text carrying the address. It is not -- there is no API that edits it, and it is machine-written from the service catalogue. The rule that got written is the one whose justification someone imagined. Deliberately NOT fixed. The address ruling is a compliance decision awaiting a human, and widening the regex first would red the gate on twelve columns and invite twelve reasonless out-of-scope entries -- which is worse than the gap. The document says it is open and unowned, and names the twelve. Held up as the behaviour to copy: users.service_origin_address, declared out of scope although the heuristic never asked. --- docs/compliance/erasure-heuristic-limits.md | 255 ++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/compliance/erasure-heuristic-limits.md diff --git a/docs/compliance/erasure-heuristic-limits.md b/docs/compliance/erasure-heuristic-limits.md new file mode 100644 index 000000000..ff8f14254 --- /dev/null +++ b/docs/compliance/erasure-heuristic-limits.md @@ -0,0 +1,255 @@ +# What the erasure PII heuristic can and cannot see + +`scripts/check-erasure-manifest.mjs` is a CI gate. It walks every Drizzle +schema file, matches column names against a regex, and fails if a matching +column has neither a rule in `ERASURE_MANIFEST` nor a reasoned entry in +`ERASURE_OUT_OF_SCOPE`. + +It is green today: `31 rules, 48 out-of-scope declarations`, exit 0. + +This document exists because that sentence is easy to read as "erasure covers +the schema", and it does not mean that. It means no column *whose name the +gate was told to look for* is unruled. Everything else is invisible to it, and +invisible reads exactly like correct. + +That misreading is the reason portal #88 was filed. This page is here so it is +not the reason for the next one. + +--- + +## The mechanism, exactly + +```js +// scripts/check-erasure-manifest.mjs:52 +const PII_HEURISTIC = /(email|phone|ip_address|user_agent|signature|client_name|full_name|recipient)/; +const isPiiColumn = (col) => PII_HEURISTIC.test(col) || col === "ip"; +``` + +Eight substrings and one exact match. That is the entire model of "this column +might hold personal data". + +The gate is honest about what it does — it never claims to find PII, only to +find *unruled columns whose names look like PII*. The gap is between what it +does and what a green run gets read as. + +--- + +## The worked example: `inspections.property_address` + +`address` is not in the regex. + +Follow that one omission: + +- `inspections.property_address` is `text('property_address').notNull()` + (`server/lib/db/schema/inspection/core.ts:13`), with nine geocoded siblings + beneath it (`:15`-`:24`: place id, street, city, state, zip, county, lat, + lng, geocoded-at). For a residential inspection ordered by the buyer or the + homeowner, that is a person's home address, held against a named client + through `inspection_people`. +- The gate never asks about it, because the name does not match. +- `inspections` therefore has **zero** entries in the manifest: no column rule + (`grep "table: 'inspections'"` in `server/lib/compliance/` returns nothing), + no out-of-scope entry, and no row rule. +- `runErasure` has fourteen hand-written per-table steps + (`server/lib/compliance/erasure-orchestrator.ts:232`, `:250`, `:270`, + `:276`, `:310`, `:324`, `:336`, `:345`, `:354`, `:372`, `:392`, `:417`, + `:433`, `:445`). None of them is `inspections`. +- Two tables away, `reports.title` **is** declared — `category: 'user.address'`, + `action: 'anonymize'`, `legalBasis: 'art_17_3_e'`, `retention: 'P6Y'` + (`erasure-manifest.ts:168`) — and executed, overwritten with + `'Inspection Report (details removed)'` (`erasure-orchestrator.ts:68`, + step at `:372`). + +So a consumer erasure today clears the report title and leaves the address. + +### The failure mode is not an under-report + +If the gate merely missed a column, the cost would be a smaller number in a +coverage report. The actual cost is different and worse. + +`runErasure` sets `status: 'completed'` whenever no step threw +(`erasure-orchestrator.ts:453`) and writes that status into the append-only +`erasure_log` row (`:461`). Nothing in that path can know about a table it was +never told to visit, so the absence of `inspections` produces no warning, no +partial status, and no decision entry. The only caller +(`server/services/admin.service.ts:227`) spreads that summary straight through +to whatever operator surface invoked it, so a DSAR console records a completed +erasure over a record that still holds the subject's home address — and the +accountability log now says so in writing. + +A silent gap and a gap recorded as "completed" are not the same defect. The +second one manufactures evidence that the first one was handled. + +### One correction while we are here + +The manifest's own comment above the `reports.title` rule +(`erasure-manifest.ts:162`-`:167`) says `title` is "the one free-text column a +human writes" and "routinely carries the address (`123 Oak St — Radon`)". +That is not true of the current code. There is no API that edits +`reports.title`; it is written machine-side, either as the literal +`'Inspection Report'` (`server/lib/inspection/reports.ts:96`) or as the +service line's `nameSnapshot` (`server/lib/inspection/report-generation.ts:123`, +written at `:158` and `:177`) — a string out of the tenant's own service +catalogue. + +Which sharpens the example rather than softening it. The column the manifest +anonymises under an Art. 17(3)(e) legal basis currently holds a tenant +catalogue name. The column holding the client's home address holds no rule at +all. The rule that got written is the one whose *justification* someone +imagined; the one that was needed is the one nobody was prompted to think +about. + +--- + +## Categories structurally out of reach + +Not "not covered yet" — out of reach of *any* name-matching gate. For each, +what compensates. + +### 1. Free prose in a column whose name does not announce prose + +`data`, `payload`, `meta`, `details`, `body`, `snapshot`. A person typed into +it; the name does not say so. + +**Compensator:** a manifest rule, when a human thinks of it. `audit_logs.metadata` +is the case where one did — rule at `erasure-manifest.ts:181`, plus write-time +stripping of machine-detectable identifiers in the audit writer, plus a +wholesale scrub on erasure because prose is not detectable at all and historical +rows predate the redactor. That is three mechanisms for one column, and none of +them was prompted by the gate. + +### 2. Addresses, and location generally + +Covered above. `street`, `city`, `zip`, `lat`, `lng`, `place_id` — none match. + +**Compensator: none.** See "Open and unowned" below. + +### 3. Sensitivity that is contextual rather than lexical + +A column can be innocuous in isolation and personal in combination. +`inspections.date` is a date. `inspections.date` joined to a property address +and a named client is a record of where a specific person was living on a +specific day. No lexical test reaches that, because the sensitivity is not in +either column — it is in the join. + +**Compensator: none mechanical.** Only a human reading the schema as a whole, +which is what an out-of-scope entry with a real reason forces someone to do +once. + +### 4. PII inside a JSON blob + +The gate reads column *names* out of the schema source. It never reads a row. +A JSON TEXT column holding `{"clientName": "..."}` is one column called +`payload` as far as the gate is concerned. + +**Compensator:** per-column, by hand. `audit_logs.metadata` again; nothing +generic. + +### 5. Row-level semantics + +The gate is per-column. It cannot see that an entire row is a record about a +person, or that a column is a locator rather than content. + +**Compensator:** the manifest's row-delete convention — `action: 'delete'` +with the `column` naming the locator, documented at `erasure-manifest.ts:47`-`:54` +— plus explicit out-of-scope entries for columns that ride along, e.g. +`contacts.phone`, "rides with the contacts row delete (locator = email)" +(`:207`). This one works, because the convention is written down where the +rules are. + +### 6. A rule that exists but never runs + +The inverse failure. `runErasure` is a hand-written sequence, not a manifest +interpreter, so a rule can be added and simply never executed. The gate is +green either way: it validates the manifest against the *schema*, never +against the executor. + +**Compensator:** `tests/unit/privacy/erasure-manifest-coverage.spec.ts`, a +drift guard that fails when a rule has no orchestrator wiring. This is the one +blind spot with a real mechanical answer. + +### 7. False positives + +`automations.recipient_kind` matches `recipient` and is an enum. +`comment_usage.comment_id` would match a widened pattern and is a foreign key. + +**Compensator:** an out-of-scope entry saying so. This is the gate working +correctly — the cost of a name-based test is that it over-matches, and paying +that cost in written reasons is the intended trade. + +--- + +## The behaviour to copy + +`users.service_origin_address` is declared out of scope +(`erasure-manifest.ts:221`) with the reason "staff routing origin (may be a +home address) — staff offboarding lifecycle, not consumer-DSAR scope", and the +comment above it says explicitly that it is "declared here so the decision is +recorded rather than inferred from the PII heuristic not matching +`service_origin_address`". + +The gate never asked. Nothing would have gone red. Somebody wrote the entry +anyway, and it is correct: an inspector's routing origin genuinely can be a +home address, so it is personal data — it is simply not a *consumer* data +subject's, which is a decision, not an oversight. + +Several other entries do the same thing and say so in the same words — the +`reports` column sweep (`:252`-`:262`), the payment ledger (`:273`-`:278`), +`tenant_legal_versions` (`:279`-`:280`), the pay-split rows (`:286`-`:291`), +and `parked_cmd_events` (`:295`-`:298`), which notes that "silence here is +exactly how this one hid: `envelope` and `reason` look like nothing". + +**That is the discipline the heuristic cannot produce.** Ruling on a column +the gate did not flag is the only thing that closes the categories above, and +it depends entirely on somebody deciding to do it. + +The practical rule when adding a table: do not ask "will the gate pass". Ask +"what does a column the gate says nothing about look like" — and then write +the entry for each one. + +--- + +## Open and unowned + +The address gap described above is **not fixed, and nobody owns it.** This +document makes it legible; it does not close it. + +Two reasons it is left open deliberately rather than patched here: + +1. **It is a compliance decision, not an engineering one.** Mirroring the + `reports.title` treatment — anonymise with an Art. 17(3)(e) basis and a + bounded retention period — is the obvious call, and it changes what erasure + does to production data on every future request. A property address may be + personal data of the client where it is linked to the client relationship, + and it may also be the thing a professional record has to keep in order to + identify which property a report describes. Which of those wins, and for + how long, is a question for a human with authority to answer it. + +2. **Widening the regex is not a neutral first step.** Adding `address` to + `PII_HEURISTIC` today turns the gate red on **twelve** columns, measured + 2026-08-07: + + ``` + inspections.property_address inspections.address_county + inspections.address_place_id inspections.address_lat + inspections.address_street inspections.address_lng + inspections.address_city inspections.address_geocoded_at + inspections.address_state inspection_requests.property_address + inspections.address_zip tenant_configs.company_address + ``` + + A red gate on twelve columns invites the cheapest way back to green, which + is twelve out-of-scope entries. An out-of-scope entry without a real reason + is worse than a missing rule: it converts an open question into a recorded + decision nobody will revisit. Widening the pattern is the *last* step, once + the ruling exists — not the first. + +Not all twelve are the same question. `tenant_configs.company_address` is the +controller's own business address and mirrors the `company_lat`/`company_lng` +entries already at `erasure-manifest.ts:233`-`:234`. +`inspection_requests.property_address` sits on a table whose identity columns +already carry rules (`:135`-`:137`), so it is the odd one out on a table that +was otherwise ruled on. The `inspections` family is the substantive one. + +Until that decision is made, read the gate's green output as it is written: +*no column whose name suggests PII is unruled*. Not *no PII is unruled*.