From 4d367ee711fb64e6f9e743080c8acbb36d345c0f Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:33:03 -0400 Subject: [PATCH 01/16] feat(config): default a new workspace's Reply-To to its owner, with a dry-run backfill for existing ones Without a configured Reply-To, a customer replying to an outbound message reaches the platform sender address instead of the inspector. The owner's email is the sensible default and stays editable in Settings. - New workspaces: when the owner account is created, write the owner's email to tenant_configs.reply_to. Both creation paths are covered: the provisioning command from a control plane and the standalone first-owner setup. The write happens only on the branch that creates the owner, so rotating a credential later cannot refill a field the operator cleared on purpose. Initialise-only: a value that is already set is never touched. Best-effort: a failure logs a warning and does not fail provisioning. - Existing workspaces: scripts/backfill-reply-to.mjs fills a NULL, empty or whitespace-only reply_to with the earliest-created owner's email. It is a dry run unless --apply is given, never overwrites a value, re-checks the blank condition inside each write statement so an edit between read and write wins, and prints "filled N / skipped M". A workspace with no owner, or whose owner email is blank, is skipped. The planning logic is a pure function with unit tests. - The script passes every statement to wrangler as a temp file: the wrapper forwards its arguments through a shell without quoting them, so a SQL string given with --command is split at the first space. Verified end to end against a throwaway local database with seven cases (NULL, blank, whitespace, already set, no owner, blank owner email, two owners): the right rows are filled, the earliest owner wins, a second dry run reports "filled 0". Not run against any real database. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- scripts/backfill-reply-to.mjs | 118 +++++++++++++++++++ scripts/lib/reply-to-backfill.mjs | 50 ++++++++ server/lib/integration/owner-reply-to.ts | 48 ++++++++ server/lib/integration/standalone.ts | 5 + server/portal/admin-credential.ts | 6 + tests/unit/platform/reply-to-init.spec.ts | 96 +++++++++++++++ tests/unit/tooling/reply-to-backfill.spec.ts | 51 ++++++++ 7 files changed, 374 insertions(+) create mode 100644 scripts/backfill-reply-to.mjs create mode 100644 scripts/lib/reply-to-backfill.mjs create mode 100644 server/lib/integration/owner-reply-to.ts create mode 100644 tests/unit/platform/reply-to-init.spec.ts create mode 100644 tests/unit/tooling/reply-to-backfill.spec.ts diff --git a/scripts/backfill-reply-to.mjs b/scripts/backfill-reply-to.mjs new file mode 100644 index 000000000..0ce9803ac --- /dev/null +++ b/scripts/backfill-reply-to.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * One-off: give every tenant that has no Reply-To the email of its owner. + * + * New workspaces get this default at creation time; this covers the ones that + * predate it. Without a configured Reply-To, a customer replying to an outbound + * message reaches the platform sender address rather than the inspector. + * + * Rules (decided in scripts/lib/reply-to-backfill.mjs, unit-tested there): + * - only a NULL / empty / whitespace-only `tenant_configs.reply_to` is filled; + * a value anyone set is never overwritten, and the write statement re-checks + * that condition itself so a concurrent edit between read and write wins; + * - the owner is the earliest-created user whose `users.role` is 'owner' + * (the role enum is owner | manager | inspector | agent); + * - a tenant without an owner, or whose owner email is blank, is skipped. + * + * DRY RUN BY DEFAULT: it prints every change it would make and writes nothing. + * `--apply` performs the writes. Target is local D1 unless `--remote` is given. + * + * node scripts/backfill-reply-to.mjs # dry run, local D1 + * node scripts/backfill-reply-to.mjs --remote # dry run, remote D1 + * node scripts/backfill-reply-to.mjs --apply --remote # write, remote D1 + * + * Manual steps around a remote run, in order: + * 1. Take a D1 Time Travel bookmark first and record it + * (`wrangler d1 time-travel info `), so the run can be undone. + * 2. Run the dry run and review the list: right tenants, right addresses. + * 3. Run with `--apply`. Every statement re-checks that the field is still + * blank, so a run that stops half way can simply be repeated. + * 4. Verify: run the dry run again. It must report `filled 0`, and the + * `skipped` count must equal the previous `filled` plus the previous + * `skipped`. + * + * The script reaches D1 through `scripts/wrangler.mjs` like the other scripts + * here, and passes every statement as a temp file (`--file`), for reads and + * for writes. + */ +import { writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { planReplyToBackfill } from './lib/reply-to-backfill.mjs'; + +const args = process.argv.slice(2); +const apply = args.includes('--apply'); +const target = args.includes('--remote') ? '--remote' : '--local'; +const wrangler = join(import.meta.dirname, 'wrangler.mjs'); + +/** + * One SELECT through wrangler; returns the result rows. + * + * The statement goes through a temp file (`--file`): `scripts/wrangler.mjs` + * forwards its arguments through a shell without quoting them, so a SQL string + * passed with `--command` is split at the first space. + */ +function query(sql) { + const file = join(tmpdir(), `backfill-reply-to-read-${process.pid}.sql`); + writeFileSync(file, `${sql}; +`, 'utf8'); + try { + const r = spawnSync(process.execPath, [wrangler, 'd1', 'execute', 'DB', target, '--json', '--file', file], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1' }, + }); + if (r.status !== 0) throw new Error(`query failed: ${(r.stderr || r.stdout || '').slice(-400)}`); + // Wrangler prints warnings before the JSON; the JSON array starts on a + // line that holds nothing but "[". + const out = r.stdout; + const start = out.search(/^\[\s*$/m); + if (start === -1) throw new Error(`no JSON in wrangler output: ${out.slice(0, 200)}`); + return JSON.parse(out.slice(start))[0]?.results ?? []; + } finally { + unlinkSync(file); + } +} + +const quote = (s) => `'${String(s).replaceAll("'", "''")}'`; + +/** + * Upsert guarded on the field still being blank, so the statement is safe even + * if the row changed after the read. A tenant with no config row gets one; + * `updated_at` is the only other NOT NULL column. + */ +const writeSql = ({ tenantId, email }) => + `INSERT INTO tenant_configs (tenant_id, reply_to, updated_at) VALUES (${quote(tenantId)}, ${quote(email)}, ${Date.now()})\n` + + `ON CONFLICT(tenant_id) DO UPDATE SET reply_to = excluded.reply_to, updated_at = excluded.updated_at\n` + + `WHERE tenant_configs.reply_to IS NULL OR trim(tenant_configs.reply_to) = '';`; + +console.info(`[backfill-reply-to] ${apply ? 'APPLY' : 'DRY RUN'} against ${target} D1`); + +const tenants = query( + 'SELECT t.id AS tenantId, c.reply_to AS replyTo FROM tenants t LEFT JOIN tenant_configs c ON c.tenant_id = t.id', +); +const owners = query( + "SELECT tenant_id AS tenantId, email, created_at AS createdAt FROM users WHERE role = 'owner'", +); +const { fills, skipped } = planReplyToBackfill(tenants, owners); + +for (const f of fills) console.info(` fill ${f.tenantId} -> ${f.email}`); +for (const s of skipped) console.info(` skip ${s.tenantId} (${s.reason})`); + +if (apply && fills.length > 0) { + const file = join(tmpdir(), `backfill-reply-to-${process.pid}.sql`); + writeFileSync(file, fills.map(writeSql).join('\n') + '\n', 'utf8'); + try { + const r = spawnSync(process.execPath, [wrangler, 'd1', 'execute', 'DB', target, '--file', file, '--yes'], { + stdio: 'inherit', + }); + if (r.status !== 0) { + console.error('[backfill-reply-to] write FAILED — nothing is confirmed; re-run the dry run to see the state'); + process.exitCode = r.status ?? 1; + } + } finally { + unlinkSync(file); + } +} + +console.info(`filled ${fills.length} / skipped ${skipped.length}${apply ? '' : ' (dry run — nothing written; re-run with --apply)'}`); diff --git a/scripts/lib/reply-to-backfill.mjs b/scripts/lib/reply-to-backfill.mjs new file mode 100644 index 000000000..3bbd768db --- /dev/null +++ b/scripts/lib/reply-to-backfill.mjs @@ -0,0 +1,50 @@ +/** + * Which tenants get a Reply-To filled in, and with what. + * + * Pure: rows in, plan out, no database. `scripts/backfill-reply-to.mjs` reads + * the rows and writes the plan; this module only decides, so the rules can be + * tested without either. + * + * Rules: + * - a `reply_to` that is NULL, empty or whitespace-only counts as unset; + * anything else is a choice the operator made and is never touched; + * - the owner is the EARLIEST-created user with the `owner` role. If that + * person's email is blank the tenant is skipped, not handed to the next + * owner: guessing a different address is worse than leaving the field empty; + * - a tenant with no owner is skipped. + */ + +/** @param {unknown} v */ +const blank = (v) => typeof v !== 'string' || v.trim() === ''; + +/** + * @param {{ tenantId: string, replyTo: string | null }[]} tenants + * @param {{ tenantId: string, email: string | null, createdAt: number }[]} owners + * @returns {{ fills: { tenantId: string, email: string }[], skipped: { tenantId: string, reason: string }[] }} + */ +export function planReplyToBackfill(tenants, owners) { + /** @type {Map} */ + const earliest = new Map(); + for (const o of owners) { + const cur = earliest.get(o.tenantId); + if (!cur || o.createdAt < cur.createdAt) earliest.set(o.tenantId, o); + } + + const fills = []; + const skipped = []; + for (const t of tenants) { + if (!blank(t.replyTo)) { + skipped.push({ tenantId: t.tenantId, reason: 'already-set' }); + continue; + } + const owner = earliest.get(t.tenantId); + if (!owner) { + skipped.push({ tenantId: t.tenantId, reason: 'no-owner' }); + } else if (blank(owner.email)) { + skipped.push({ tenantId: t.tenantId, reason: 'owner-email-blank' }); + } else { + fills.push({ tenantId: t.tenantId, email: owner.email.trim() }); + } + } + return { fills, skipped }; +} diff --git a/server/lib/integration/owner-reply-to.ts b/server/lib/integration/owner-reply-to.ts new file mode 100644 index 000000000..94c68c171 --- /dev/null +++ b/server/lib/integration/owner-reply-to.ts @@ -0,0 +1,48 @@ +import { eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { tenantConfigs } from '../db/schema'; +import { logger } from '../logger'; + +/** + * Initialise `tenant_configs.reply_to` from the owner's email, once, when the + * owner account is created. + * + * Without a configured Reply-To a customer's reply to an outbound message goes + * to the platform sender address instead of the inspector. The owner's email is + * the sensible default; the operator can change it later in Settings. + * + * Initialize-only: a value that is already set (anything but NULL / empty / + * whitespace) is never touched. Callers invoke this only on the branch that + * CREATES the owner, so rotating a credential later cannot refill a field the + * operator cleared on purpose. + * + * Best-effort: the owner account already exists by the time this runs, and + * failing the whole provisioning command (which retries on the branch that no + * longer creates anything) would be worse than a blank field. The one-off + * `scripts/backfill-reply-to.mjs` covers any tenant this misses. + */ +export async function initReplyToFromOwner( + db: DrizzleD1Database, + tenantId: string, + ownerEmail: string, +): Promise { + const email = ownerEmail.trim(); + if (!email) return; + try { + const cfg = await db.select({ replyTo: tenantConfigs.replyTo }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + const now = new Date(); + if (!cfg) { + await db.insert(tenantConfigs).values({ tenantId, replyTo: email, updatedAt: now }); + } else if (!cfg.replyTo || cfg.replyTo.trim() === '') { + await db.update(tenantConfigs) + .set({ replyTo: email, updatedAt: now }) + .where(eq(tenantConfigs.tenantId, tenantId)); + } + } catch (err) { + logger.warn('reply-to.init.failed', { + tenantId, + error: err instanceof Error ? err.message : String(err), + }); + } +} diff --git a/server/lib/integration/standalone.ts b/server/lib/integration/standalone.ts index c4410dadf..e6a2c8226 100644 --- a/server/lib/integration/standalone.ts +++ b/server/lib/integration/standalone.ts @@ -10,6 +10,7 @@ import { SQL_UUID_V4 } from './standalone-uuid'; import { seedDefaultAutomations } from './standalone-seed-automations'; import { SmsConsentService } from '../../services/sms-consent.service'; import { SMS_DISCLOSURE_V1 } from '../../services/automation/shared'; +import { initReplyToFromOwner } from './owner-reply-to'; // Default Comment Library entries seeded into every new tenant. The same set // is also backfilled into existing tenants by the default-comments seed @@ -305,6 +306,10 @@ export class StandaloneProvider implements IntegrationProvider { ]; await db.batch(statements as unknown as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]); + // First owner exists: default the tenant's Reply-To to their + // email (initialize-only; a value set earlier is kept). + await initReplyToFromOwner(db, tenantId, adminEmail); + // Default Template — empty starter. Renamed from "Standard // Home Inspection" (R7-23) because it collided semantically // with the Marketplace template "Standard Residential diff --git a/server/portal/admin-credential.ts b/server/portal/admin-credential.ts index 4b8a13003..c59885adf 100644 --- a/server/portal/admin-credential.ts +++ b/server/portal/admin-credential.ts @@ -3,6 +3,7 @@ import { eq, and, lt } from 'drizzle-orm'; import type { BatchItem } from 'drizzle-orm/batch'; import { users, tenants } from '../lib/db/schema'; import { deriveAuthorityBasis } from '../lib/auth/authority-basis'; +import { initReplyToFromOwner } from '../lib/integration/owner-reply-to'; import { buildAcceptanceStatement, type CapturedAcceptance } from '../services/legal/account-acceptance'; /** @@ -110,6 +111,11 @@ export async function applyAdminCredential( // buildAcceptanceStatement threw above rather than return zero // statements), which the compiler cannot see through the spread. await db.batch(statements as unknown as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]); + // Only here, on the branch that CREATES the owner: the Reply-To default + // is a provisioning-time fact, not something to re-assert on every + // credential rotation. Placed after the batch so the atomic + // account+acceptance write is untouched by a config failure. + await initReplyToFromOwner(db, p.tenantId, p.adminEmail); } else { await db.update(users) .set({ diff --git a/tests/unit/platform/reply-to-init.spec.ts b/tests/unit/platform/reply-to-init.spec.ts new file mode 100644 index 000000000..fa0a31b40 --- /dev/null +++ b/tests/unit/platform/reply-to-init.spec.ts @@ -0,0 +1,96 @@ +/** + * tenant_configs.reply_to is initialised from the owner's email when the owner + * account is created, on both creation paths: + * + * - provisioning command path: PortalProvider.handleTenantUpdate + * (and the shared applyAdminCredential it delegates to); + * - first-run setup path: StandaloneProvider.handleTenantUpdate. + * + * Initialize-only: a Reply-To the operator already chose is never overwritten, + * and rotating a credential on an existing account never refills a field the + * operator deliberately cleared. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { eq } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { createTestDb, setupSchema } from '../db'; +import { toD1Binding } from '../helpers/d1-binding'; +import * as schema from '../../../server/lib/db/schema'; +import { tenants, tenantConfigs } from '../../../server/lib/db/schema'; +import { PortalProvider } from '../../../server/portal/portal.provider'; +import { StandaloneProvider } from '../../../server/lib/integration/standalone'; + +const TENANT = '00000000-0000-0000-0000-0000000000b1'; +const EMAIL = 'owner@example.com'; +const acceptance = { + actorIdentityRef: 'identity-1', + authorityBasis: 'owner' as const, + documents: [{ doc: 'terms', version: '2026-08-01', contentHash: 'a'.repeat(64), acceptedAt: 1_700_000_000_000 }], +}; + +let db: BetterSQLite3Database; +let binding: D1Database; +const replyTo = async () => (await db.select().from(tenantConfigs).where(eq(tenantConfigs.tenantId, TENANT)).get())?.replyTo; + +beforeEach(async () => { + const fix = createTestDb(); + db = fix.db; + binding = toD1Binding(fix.sqlite); + await setupSchema(fix.sqlite); +}); + +describe('provisioning command path (PortalProvider)', () => { + const sync = (extra: Record = {}) => new PortalProvider(binding).handleTenantUpdate({ + id: TENANT, slug: 'acme', status: 'active', adminEmail: EMAIL, adminPasswordHash: 'pbkdf2:x:y', + acceptance, ...extra, + } as Parameters[0]); + + it('writes the owner email into a new tenant config when the owner is created', async () => { + await sync(); + expect(await replyTo()).toBe(EMAIL); + }); + + it('fills a blank reply_to on an existing config row', async () => { + await db.insert(tenants).values({ id: TENANT, slug: 'acme', createdAt: new Date() }); + await db.insert(tenantConfigs).values({ tenantId: TENANT, replyTo: ' ', updatedAt: new Date() }); + await sync(); + expect(await replyTo()).toBe(EMAIL); + }); + + it('never overwrites a reply_to that is already set', async () => { + await db.insert(tenants).values({ id: TENANT, slug: 'acme', createdAt: new Date() }); + await db.insert(tenantConfigs).values({ tenantId: TENANT, replyTo: 'desk@example.com', updatedAt: new Date() }); + await sync(); + expect(await replyTo()).toBe('desk@example.com'); + }); + + it('does not refill a cleared field when a credential is rotated on an existing owner', async () => { + await sync(); + await db.update(tenantConfigs).set({ replyTo: null }).where(eq(tenantConfigs.tenantId, TENANT)); + await sync({ adminPasswordHash: 'pbkdf2:new:hash' }); + expect(await replyTo()).toBeNull(); + }); + + it('writes nothing when no admin is being created', async () => { + await sync({ adminEmail: undefined, adminPasswordHash: undefined, acceptance: undefined }); + expect(await replyTo()).toBeUndefined(); + }); +}); + +describe('first-run setup path (StandaloneProvider)', () => { + const setup = () => new StandaloneProvider(binding).handleTenantUpdate({ + id: TENANT, slug: 'acme', name: 'Acme', adminEmail: EMAIL, adminPasswordHash: 'pbkdf2:x:y', adminName: 'Owner', + } as Parameters[0]); + + it('writes the first owner email into the tenant config', async () => { + await setup(); + expect(await replyTo()).toBe(EMAIL); + }); + + it('never overwrites a reply_to that is already set', async () => { + await db.insert(tenants).values({ id: TENANT, slug: 'acme', createdAt: new Date() }); + await db.insert(tenantConfigs).values({ tenantId: TENANT, replyTo: 'desk@example.com', updatedAt: new Date() }); + await setup(); + expect(await replyTo()).toBe('desk@example.com'); + }); +}); diff --git a/tests/unit/tooling/reply-to-backfill.spec.ts b/tests/unit/tooling/reply-to-backfill.spec.ts new file mode 100644 index 000000000..69f7a474e --- /dev/null +++ b/tests/unit/tooling/reply-to-backfill.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +// @ts-expect-error — plain .mjs helper, no types alongside it (same as the +// other scripts/lib modules the tooling specs import). +import { planReplyToBackfill } from '../../../scripts/lib/reply-to-backfill.mjs'; + +interface Plan { + fills: { tenantId: string; email: string }[]; + skipped: { tenantId: string; reason: string }[]; +} +const plan = (tenants: unknown[], owners: unknown[]) => planReplyToBackfill(tenants, owners) as Plan; + +const t = (tenantId: string, replyTo: string | null) => ({ tenantId, replyTo }); +const o = (tenantId: string, email: string, createdAt: number) => ({ tenantId, email, createdAt }); + +describe('planReplyToBackfill', () => { + it('fills a NULL reply_to with the owner email', () => { + expect(plan([t('a', null)], [o('a', 'own@a.test', 1)]).fills).toEqual([{ tenantId: 'a', email: 'own@a.test' }]); + }); + + it('treats empty and whitespace-only values as unset', () => { + const r = plan([t('a', ''), t('b', ' \t ')], [o('a', 'x@a.test', 1), o('b', 'y@b.test', 1)]); + expect(r.fills.map((f) => f.tenantId)).toEqual(['a', 'b']); + }); + + it('never overwrites a value that is already set', () => { + const r = plan([t('a', 'keep@a.test')], [o('a', 'own@a.test', 1)]); + expect(r.fills).toEqual([]); + expect(r.skipped).toEqual([{ tenantId: 'a', reason: 'already-set' }]); + }); + + it('picks the earliest-created owner when a tenant has several', () => { + const r = plan([t('a', null)], [o('a', 'late@a.test', 20), o('a', 'early@a.test', 5), o('a', 'mid@a.test', 9)]); + expect(r.fills).toEqual([{ tenantId: 'a', email: 'early@a.test' }]); + }); + + it('skips a tenant with no owner', () => { + const r = plan([t('a', null)], [o('b', 'own@b.test', 1)]); + expect(r.fills).toEqual([]); + expect(r.skipped).toEqual([{ tenantId: 'a', reason: 'no-owner' }]); + }); + + it('skips when the earliest owner has a blank email instead of falling through to a later owner', () => { + const r = plan([t('a', null)], [o('a', ' ', 1), o('a', 'later@a.test', 2)]); + expect(r.fills).toEqual([]); + expect(r.skipped).toEqual([{ tenantId: 'a', reason: 'owner-email-blank' }]); + }); + + it('trims the owner email it writes', () => { + expect(plan([t('a', null)], [o('a', ' own@a.test ', 1)]).fills[0]?.email).toBe('own@a.test'); + }); +}); From c8e15cd36fd38b6fec142c178c48a089b6b40ae0 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:33:39 -0400 Subject: [PATCH 02/16] feat(email): absorb short rate limits, defer instead of fail on quota, throttle the automation flush A provider rate limit or a used-up daily quota used to end an owed automation email as a permanent failure, and a short rate limit in a request surfaced as a generic 502. Both are transient and now treated as such. - Request path: when the provider answers rate_limited with a retry-after of at most two seconds, wait once and send again. A longer wait, or no hint, is returned as a clear 429 EMAIL_RATE_LIMITED ("Email delivery is busy right now. Please try again shortly.") instead of the generic 502. The sleep is injected so tests do not wait. - Quota window: a quota failure now carries an optional quotaWindow ("daily" or "monthly"), taken from the provider's own error name. The failure kind is unchanged and adapters that do not set the field behave as before. - Automation flush: a rate limit keeps the row pending and moves send_at by the provider's retry-after (60 seconds when it gives none, never less than one); a daily quota keeps it pending until the next UTC midnight; a monthly quota marks it failed with a reason a person can act on. A quota whose window is unknown keeps the old terminal behaviour rather than being guessed into a retry. This reuses the stay-pending-and-push-send_at mechanism of the outbound window, so nothing new is needed to deliver the row later. The report email's plain-text fallback is skipped after such a refusal, so the same limit is not hit twice. - Throttle: a flush batch starts at most five sends in any one second, using a sliding window with an injected clock. It only adds wall time, not CPU time. - The failure mapping moved out of services/email/base.ts into its own module so base.ts shrinks instead of growing. Known limits: the Outbox view shows a deferred row's "waiting" reason as "Skipped" because it only recognises the outbound-window reason; teaching it the new reasons needs new translations and is left for a follow-up. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- server/lib/email/provider-failure.ts | 40 +++++ server/lib/email/provider.ts | 10 +- server/lib/email/providers/resend.ts | 12 +- server/lib/email/rate-limit-retry.ts | 40 +++++ server/lib/errors.ts | 12 ++ server/services/automation/deliver-email.ts | 16 +- server/services/automation/delivery.ts | 9 ++ .../services/automation/provider-deferral.ts | 102 +++++++++++++ server/services/automation/report-email.ts | 11 +- server/services/automation/send-throttle.ts | 42 ++++++ server/services/email/base.ts | 30 +--- .../automation-flush-provider-refusal.spec.ts | 142 ++++++++++++++++++ .../automation-provider-deferral.spec.ts | 98 ++++++++++++ .../automation-send-throttle.spec.ts | 58 +++++++ tests/unit/email/rate-limit-retry.spec.ts | 128 ++++++++++++++++ tests/unit/email/resend-quota-window.spec.ts | 47 ++++++ .../unit/email/resend-usage-and-probe.spec.ts | 2 +- 17 files changed, 770 insertions(+), 29 deletions(-) create mode 100644 server/lib/email/provider-failure.ts create mode 100644 server/lib/email/rate-limit-retry.ts create mode 100644 server/services/automation/provider-deferral.ts create mode 100644 server/services/automation/send-throttle.ts create mode 100644 tests/unit/automations/automation-flush-provider-refusal.spec.ts create mode 100644 tests/unit/automations/automation-provider-deferral.spec.ts create mode 100644 tests/unit/automations/automation-send-throttle.spec.ts create mode 100644 tests/unit/email/rate-limit-retry.spec.ts create mode 100644 tests/unit/email/resend-quota-window.spec.ts diff --git a/server/lib/email/provider-failure.ts b/server/lib/email/provider-failure.ts new file mode 100644 index 000000000..b88283630 --- /dev/null +++ b/server/lib/email/provider-failure.ts @@ -0,0 +1,40 @@ +import { AppError, ErrorCode, Errors } from '../errors'; +import { logger } from '../logger'; +import type { EmailProvider } from './provider'; + +type Failure = Extract>, { ok: false }>; + +/** + * Turn a provider failure that needs no side effect into the error callers see. + * (A suppressed recipient is handled by the sender itself because it writes + * back to the suppression table first.) + * + * - quota_exceeded: 402. `details.quotaWindow` says daily or monthly when the + * adapter knew, so a caller that can wait for the reset tells them apart. + * - rate_limited: 429 with a plain "try again shortly" message. + * - sender_domain: the operator-facing domain error. + * - anything else: the generic delivery failure. + */ +export function providerFailureError(failure: Failure): AppError { + const { error, kind } = failure; + if (kind === 'quota_exceeded') { + logger.error('[email] Provider quota exceeded', { error, quotaWindow: failure.quotaWindow }); + return new AppError(402, ErrorCode.QUOTA_EXHAUSTED, + 'Your email sending quota is exhausted. Upgrade your plan or wait for the quota to reset.', + { + metric: 'email', used: 0, cap: 0, billingPortalUrl: null, + ...(failure.quotaWindow ? { quotaWindow: failure.quotaWindow } : {}), + }, + ); + } + if (kind === 'rate_limited') { + logger.warn('[email] Provider rate limit persisted after the inline retry', { retryAfterSeconds: failure.retryAfterSeconds }); + return Errors.EmailRateLimited(failure.retryAfterSeconds); + } + if (kind === 'sender_domain') { + logger.error('[email] Sender domain error', { error }); + return Errors.EmailSenderDomain({ hint: error }); + } + logger.error('[email] Delivery failed', { kind, error }); + return new AppError(502, ErrorCode.SERVICE_UNAVAILABLE, 'Email delivery failed. Please try again.'); +} diff --git a/server/lib/email/provider.ts b/server/lib/email/provider.ts index 25800c69c..cc7e64eec 100644 --- a/server/lib/email/provider.ts +++ b/server/lib/email/provider.ts @@ -79,6 +79,10 @@ export type NormalizedEmailEvent = { * so we stop trying before the provider rejects it again. * `quota_exceeded` — daily or monthly send quota exhausted. Operator must * upgrade their plan or wait for the quota to reset. + * `quotaWindow` says which one when the provider told us: + * a daily quota resets at the next UTC midnight, a monthly + * one only with the next billing period. Absent means the + * adapter could not tell (treat as unknown, not as either). * `sender_domain` — the `from` domain is unverified or the key is limited to * test sends. Operator must verify the domain in Resend. */ @@ -94,6 +98,8 @@ export type EmailFailureKind = * emails USED so far in the window (not remaining). Each field is present only * when the provider sent a valid reading for it. */ +export type EmailQuotaWindow = 'daily' | 'monthly'; + export interface EmailSendUsage { dailyUsed?: number; monthlyUsed?: number; @@ -104,13 +110,13 @@ export interface EmailProvider { * Send a transactional email. * * Returns `{ ok: true; id?; usage? }` on success. - * Returns `{ ok: false; error; kind; retryAfterSeconds?; usage? }` on failure — implementations MUST NOT + * Returns `{ ok: false; error; kind; retryAfterSeconds?; quotaWindow?; usage? }` on failure — implementations MUST NOT * throw. `kind` lets callers branch on WHY delivery failed rather than * treating all failures identically. */ sendEmail(args: EmailSendArgs): Promise< | { ok: true; id?: string; usage?: EmailSendUsage } - | { ok: false; error: string; kind: EmailFailureKind; retryAfterSeconds?: number; usage?: EmailSendUsage } + | { ok: false; error: string; kind: EmailFailureKind; retryAfterSeconds?: number; quotaWindow?: EmailQuotaWindow; usage?: EmailSendUsage } >; /** diff --git a/server/lib/email/providers/resend.ts b/server/lib/email/providers/resend.ts index 92c89273f..e0ec0d14d 100644 --- a/server/lib/email/providers/resend.ts +++ b/server/lib/email/providers/resend.ts @@ -1,5 +1,5 @@ import { logger } from '../../logger'; -import type { EmailFailureKind, EmailProvider, EmailSendArgs, EmailSendUsage, EmailWebhookContext, NormalizedEmailEvent } from '../provider'; +import type { EmailFailureKind, EmailQuotaWindow, EmailProvider, EmailSendArgs, EmailSendUsage, EmailWebhookContext, NormalizedEmailEvent } from '../provider'; import { base64ToBytes, bytesToBase64, @@ -93,6 +93,12 @@ export function classifyResendError( return 'transient'; } +/** Which quota a Resend 429 error name refers to, or undefined when it is neither. */ +export function quotaWindowFor(name: string | undefined): EmailQuotaWindow | undefined { + if (name === 'daily_quota_exceeded') return 'daily'; + return name === 'monthly_quota_exceeded' ? 'monthly' : undefined; +} + /** * ResendProvider — thin fetch-based adapter over the Resend REST API. * Satisfies EmailProvider for send + credential validation. @@ -105,7 +111,7 @@ export class ResendProvider implements EmailProvider { args: EmailSendArgs, ): Promise< | { ok: true; id?: string; usage?: EmailSendUsage } - | { ok: false; error: string; kind: EmailFailureKind; retryAfterSeconds?: number; usage?: EmailSendUsage } + | { ok: false; error: string; kind: EmailFailureKind; retryAfterSeconds?: number; quotaWindow?: EmailQuotaWindow; usage?: EmailSendUsage } > { const payload: Record = { from: args.from, @@ -161,9 +167,11 @@ export class ResendProvider implements EmailProvider { else if (kind === 'suppressed') logger.warn('[email] ResendProvider delivery failed (suppressed)', failure); else logger.error(`[email] ResendProvider delivery failed (${kind})`, failure); const retryAfterSeconds = kind === 'rate_limited' ? readRetryAfterSeconds(res.headers) : undefined; + const quotaWindow = kind === 'quota_exceeded' ? quotaWindowFor(errName) : undefined; return { ok: false, error: errMsg, kind, ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}), + ...(quotaWindow ? { quotaWindow } : {}), ...(usage ? { usage } : {}), }; } diff --git a/server/lib/email/rate-limit-retry.ts b/server/lib/email/rate-limit-retry.ts new file mode 100644 index 000000000..b2da98cb6 --- /dev/null +++ b/server/lib/email/rate-limit-retry.ts @@ -0,0 +1,40 @@ +import type { EmailProvider } from './provider'; + +/** + * Longest provider-requested pause the request path will sit through. A person + * is waiting on the response, so anything longer is reported back instead. + */ +export const MAX_INLINE_RETRY_SECONDS = 2; + +export interface RateLimitRetryOptions { + /** Injected so tests do not wait; defaults to a timer. */ + sleep?: (ms: number) => Promise; + maxWaitSeconds?: number; +} + +const timerSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Wrap a provider so a short rate limit is absorbed once, in the request path. + * + * When the provider answers `rate_limited` AND told us to wait no longer than + * `maxWaitSeconds`, sleep that long and send once more. A longer wait, or no + * hint at all, is returned untouched for the caller to report: guessing a wait + * would hold the request open for a time the provider never asked for. Only one + * retry is made, so the worst case is a single short pause. + * + * Every other provider member is reachable through the prototype, unchanged. + */ +export function withRateLimitRetry(provider: EmailProvider, opts: RateLimitRetryOptions = {}): EmailProvider { + const sleep = opts.sleep ?? timerSleep; + const maxWait = opts.maxWaitSeconds ?? MAX_INLINE_RETRY_SECONDS; + const sendEmail: EmailProvider['sendEmail'] = async (args) => { + const first = await provider.sendEmail(args); + if (first.ok || first.kind !== 'rate_limited') return first; + const wait = first.retryAfterSeconds; + if (wait === undefined || wait > maxWait) return first; + await sleep(wait * 1000); + return provider.sendEmail(args); + }; + return Object.assign(Object.create(provider) as EmailProvider, { sendEmail }); +} diff --git a/server/lib/errors.ts b/server/lib/errors.ts index c5419e0b5..e06ef574c 100644 --- a/server/lib/errors.ts +++ b/server/lib/errors.ts @@ -60,6 +60,10 @@ export enum ErrorCode { // provider, or the API key is scoped to test sends only. Operator action // required: verify the domain in email provider settings. EMAIL_SENDER_DOMAIN = 'EMAIL_SENDER_DOMAIN', + // The email provider's request-rate limit was hit and did not clear within + // the short inline retry. Nothing is wrong with the message or the account; + // trying again in a few seconds is the right response. + EMAIL_RATE_LIMITED = 'EMAIL_RATE_LIMITED', } /** @@ -214,4 +218,12 @@ export const Errors = { 'Email could not be sent: the sender domain is not verified. Go to Settings → Email to fix this.', details, ), + /** 429: the provider is rate limiting sends right now. `retryAfterSeconds` is its hint, when it gave one. */ + EmailRateLimited: (retryAfterSeconds?: number) => + new AppError( + 429, + ErrorCode.EMAIL_RATE_LIMITED, + 'Email delivery is busy right now. Please try again shortly.', + retryAfterSeconds !== undefined ? { retryAfterSeconds } : undefined, + ), }; diff --git a/server/services/automation/deliver-email.ts b/server/services/automation/deliver-email.ts index 1f4b6e16c..24c82bef1 100644 --- a/server/services/automation/deliver-email.ts +++ b/server/services/automation/deliver-email.ts @@ -13,6 +13,9 @@ import { COOLING_WINDOW_SENTINEL, COOLING_WINDOW_DEFER_REASON, coolingWindowSentinelFor, coolingWindowUnlockAtMs, deferUntilCoolingWindowOpens, } from './cooling-window'; +import { + PROVIDER_DEFERRAL_SENTINEL, providerOutcomeFor, applyProviderOutcome, type ProviderOutcome, +} from './provider-deferral'; import type { FlushInspection } from './shared'; import type { EmailService } from '../email.service'; import type { automations, automationLogs as automationLogsTable, tenants } from '../../lib/db/schema'; @@ -188,6 +191,9 @@ export async function deliverTemplatedEmail( // which is what the log row is re-scheduled to. Scoped to this one // delivery, written on exactly one branch, read on exactly one. let coolingUnlockAtMs: number | null = null; + // Same idea for a provider refusal that undoes itself (rate limit, daily + // quota) or is final for the month: see ./provider-deferral. + let providerOutcome: ProviderOutcome | null = null; const transport = { sendEmail: async (a: { to: string; subject: string; html: string }) => { // The rules layer names what it is sending, like every other @@ -207,7 +213,11 @@ export async function deliverTemplatedEmail( // The cooling window DECLINED; it did not break. Anything else // is a real fault and must keep propagating as one. const sentinel = coolingWindowSentinelFor(err); - if (!sentinel) throw err; + if (!sentinel) { + providerOutcome = providerOutcomeFor(err, Date.now()); + if (!providerOutcome) throw err; + return { ok: false as const, error: PROVIDER_DEFERRAL_SENTINEL }; + } coolingUnlockAtMs = coolingWindowUnlockAtMs(err); return { ok: false as const, error: sentinel }; } @@ -248,6 +258,10 @@ export async function deliverTemplatedEmail( } return; } + if (row.status === 'failed' && row.error === PROVIDER_DEFERRAL_SENTINEL && providerOutcome) { + await applyProviderOutcome(db, log.id, providerOutcome); + return; + } if (row.status === 'sent') { await db.update(automationLogs).set({ status: 'sent', diff --git a/server/services/automation/delivery.ts b/server/services/automation/delivery.ts index 6674ff323..6fcc127fd 100644 --- a/server/services/automation/delivery.ts +++ b/server/services/automation/delivery.ts @@ -11,6 +11,7 @@ import type { ManagedSendGateEnv } from '../../lib/sms/managed-send-gate'; import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; import { deliverReportEmail, type ReportDeliveryDeps } from './report-email'; import { deliverTemplatedEmail } from './deliver-email'; +import { createSendThrottle, type SendThrottle } from './send-throttle'; /** * The flush query's SELECT projection. `inspection` is narrowed to the @@ -86,6 +87,9 @@ export function AutomationDelivery { const db = this.getDrizzle(); const now = new Date(); @@ -261,6 +265,9 @@ export function AutomationDelivery>(); + // Email sends in this batch start at most N per second (wall time only). + const throttle = flushDeps?.throttle ?? createSendThrottle(); + // report.amended templates carry the re-publish change note // ({{summary}}) from the latest report_versions row. Fetch once per // inspection per flush; only amended emails need it, so the query is @@ -345,6 +352,7 @@ export function AutomationDelivery { + const d = err instanceof AppError ? err.details : undefined; + return typeof d === 'object' && d !== null ? d as Record : {}; +} + +/** + * Decide what a provider refusal means for the log row, or null when this is not + * one of the refusals handled here (so every other error keeps reading as the + * failure it is). + */ +export function providerOutcomeFor(err: unknown, nowMs: number): ProviderOutcome | null { + if (!(err instanceof AppError)) return null; + const details = detailsOf(err); + if (err.code === ErrorCode.EMAIL_RATE_LIMITED) { + const secs = details.retryAfterSeconds; + const pause = typeof secs === 'number' && Number.isFinite(secs) + ? Math.max(secs * 1000, MIN_PAUSE_MS) + : DEFAULT_RATE_LIMIT_PAUSE_MS; + return { action: 'defer', sendAtMs: nowMs + pause, reason: RATE_LIMIT_DEFER_REASON }; + } + if (err.code === ErrorCode.QUOTA_EXHAUSTED && details.metric === 'email') { + if (details.quotaWindow === 'daily') { + return { action: 'defer', sendAtMs: nextUtcMidnightMs(nowMs), reason: DAILY_QUOTA_DEFER_REASON }; + } + if (details.quotaWindow === 'monthly') return { action: 'fail', reason: MONTHLY_QUOTA_FAIL_REASON }; + } + return null; +} + +/** Write the outcome onto the log row: pending with a later send_at, or failed with the reason. */ +export async function applyProviderOutcome( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + logId: string, + outcome: ProviderOutcome, +): Promise { + if (outcome.action === 'defer') { + await db.update(automationLogs) + .set({ status: 'pending', error: outcome.reason, sendAt: new Date(outcome.sendAtMs) }) + .where(eq(automationLogs.id, logId)); + // NO recipient/PII — the fact and the instant only. + logger.info('[automation] send deferred after an email provider refusal', { logId, sendAtMs: outcome.sendAtMs }); + return; + } + await db.update(automationLogs) + .set({ status: 'failed', error: outcome.reason }) + .where(eq(automationLogs.id, logId)); + logger.warn('[automation] send failed: provider quota exhausted for the month', { logId }); +} diff --git a/server/services/automation/report-email.ts b/server/services/automation/report-email.ts index 6d6d8b4c3..18182cb66 100644 --- a/server/services/automation/report-email.ts +++ b/server/services/automation/report-email.ts @@ -11,6 +11,7 @@ import { createOiTemplateStore } from './template-store'; import { createRecipientLocaleResolver } from '../../lib/i18n/recipient-locale'; import { automationClassId } from '../../lib/notifications/automation-classes'; import { coolingWindowUnlockAtMs, deferUntilCoolingWindowOpens } from './cooling-window'; +import { providerOutcomeFor, applyProviderOutcome } from './provider-deferral'; import { reportDeliverySystemBlocks } from '../../lib/email-templates/renderer'; import { readReportViewCountingEnabled } from '../../lib/report-views'; import type { FlushInspection } from './shared'; @@ -222,7 +223,8 @@ export async function deliverReportEmail( // provider request is built, so the retry raises the identical // error, logs a second scary line about a PDF that was fine, // and arrives at the same outer catch. Hand it straight up. - if (coolingWindowUnlockAtMs(err) !== null) throw err; + // Same for a provider refusal: the text-only retry would hit it again. + if (coolingWindowUnlockAtMs(err) !== null || providerOutcomeFor(err, Date.now())) throw err; logger.error('AutomationService.flush: report PDF email send failed; falling back to text-only email', { inspectionId: inspection.id, logId: log.id }, err instanceof Error ? err : undefined); delivered = await emailSvc.sendReportReady(log.recipient, address, linkUrl, undefined, reportDelivery.renderHost); @@ -256,6 +258,13 @@ export async function deliverReportEmail( await deferUntilCoolingWindowOpens(db, log.id, unlockAtMs); return; } + // A provider rate limit / daily quota keeps the row pending (a monthly + // quota fails it with a readable reason) — see ./provider-deferral. + const providerOutcome = providerOutcomeFor(err, Date.now()); + if (providerOutcome) { + await applyProviderOutcome(db, log.id, providerOutcome); + return; + } await db.update(automationLogs) .set({ status: 'failed', error: err instanceof Error ? err.message.slice(0, 500) : 'Unknown error' }) .where(eq(automationLogs.id, log.id)); diff --git a/server/services/automation/send-throttle.ts b/server/services/automation/send-throttle.ts new file mode 100644 index 000000000..9961f6b23 --- /dev/null +++ b/server/services/automation/send-throttle.ts @@ -0,0 +1,42 @@ +/** + * Keeps a flush batch under a provider's request-rate limit. + * + * `beforeSend()` resolves immediately while fewer than `maxPerSecond` sends + * have started in the last second, and otherwise waits until the oldest of + * them is a full second old. A sliding window, not fixed spacing, so a quiet + * batch is never slowed and a burst is cut at exactly the limit. The throttle + * only adds wall-clock time: waiting on a timer costs no CPU. + * + * Clock and sleep are injected so tests assert the schedule without waiting. + */ +export interface SendThrottle { + beforeSend(): Promise; +} + +export interface SendThrottleOptions { + maxPerSecond?: number; + now?: () => number; + sleep?: (ms: number) => Promise; +} + +/** Sends per second a flush batch may start. Half of a typical provider limit, to leave room for other senders. */ +export const FLUSH_MAX_SENDS_PER_SECOND = 5; + +const timerSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export function createSendThrottle(opts: SendThrottleOptions = {}): SendThrottle { + const max = Math.max(1, opts.maxPerSecond ?? FLUSH_MAX_SENDS_PER_SECOND); + const now = opts.now ?? Date.now; + const sleep = opts.sleep ?? timerSleep; + const starts: number[] = []; + return { + async beforeSend() { + if (starts.length >= max) { + const wait = starts[starts.length - max]! + 1000 - now(); + if (wait > 0) await sleep(wait); + } + starts.push(now()); + if (starts.length > max) starts.splice(0, starts.length - max); + }, + }; +} diff --git a/server/services/email/base.ts b/server/services/email/base.ts index f109adb7e..d910479c7 100644 --- a/server/services/email/base.ts +++ b/server/services/email/base.ts @@ -1,4 +1,6 @@ import { AppError, Errors, ErrorCode } from '../../lib/errors'; +import { providerFailureError } from '../../lib/email/provider-failure'; +import { withRateLimitRetry } from '../../lib/email/rate-limit-retry'; import { logger } from '../../lib/logger'; import { buildIcs, type IcsEvent } from '../../lib/ics'; import { inspectorSignature, type SignatureUser } from '../../lib/inspector-signature'; @@ -94,7 +96,8 @@ export class EmailBaseService { */ public unsubscribeLinks?: UnsubscribeLinkPort, ) { - this.provider = provider ?? new ResendProvider({ apiKey: this.apiKey }); + // A short provider rate limit is absorbed once here (see rate-limit-retry.ts). + this.provider = withRateLimitRetry(provider ?? new ResendProvider({ apiKey: this.apiKey })); } /** Render `trigger` via the template registry when a renderer is injected; @@ -368,7 +371,7 @@ export class EmailBaseService { }, to, html, this.unsubscribeLinks, opts?.classId); if (!result.ok) { - const { error: providerError, kind } = result; + const { kind } = result; if (kind === 'suppressed') { // Write back to the local suppression table so the send-path gate @@ -382,26 +385,9 @@ export class EmailBaseService { throw Errors.EmailRecipientSuppressed({ recipient: to.join(', ') }); } - if (kind === 'quota_exceeded') { - // Re-use the existing QuotaExhausted shape. Provider-level quota - // (no cap/used numbers available), but the code lets the UI - // surface the upgrade path. - logger.error('[email] Provider quota exceeded', { error: providerError }); - throw new AppError(402, ErrorCode.QUOTA_EXHAUSTED, - 'Your email sending quota is exhausted. Upgrade your plan or wait for the quota to reset.', - { metric: 'email', used: 0, cap: 0, billingPortalUrl: null }, - ); - } - - if (kind === 'sender_domain') { - // Operator configuration problem — the from-domain is not verified. - logger.error('[email] Sender domain error', { error: providerError }); - throw Errors.EmailSenderDomain({ hint: providerError }); - } - - // Transient / unclassified: generic delivery failure. - logger.error('[email] Delivery failed', { kind, error: providerError }); - throw new AppError(502, ErrorCode.SERVICE_UNAVAILABLE, 'Email delivery failed. Please try again.'); + // Quota, rate limit, sender domain and the generic fault — see + // provider-failure.ts for the status each one maps to. + throw providerFailureError(result); } // success — meter the send (best-effort; never blocks or breaks delivery). diff --git a/tests/unit/automations/automation-flush-provider-refusal.spec.ts b/tests/unit/automations/automation-flush-provider-refusal.spec.ts new file mode 100644 index 000000000..8e8dff9dc --- /dev/null +++ b/tests/unit/automations/automation-flush-provider-refusal.spec.ts @@ -0,0 +1,142 @@ +/** + * flush() when the email provider refuses a send, and the per-second throttle. + * + * A rate limit and a daily quota leave the row PENDING with send_at pushed (the + * message is still owed and nothing moves a terminal row back to pending); a + * monthly quota fails the row with a readable reason; anything else keeps its + * old terminal behaviour. The throttle is injected so no test waits. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import { asD1Db } from '../helpers/test-db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import { AutomationService } from '../../../server/services/automation.service'; +import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles'; +import { AppError, ErrorCode, Errors } from '../../../server/lib/errors'; +import { + RATE_LIMIT_DEFER_REASON, DAILY_QUOTA_DEFER_REASON, MONTHLY_QUOTA_FAIL_REASON, nextUtcMidnightMs, +} from '../../../server/services/automation/provider-deferral'; +import type { EmailService } from '../../../server/services/email.service'; +import type { SendThrottle } from '../../../server/services/automation/send-throttle'; + +const T = '00000000-0000-0000-0000-0000000000c4'; +let db: BetterSQLite3Database; +let svc: AutomationService; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db; + await setupSchema(fx.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + await db.insert(schema.tenants).values({ + id: T, slug: 'acme-c4', status: 'active', deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); + await seedRoleProfiles(asD1Db(db), T, new Date(1)); + await db.insert(schema.inspections).values({ + id: 'insp-1', tenantId: T, propertyAddress: '1 Main St', date: '2026-06-01', status: 'completed', + reportStatus: 'published', paymentStatus: 'unpaid', price: 0, agreementRequired: false, + paymentRequired: false, createdAt: new Date(), + } as never); + svc = new AutomationService({} as D1Database); + await svc.ensureSeeds(T); +}); + +async function seedEmailLogs(ids: string[]) { + const rule = (await db.select().from(schema.automations).where(eq(schema.automations.tenantId, T))) + .find(a => a.name === 'Report Ready')!; + for (const id of ids) { + await db.insert(schema.automationLogs).values({ + id, tenantId: T, automationId: rule.id, inspectionId: 'insp-1', + recipient: `${id}@example.com`, channel: 'email', sendAt: new Date(0), status: 'pending', + } as never); + } +} +const rowOf = (id: string) => db.select().from(schema.automationLogs).where(eq(schema.automationLogs.id, id)).get(); +const emailThrowing = (err: unknown) => async () => + ({ sendEmail: async () => { throw err; } } as unknown as EmailService); +const quotaError = (quotaWindow?: 'daily' | 'monthly') => new AppError(402, ErrorCode.QUOTA_EXHAUSTED, 'quota', + { metric: 'email', used: 0, cap: 0, billingPortalUrl: null, ...(quotaWindow ? { quotaWindow } : {}) }); +const instantThrottle: SendThrottle = { beforeSend: async () => undefined }; + +async function flushWith(emailFor: () => Promise, throttle: SendThrottle = instantThrottle) { + await svc.flush(emailFor, 'Acme', 'https://app.example.com', undefined, 50, undefined, undefined, undefined, { throttle }); +} + +describe('flush() — provider refusals', () => { + it('keeps a rate-limited row PENDING and pushes send_at by retry-after', async () => { + await seedEmailLogs(['log-rl']); + const before = Date.now(); + await flushWith(emailThrowing(Errors.EmailRateLimited(30))); + const row = await rowOf('log-rl'); + expect(row?.status).toBe('pending'); + expect(row?.error).toBe(RATE_LIMIT_DEFER_REASON); + const sendAt = new Date(row!.sendAt as unknown as number | Date).getTime(); + expect(sendAt).toBeGreaterThanOrEqual(before + 30_000); + expect(sendAt).toBeLessThan(Date.now() + 31_000); + }); + + it('keeps a daily-quota row PENDING until the next UTC midnight', async () => { + await seedEmailLogs(['log-dq']); + await flushWith(emailThrowing(quotaError('daily'))); + const row = await rowOf('log-dq'); + expect(row?.status).toBe('pending'); + expect(row?.error).toBe(DAILY_QUOTA_DEFER_REASON); + expect(new Date(row!.sendAt as unknown as number | Date).getTime()).toBe(nextUtcMidnightMs(Date.now())); + }); + + it('does not pick a deferred row up again in the same tick', async () => { + await seedEmailLogs(['log-dq']); + const send = vi.fn(async () => { throw quotaError('daily'); }); + const emailFor = async () => ({ sendEmail: send } as unknown as EmailService); + await flushWith(emailFor); + await flushWith(emailFor); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('fails a monthly-quota row with a readable reason', async () => { + await seedEmailLogs(['log-mq']); + await flushWith(emailThrowing(quotaError('monthly'))); + const row = await rowOf('log-mq'); + expect(row?.status).toBe('failed'); + expect(row?.error).toBe(MONTHLY_QUOTA_FAIL_REASON); + }); + + it('keeps the old terminal failure for a quota whose window is unknown', async () => { + await seedEmailLogs(['log-uq']); + await flushWith(emailThrowing(quotaError())); + expect((await rowOf('log-uq'))?.status).toBe('failed'); + }); + + it('keeps the old terminal failure for an ordinary delivery error', async () => { + await seedEmailLogs(['log-502']); + await flushWith(emailThrowing(new AppError(502, ErrorCode.SERVICE_UNAVAILABLE, 'Email delivery failed. Please try again.'))); + const row = await rowOf('log-502'); + expect(row?.status).toBe('failed'); + expect(row?.error).toBe('Email delivery failed. Please try again.'); + }); +}); + +describe('flush() — send throttle', () => { + it('waits on the throttle before every email send, one call per send', async () => { + await seedEmailLogs(['log-a', 'log-b', 'log-c']); + const order: string[] = []; + const throttle = { beforeSend: async () => { order.push('throttle'); } }; + const emailFor = async () => ({ + sendEmail: async () => { order.push('send'); return { delivered: true }; }, + } as unknown as EmailService); + await flushWith(emailFor, throttle); + expect(order).toEqual(['throttle', 'send', 'throttle', 'send', 'throttle', 'send']); + }); + + it('defaults to a real throttle so existing callers are throttled without opting in', async () => { + await seedEmailLogs(['log-a']); + const emailFor = async () => ({ sendEmail: async () => ({ delivered: true }) } as unknown as EmailService); + await svc.flush(emailFor, 'Acme', 'https://app.example.com'); + expect((await rowOf('log-a'))?.status).toBe('sent'); + }); +}); diff --git a/tests/unit/automations/automation-provider-deferral.spec.ts b/tests/unit/automations/automation-provider-deferral.spec.ts new file mode 100644 index 000000000..15c90e3dc --- /dev/null +++ b/tests/unit/automations/automation-provider-deferral.spec.ts @@ -0,0 +1,98 @@ +/** + * What the automation ledger does when the email PROVIDER pushes back. + * + * A rate limit and a daily quota both undo themselves on a known clock, so the + * row must stay pending with its send_at moved (the same shape the cooling + * window uses) rather than spend a terminal `failed` on a message that is still + * owed. A monthly quota does not undo itself within any useful time, so that one + * is a real failure with a reason a person can read. + */ +import { describe, it, expect, vi } from 'vitest'; +import { AppError, ErrorCode, Errors } from '../../../server/lib/errors'; +import { + providerOutcomeFor, nextUtcMidnightMs, applyProviderOutcome, + PROVIDER_DEFERRAL_SENTINEL, RATE_LIMIT_DEFER_REASON, DAILY_QUOTA_DEFER_REASON, MONTHLY_QUOTA_FAIL_REASON, +} from '../../../server/services/automation/provider-deferral'; + +const quotaError = (quotaWindow?: 'daily' | 'monthly') => new AppError( + 402, ErrorCode.QUOTA_EXHAUSTED, 'quota', + { metric: 'email', used: 0, cap: 0, billingPortalUrl: null, ...(quotaWindow ? { quotaWindow } : {}) }, +); + +function fakeDb() { + const set = vi.fn(); + const db = { update: vi.fn(() => ({ set: (v: Record) => { set(v); return { where: vi.fn(async () => undefined) }; } })) }; + return { db, set }; +} + +describe('nextUtcMidnightMs', () => { + it('is the start of the following UTC day', () => { + expect(nextUtcMidnightMs(Date.UTC(2026, 9, 11, 13, 45, 12))).toBe(Date.UTC(2026, 9, 12)); + }); + it('moves a full day when called exactly at midnight', () => { + expect(nextUtcMidnightMs(Date.UTC(2026, 9, 11))).toBe(Date.UTC(2026, 9, 12)); + }); + it('rolls over month and year ends', () => { + expect(nextUtcMidnightMs(Date.UTC(2026, 11, 31, 23, 59, 59))).toBe(Date.UTC(2027, 0, 1)); + }); +}); + +describe('providerOutcomeFor', () => { + const now = Date.UTC(2026, 9, 11, 10, 0, 0); + + it('defers a rate limit by the provider retry-after', () => { + expect(providerOutcomeFor(Errors.EmailRateLimited(30), now)) + .toEqual({ action: 'defer', sendAtMs: now + 30_000, reason: RATE_LIMIT_DEFER_REASON }); + }); + + it('falls back to a one-minute pause when the provider gave no hint, never to zero', () => { + expect(providerOutcomeFor(Errors.EmailRateLimited(), now)) + .toEqual({ action: 'defer', sendAtMs: now + 60_000, reason: RATE_LIMIT_DEFER_REASON }); + expect(providerOutcomeFor(Errors.EmailRateLimited(0), now)).toMatchObject({ sendAtMs: now + 1_000 }); + }); + + it('defers a daily quota to the next UTC midnight', () => { + expect(providerOutcomeFor(quotaError('daily'), now)) + .toEqual({ action: 'defer', sendAtMs: Date.UTC(2026, 9, 12), reason: DAILY_QUOTA_DEFER_REASON }); + }); + + it('fails a monthly quota with a readable reason', () => { + const out = providerOutcomeFor(quotaError('monthly'), now); + expect(out).toEqual({ action: 'fail', reason: MONTHLY_QUOTA_FAIL_REASON }); + expect(MONTHLY_QUOTA_FAIL_REASON).toMatch(/monthly/i); + }); + + it('leaves a quota of unknown window alone, so it keeps the old terminal behaviour', () => { + expect(providerOutcomeFor(quotaError(), now)).toBeNull(); + }); + + it('leaves a plan quota (no provider window) and every other error alone', () => { + expect(providerOutcomeFor(Errors.QuotaExhausted({ metric: 'email', used: 100, cap: 100, billingPortalUrl: null }), now)).toBeNull(); + expect(providerOutcomeFor(new AppError(502, ErrorCode.SERVICE_UNAVAILABLE, 'x'), now)).toBeNull(); + expect(providerOutcomeFor(new Error('boom'), now)).toBeNull(); + expect(providerOutcomeFor(undefined, now)).toBeNull(); + }); + + it('ignores a window on a quota error about another metric', () => { + const e = new AppError(402, ErrorCode.QUOTA_EXHAUSTED, 'q', { metric: 'sms', quotaWindow: 'daily' }); + expect(providerOutcomeFor(e, now)).toBeNull(); + }); +}); + +describe('applyProviderOutcome', () => { + it('keeps a deferred row PENDING and moves send_at', async () => { + const { db, set } = fakeDb(); + await applyProviderOutcome(db, 'log-1', { action: 'defer', sendAtMs: 5_000, reason: RATE_LIMIT_DEFER_REASON }); + expect(set).toHaveBeenCalledWith({ status: 'pending', error: RATE_LIMIT_DEFER_REASON, sendAt: new Date(5_000) }); + }); + + it('marks a failed row failed with the reason', async () => { + const { db, set } = fakeDb(); + await applyProviderOutcome(db, 'log-1', { action: 'fail', reason: MONTHLY_QUOTA_FAIL_REASON }); + expect(set).toHaveBeenCalledWith({ status: 'failed', error: MONTHLY_QUOTA_FAIL_REASON }); + }); + + it('exposes a sentinel distinct from the cooling-window one', () => { + expect(PROVIDER_DEFERRAL_SENTINEL).toBe('__provider_deferral__'); + }); +}); diff --git a/tests/unit/automations/automation-send-throttle.spec.ts b/tests/unit/automations/automation-send-throttle.spec.ts new file mode 100644 index 000000000..1bb4e0baf --- /dev/null +++ b/tests/unit/automations/automation-send-throttle.spec.ts @@ -0,0 +1,58 @@ +/** + * A flush batch never starts more than N sends in any one-second window. + * Clock and sleep are injected: the fake sleep advances the fake clock, so the + * test asserts the schedule without waiting. + */ +import { describe, it, expect } from 'vitest'; +import { createSendThrottle } from '../../../server/services/automation/send-throttle'; + +function fakeClock(start = 1_000_000) { + let t = start; + const sleeps: number[] = []; + return { + now: () => t, + sleep: async (ms: number) => { sleeps.push(ms); t += ms; }, + advance: (ms: number) => { t += ms; }, + sleeps, + }; +} + +describe('createSendThrottle', () => { + it('lets a first burst of the limit through with no waiting', async () => { + const c = fakeClock(); + const th = createSendThrottle({ maxPerSecond: 5, now: c.now, sleep: c.sleep }); + for (let i = 0; i < 5; i++) await th.beforeSend(); + expect(c.sleeps).toEqual([]); + }); + + it('holds the sixth send until a full second after the first', async () => { + const c = fakeClock(); + const th = createSendThrottle({ maxPerSecond: 5, now: c.now, sleep: c.sleep }); + for (let i = 0; i < 6; i++) await th.beforeSend(); + expect(c.sleeps).toEqual([1000]); + }); + + it('never starts more than the limit inside any one-second window', async () => { + const c = fakeClock(); + const th = createSendThrottle({ maxPerSecond: 5, now: c.now, sleep: c.sleep }); + const starts: number[] = []; + for (let i = 0; i < 23; i++) { await th.beforeSend(); starts.push(c.now()); c.advance(10); } + for (let i = 0; i + 5 < starts.length; i++) { + expect(starts[i + 5]! - starts[i]!).toBeGreaterThanOrEqual(1000); + } + }); + + it('does not wait when sends are naturally spread out', async () => { + const c = fakeClock(); + const th = createSendThrottle({ maxPerSecond: 5, now: c.now, sleep: c.sleep }); + for (let i = 0; i < 12; i++) { await th.beforeSend(); c.advance(400); } + expect(c.sleeps).toEqual([]); + }); + + it('defaults to five per second', async () => { + const c = fakeClock(); + const th = createSendThrottle({ now: c.now, sleep: c.sleep }); + for (let i = 0; i < 6; i++) await th.beforeSend(); + expect(c.sleeps).toEqual([1000]); + }); +}); diff --git a/tests/unit/email/rate-limit-retry.spec.ts b/tests/unit/email/rate-limit-retry.spec.ts new file mode 100644 index 000000000..21abb2472 --- /dev/null +++ b/tests/unit/email/rate-limit-retry.spec.ts @@ -0,0 +1,128 @@ +/** + * Request-path handling of a provider rate limit. + * + * A person is waiting on this send, so a short provider-requested pause is + * worth absorbing once; a long one is not, and the error must say "try again + * shortly" instead of the generic delivery failure. The sleep is injected so + * no test waits. + */ +import { describe, it, expect, vi } from 'vitest'; +import { withRateLimitRetry } from '../../../server/lib/email/rate-limit-retry'; +import { EmailBaseService } from '../../../server/services/email/base'; +import { AppError, ErrorCode } from '../../../server/lib/errors'; +import type { EmailProvider } from '../../../server/lib/email/provider'; + +type Result = Awaited>; +const ARGS = { from: 'a@x.com', to: 'b@y.com', subject: 'Hi', html: '

hi

' }; +const limited = (retryAfterSeconds?: number): Result => + ({ ok: false, error: 'Too many requests.', kind: 'rate_limited', ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}) }); +const OK: Result = { ok: true, id: 'e1' }; + +function providerReturning(...results: Result[]) { + const sendEmail = vi.fn(async () => results.shift() ?? OK); + return { sendEmail, provider: { sendEmail } as unknown as EmailProvider }; +} + +describe('withRateLimitRetry', () => { + it('waits the provider-requested time once and returns the retry result', async () => { + const { sendEmail, provider } = providerReturning(limited(2), OK); + const sleep = vi.fn(async () => undefined); + const res = await withRateLimitRetry(provider, { sleep }).sendEmail(ARGS); + expect(res).toEqual(OK); + expect(sleep).toHaveBeenCalledExactlyOnceWith(2000); + expect(sendEmail).toHaveBeenCalledTimes(2); + }); + + it('does not retry when the wait is longer than the cap', async () => { + const { sendEmail, provider } = providerReturning(limited(3), OK); + const sleep = vi.fn(async () => undefined); + const res = await withRateLimitRetry(provider, { sleep }).sendEmail(ARGS); + expect(res).toMatchObject({ ok: false, kind: 'rate_limited', retryAfterSeconds: 3 }); + expect(sleep).not.toHaveBeenCalled(); + expect(sendEmail).toHaveBeenCalledTimes(1); + }); + + it('does not retry when the provider gave no wait hint', async () => { + const { sendEmail, provider } = providerReturning(limited(), OK); + const sleep = vi.fn(async () => undefined); + await withRateLimitRetry(provider, { sleep }).sendEmail(ARGS); + expect(sleep).not.toHaveBeenCalled(); + expect(sendEmail).toHaveBeenCalledTimes(1); + }); + + it('retries only once, then returns the second failure', async () => { + const { sendEmail, provider } = providerReturning(limited(1), limited(1), OK); + const sleep = vi.fn(async () => undefined); + const res = await withRateLimitRetry(provider, { sleep }).sendEmail(ARGS); + expect(res).toMatchObject({ ok: false, kind: 'rate_limited' }); + expect(sendEmail).toHaveBeenCalledTimes(2); + }); + + it('passes success and other failure kinds straight through without sleeping', async () => { + const sleep = vi.fn(async () => undefined); + for (const r of [OK, { ok: false, error: 'x', kind: 'transient' }, { ok: false, error: 'q', kind: 'quota_exceeded', quotaWindow: 'daily' }] as Result[]) { + const { sendEmail, provider } = providerReturning(r); + expect(await withRateLimitRetry(provider, { sleep }).sendEmail(ARGS)).toEqual(r); + expect(sendEmail).toHaveBeenCalledTimes(1); + } + expect(sleep).not.toHaveBeenCalled(); + }); + + it('leaves the other provider methods reachable', () => { + const verify = vi.fn(async () => true); + const wrapped = withRateLimitRetry({ sendEmail: vi.fn(), verifyWebhookSignature: verify } as unknown as EmailProvider); + expect(wrapped.verifyWebhookSignature).toBe(verify); + }); +}); + +describe('EmailBaseService.sendEmail when the provider is rate limited', () => { + const svcWith = (...results: Result[]) => { + const { sendEmail, provider } = providerReturning(...results); + return { sendEmail, svc: new EmailBaseService('re_key', 'from@x.com', 'App', undefined, undefined, undefined, provider) }; + }; + + it('absorbs one short rate limit and delivers', async () => { + const { sendEmail, svc } = svcWith(limited(0), OK); + expect(await svc.sendEmail(['b@y.com'], 's', '

x

')).toEqual({ delivered: true }); + expect(sendEmail).toHaveBeenCalledTimes(2); + }); + + it('answers a clear try-again-shortly error, not the generic 502, when it persists', async () => { + const { svc } = svcWith(limited(0), limited(0)); + const err = await svc.sendEmail(['b@y.com'], 's', '

x

').catch((e: unknown) => e) as AppError; + expect(err).toBeInstanceOf(AppError); + expect(err.status).toBe(429); + expect(err.code).toBe(ErrorCode.EMAIL_RATE_LIMITED); + expect(err.message).toMatch(/try again shortly/i); + }); + + it('does not wait for a long rate limit and still gives the clear error', async () => { + const { sendEmail, svc } = svcWith(limited(30)); + const err = await svc.sendEmail(['b@y.com'], 's', '

x

').catch((e: unknown) => e) as AppError; + expect(err.code).toBe(ErrorCode.EMAIL_RATE_LIMITED); + expect(err.details).toEqual({ retryAfterSeconds: 30 }); + expect(sendEmail).toHaveBeenCalledTimes(1); + }); + + it('keeps the generic 502 for a transient fault', async () => { + const { svc } = svcWith({ ok: false, error: 'boom', kind: 'transient' }); + const err = await svc.sendEmail(['b@y.com'], 's', '

x

').catch((e: unknown) => e) as AppError; + expect(err.status).toBe(502); + }); + + it('names the exhausted quota window on the 402 so a caller can tell daily from monthly', async () => { + for (const quotaWindow of ['daily', 'monthly'] as const) { + const { svc } = svcWith({ ok: false, error: 'q', kind: 'quota_exceeded', quotaWindow }); + const err = await svc.sendEmail(['b@y.com'], 's', '

x

').catch((e: unknown) => e) as AppError; + expect(err.status).toBe(402); + expect(err.code).toBe(ErrorCode.QUOTA_EXHAUSTED); + expect(err.details).toMatchObject({ metric: 'email', quotaWindow }); + } + }); + + it('leaves quotaWindow off the 402 when the adapter could not tell', async () => { + const { svc } = svcWith({ ok: false, error: 'q', kind: 'quota_exceeded' }); + const err = await svc.sendEmail(['b@y.com'], 's', '

x

').catch((e: unknown) => e) as AppError; + expect(err.details).not.toHaveProperty('quotaWindow'); + }); +}); diff --git a/tests/unit/email/resend-quota-window.spec.ts b/tests/unit/email/resend-quota-window.spec.ts new file mode 100644 index 000000000..8497d41c0 --- /dev/null +++ b/tests/unit/email/resend-quota-window.spec.ts @@ -0,0 +1,47 @@ +/** + * Resend adapter: a quota failure names WHICH window ran out. + * + * The provider answers `daily_quota_exceeded` or `monthly_quota_exceeded`; both + * are `kind: 'quota_exceeded'`, but they heal on different clocks (the next UTC + * midnight vs. the next billing period), so callers that schedule a retry need + * the distinction. The field is additive: callers that ignore it behave as before. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ResendProvider, quotaWindowFor } from '../../../server/lib/email/providers/resend'; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +const json = (body: unknown, status: number) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +const send = () => new ResendProvider({ apiKey: 're_test' }) + .sendEmail({ from: 'a@x.com', to: 'b@y.com', subject: 'Hi', html: '

hi

' }); + +describe('quotaWindowFor', () => { + it('maps the two Resend quota error names', () => { + expect(quotaWindowFor('daily_quota_exceeded')).toBe('daily'); + expect(quotaWindowFor('monthly_quota_exceeded')).toBe('monthly'); + }); + + it('answers undefined for anything else', () => { + expect(quotaWindowFor('rate_limit_exceeded')).toBeUndefined(); + expect(quotaWindowFor(undefined)).toBeUndefined(); + }); +}); + +describe('ResendProvider.sendEmail quota window', () => { + it('reports the daily window on daily_quota_exceeded', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'daily_quota_exceeded', message: 'Daily.' }, 429))); + expect(await send()).toEqual({ ok: false, error: 'Daily.', kind: 'quota_exceeded', quotaWindow: 'daily' }); + }); + + it('reports the monthly window on monthly_quota_exceeded', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'monthly_quota_exceeded', message: 'Monthly.' }, 429))); + expect(await send()).toEqual({ ok: false, error: 'Monthly.', kind: 'quota_exceeded', quotaWindow: 'monthly' }); + }); + + it('carries no quotaWindow on other failures', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'rate_limit_exceeded', message: 'Slow.' }, 429))); + const res = await send(); + expect('quotaWindow' in res).toBe(false); + }); +}); diff --git a/tests/unit/email/resend-usage-and-probe.spec.ts b/tests/unit/email/resend-usage-and-probe.spec.ts index 00ad08b46..00c233bf5 100644 --- a/tests/unit/email/resend-usage-and-probe.spec.ts +++ b/tests/unit/email/resend-usage-and-probe.spec.ts @@ -62,7 +62,7 @@ describe('ResendProvider.sendEmail usage headers', () => { { 'x-resend-daily-quota': '100', 'x-resend-monthly-quota': '2900' }, ))); expect(await send()).toEqual({ - ok: false, error: 'Daily quota exceeded.', kind: 'quota_exceeded', + ok: false, error: 'Daily quota exceeded.', kind: 'quota_exceeded', quotaWindow: 'daily', usage: { dailyUsed: 100, monthlyUsed: 2900 }, }); }); From 9e2cb4d8d97ace4fe60e95f5ac2b9d81d416f09b Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:01:49 -0400 Subject: [PATCH 03/16] feat(integrations): stepwise "test connection" for BYO email and Stripe Adds POST /api/integrations/test-steps/{email,stripe}, one step per call with prerequisites re-derived server-side. Email (Resend): key valid (network failure reports unreachable, never pass), key scope, sender domain verified (verified domains only; a send-only key is told apart from an account with no domains), a real test send, delivery receipt and quota headroom. The test send always goes to the signed-in user's own address, looked up from the verified session; request bodies cannot name a recipient. Other providers keep their single credentials check as one step. Stripe: key valid, charges/payouts/details_submitted, and webhook delivery log. Payment-creating steps are intentionally not included. Results are stored with a new nullable integration_test_results.step column (migration 0001); history is now kept per (tenant, target, step) so a multi-step run cannot evict earlier results. server/index.ts file-size baseline 747 -> 749: the route table is a list that grows by one mount per router (the new router could not live elsewhere; mounts must stay in this file). checkRateLimit gains an optional scope so the step endpoints share one per-workspace limit. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- migrations/0001_integration_test_step.sql | 1 + migrations/meta/0001_snapshot.json | 12450 ++++++++++++++++ migrations/meta/_journal.json | 7 + packages/api-types/index.ts | 1 + scripts/file-size-baseline.json | 2 +- server/api/integrations-test-steps.ts | 141 + server/api/integrations.ts | 1 + server/index.ts | 2 + server/lib/db/schema/tenant/integration.ts | 3 + server/lib/email/providers/resend.ts | 10 +- server/lib/integration-test-results.ts | 13 +- server/lib/integration/email-test-steps.ts | 175 + server/lib/integration/email-test-store.ts | 56 + server/lib/integration/stripe-test-steps.ts | 97 + server/lib/rate-limit.ts | 8 +- server/lib/validations/integrations.schema.ts | 28 + server/services/stripe.service.ts | 11 + .../unit/email/resend-usage-and-probe.spec.ts | 4 +- .../integrations/email-test-steps.spec.ts | 218 + .../integration-test-step-column.spec.ts | 42 + .../integrations/test-steps-route.spec.ts | 133 + 21 files changed, 13393 insertions(+), 10 deletions(-) create mode 100644 migrations/0001_integration_test_step.sql create mode 100644 migrations/meta/0001_snapshot.json create mode 100644 server/api/integrations-test-steps.ts create mode 100644 server/lib/integration/email-test-steps.ts create mode 100644 server/lib/integration/email-test-store.ts create mode 100644 server/lib/integration/stripe-test-steps.ts create mode 100644 tests/unit/integrations/email-test-steps.spec.ts create mode 100644 tests/unit/integrations/integration-test-step-column.spec.ts create mode 100644 tests/unit/integrations/test-steps-route.spec.ts diff --git a/migrations/0001_integration_test_step.sql b/migrations/0001_integration_test_step.sql new file mode 100644 index 000000000..1d171e871 --- /dev/null +++ b/migrations/0001_integration_test_step.sql @@ -0,0 +1 @@ +ALTER TABLE `integration_test_results` ADD `step` text; \ No newline at end of file diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..31742ebce --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,12450 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "174ac276-45d1-4f0b-85a8-90b5d4a62a47", + "prevId": "38205577-a1ac-4d65-8836-5721fa63a0a9", + "tables": { + "agent_terms_acceptances": { + "name": "agent_terms_acceptances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_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 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agent_terms_acceptances_user": { + "name": "idx_agent_terms_acceptances_user", + "columns": [ + "user_id", + "accepted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "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 + }, + "signer_legal_name": { + "name": "signer_legal_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signer_company_name": { + "name": "signer_company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "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 + }, + "attribution_basis": { + "name": "attribution_basis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attribution_source": { + "name": "attribution_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attributed_at": { + "name": "attributed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agreement_signers_tenant_request": { + "name": "idx_agreement_signers_tenant_request", + "columns": [ + "tenant_id", + "request_id" + ], + "isUnique": false + }, + "idx_agreement_signers_request_email": { + "name": "idx_agreement_signers_request_email", + "columns": [ + "request_id", + "email" + ], + "isUnique": true + }, + "idx_agreement_signers_token_hash": { + "name": "idx_agreement_signers_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreements": { + "name": "agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreements_tenant": { + "name": "idx_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agreements_tenant_id_tenants_id_fk": { + "name": "agreements_tenant_id_tenants_id_fk", + "tableFrom": "agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_call_provenance": { + "name": "ai_call_provenance", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_ai_call_provenance_tenant_created": { + "name": "idx_ai_call_provenance_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_content_reviews": { + "name": "ai_content_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ai_call_id": { + "name": "ai_call_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ai_content_reviews_ai_call": { + "name": "idx_ai_content_reviews_ai_call", + "columns": [ + "ai_call_id" + ], + "isUnique": false + }, + "uq_ai_content_reviews_person_call": { + "name": "uq_ai_content_reviews_person_call", + "columns": [ + "tenant_id", + "artifact_type", + "artifact_id", + "ai_call_id", + "reviewed_by" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": 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 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sender_identity": { + "name": "sender_identity", + "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 + }, + "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_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_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_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 + }, + "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 + }, + "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 + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "import_hash": { + "name": "import_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "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": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_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 + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "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 + }, + "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": {}, + "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 + }, + "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 + }, + "trade_slug": { + "name": "trade_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_contractor_types_tenant_trade": { + "name": "uq_contractor_types_tenant_trade", + "columns": [ + "tenant_id", + "trade_slug" + ], + "isUnique": true, + "where": "trade_slug IS NOT NULL" + } + }, + "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": {} + }, + "deployment_legal_versions": { + "name": "deployment_legal_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_deployment_legal_versions_doc_hash": { + "name": "uq_deployment_legal_versions_doc_hash", + "columns": [ + "doc", + "content_hash" + ], + "isUnique": true + }, + "idx_deployment_legal_versions_latest": { + "name": "idx_deployment_legal_versions_latest", + "columns": [ + "doc", + "published_at" + ], + "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'" + }, + "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 + }, + "view_tracking_objected_at": { + "name": "view_tracking_objected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "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 + }, + "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_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 + }, + "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 + }, + "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_request": { + "name": "idx_inspections_request", + "columns": [ + "request_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 + }, + "idx_inspections_tenant_created": { + "name": "idx_inspections_tenant_created", + "columns": [ + "tenant_id", + "created_at", + "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": { + "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 + }, + "invoice_number": { + "name": "invoice_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "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 + }, + "uq_invoices_tenant_number": { + "name": "uq_invoices_tenant_number", + "columns": [ + "tenant_id", + "invoice_number" + ], + "isUnique": true + } + }, + "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 + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jurisdiction": { + "name": "jurisdiction", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_kind": { + "name": "inspection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delisted_at": { + "name": "delisted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "is_featured" + ], + "isUnique": false + } + }, + "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 + }, + "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": {} + }, + "migration_batches": { + "name": "migration_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "adapter_name": { + "name": "adapter_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "adapter_version": { + "name": "adapter_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conflict_policy": { + "name": "conflict_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'staged'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reverted_at": { + "name": "reverted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_authorized_by": { + "name": "upload_authorized_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_authorized_at": { + "name": "upload_authorized_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_authorization_version": { + "name": "upload_authorization_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "staff_access_authorized_by": { + "name": "staff_access_authorized_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "staff_access_authorized_at": { + "name": "staff_access_authorized_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "staff_access_authorization_version": { + "name": "staff_access_authorization_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_migration_batches_tenant_created": { + "name": "idx_migration_batches_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_migration_batches_expires": { + "name": "idx_migration_batches_expires", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_rows": { + "name": "migration_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conflict_with": { + "name": "conflict_with", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_id": { + "name": "created_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prior_state": { + "name": "prior_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_migration_rows_batch_status": { + "name": "idx_migration_rows_batch_status", + "columns": [ + "batch_id", + "status" + ], + "isUnique": false + }, + "idx_migration_rows_tenant": { + "name": "idx_migration_rows_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "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 + } + }, + "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": { + "uq_qbo_connections_realm": { + "name": "uq_qbo_connections_realm", + "columns": [ + "realm_id" + ], + "isUnique": true + } + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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 + } + }, + "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 + }, + "repair_action_tag": { + "name": "repair_action_tag", + "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_translations": { + "name": "report_translations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report_id": { + "name": "report_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "english_hash": { + "name": "english_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_hash": { + "name": "translated_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notice_version": { + "name": "notice_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ai_call_id": { + "name": "ai_call_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_at": { + "name": "generated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_report_translations_report_locale": { + "name": "uq_report_translations_report_locale", + "columns": [ + "tenant_id", + "report_id", + "locale" + ], + "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": { + "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": {} + }, + "report_views": { + "name": "report_views", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_id": { + "name": "access_token_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_viewed_at": { + "name": "first_viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_report_views_scope": { + "name": "idx_report_views_scope", + "columns": [ + "tenant_id", + "inspection_id", + "access_token_id" + ], + "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 + }, + "inspector_narrative": { + "name": "inspector_narrative", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "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 + }, + "retired_at": { + "name": "retired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_signing_keys_tenant_fingerprint": { + "name": "uq_signing_keys_tenant_fingerprint", + "columns": [ + "tenant_id", + "fingerprint" + ], + "isUnique": true + }, + "uq_signing_keys_tenant_active": { + "name": "uq_signing_keys_tenant_active", + "columns": [ + "tenant_id" + ], + "isUnique": true, + "where": "retired_at IS NULL" + } + }, + "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": "''" + }, + "disclosure_content_hash": { + "name": "disclosure_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "statutory_form_productions": { + "name": "statutory_form_productions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "produced_by": { + "name": "produced_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "produced_at": { + "name": "produced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_statutory_productions_form_version": { + "name": "idx_statutory_productions_form_version", + "columns": [ + "form_id", + "version" + ], + "isUnique": false + }, + "idx_statutory_productions_inspection": { + "name": "idx_statutory_productions_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "statutory_form_sightings": { + "name": "statutory_form_sightings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "observed_hash": { + "name": "observed_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_statutory_form_sightings_seen": { + "name": "uq_statutory_form_sightings_seen", + "columns": [ + "form_id", + "source_url", + "observed_hash" + ], + "isUnique": true + }, + "idx_statutory_form_sightings_form": { + "name": "idx_statutory_form_sightings_form", + "columns": [ + "form_id", + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "statutory_form_versions": { + "name": "statutory_form_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_from": { + "name": "effective_from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mandatory_from": { + "name": "mandatory_from", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effective_until": { + "name": "effective_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_statutory_form_versions_form_version": { + "name": "uq_statutory_form_versions_form_version", + "columns": [ + "form_id", + "version" + ], + "isUnique": true + }, + "idx_statutory_form_versions_form": { + "name": "idx_statutory_form_versions_form", + "columns": [ + "form_id", + "effective_from" + ], + "isUnique": false + } + }, + "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 + } + }, + "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 + }, + "retired_at": { + "name": "retired_at", + "type": "integer", + "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 + } + }, + "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 + }, + "local_entity_id": { + "name": "local_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uninstalled_at": { + "name": "uninstalled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_tenant_library_import": { + "name": "uq_tenant_library_import", + "columns": [ + "tenant_id", + "library_id" + ], + "isUnique": true + } + }, + "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": {} + }, + "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": {} + }, + "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_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 + }, + "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 + }, + "repair_quick_phrases": { + "name": "repair_quick_phrases", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legal_name": { + "name": "legal_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_seq": { + "name": "invoice_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1000 + }, + "report_pdf_retention_years": { + "name": "report_pdf_retention_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "is_report_view_counting_enabled": { + "name": "is_report_view_counting_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + }, + "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 + }, + "content_version": { + "name": "content_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discovery_objections": { + "name": "discovery_objections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proved_by": { + "name": "proved_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "withdrawn_at": { + "name": "withdrawn_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_discovery_objections_email_hash": { + "name": "uq_discovery_objections_email_hash", + "columns": [ + "email_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_ai_attestations": { + "name": "tenant_ai_attestations", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_owner": { + "name": "account_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attested_at": { + "name": "attested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "policy_version": { + "name": "policy_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_ai_configs": { + "name": "tenant_ai_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_courtesy_translation_enabled": { + "name": "is_courtesy_translation_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocks": { + "name": "blocks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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_slug_history": { + "name": "tenant_slug_history", + "columns": { + "old_slug": { + "name": "old_slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retired_until": { + "name": "retired_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tenant_slug_history_tenant": { + "name": "idx_tenant_slug_history_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_invites": { + "name": "tenant_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inspector'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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 + }, + "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 + }, + "statutory_license_type": { + "name": "statutory_license_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "statutory_qualification": { + "name": "statutory_qualification", + "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_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 + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tenant_user'" + }, + "platform_actor_id": { + "name": "platform_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'completed'" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_version": { + "name": "record_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "stores_measured": { + "name": "stores_measured", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "store_results": { + "name": "store_results", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incomplete_notified_at": { + "name": "incomplete_notified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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": {} + }, + "account_acceptances": { + "name": "account_acceptances", + "columns": { + "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 + }, + "actor_identity_ref": { + "name": "actor_identity_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "doc": { + "name": "doc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authority_basis": { + "name": "authority_basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_account_acceptances_user_doc_version": { + "name": "uq_account_acceptances_user_doc_version", + "columns": [ + "user_id", + "doc", + "version" + ], + "isUnique": true + }, + "idx_account_acceptances_tenant": { + "name": "idx_account_acceptances_tenant", + "columns": [ + "tenant_id", + "accepted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legal_holds": { + "name": "legal_holds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matter": { + "name": "matter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placed_by": { + "name": "placed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placed_at": { + "name": "placed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at": { + "name": "released_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_legal_holds_tenant_active": { + "name": "idx_legal_holds_tenant_active", + "columns": [ + "tenant_id", + "released_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": {} + }, + "statutory_form_entries": { + "name": "statutory_form_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "values": { + "name": "values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_statutory_form_entries_subject": { + "name": "uq_statutory_form_entries_subject", + "columns": [ + "tenant_id", + "inspection_id", + "form_id" + ], + "isUnique": true + }, + "idx_statutory_form_entries_inspection": { + "name": "idx_statutory_form_entries_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "statutory_inspection_details": { + "name": "statutory_inspection_details", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_signature_date": { + "name": "inspector_signature_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "employee_printed_name": { + "name": "employee_printed_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_name": { + "name": "owner_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_email": { + "name": "owner_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_mailing_address": { + "name": "owner_mailing_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_home_phone": { + "name": "owner_home_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_work_phone": { + "name": "owner_work_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_cell_phone": { + "name": "owner_cell_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_statutory_inspection_details_subject": { + "name": "uq_statutory_inspection_details_subject", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": true + } + }, + "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 5e1df96d8..f274b01fe 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1788909939593, "tag": "0000_baseline", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1791726611028, + "tag": "0001_integration_test_step", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/api-types/index.ts b/packages/api-types/index.ts index 86c17207b..8b2d135ac 100644 --- a/packages/api-types/index.ts +++ b/packages/api-types/index.ts @@ -40,6 +40,7 @@ export type { InspectionSyncApi } from '../../server/api/inspection-sync'; export type { InspectionTagApi } from '../../server/api/tags'; export type { IntegrationsApi } from '../../server/api/integrations'; export type { IntegrationsAiApi } from '../../server/api/integrations-ai'; +export type { IntegrationsTestStepsApi } from '../../server/api/integrations-test-steps'; export type { InvoicesApi } from '../../server/api/invoices'; export type { MarketplaceApi } from '../../server/api/marketplace'; export type { MigrationIntakeApi } from '../../server/api/migration-intake'; diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 98ae75ccb..174ed9d56 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -15,7 +15,7 @@ "server/api/inspections/report-delivery.ts": 756, "server/services/inspection/inspection-analytics.service.ts": 748, "server/api/admin/admin-settings.ts": 747, - "server/index.ts": 747, + "server/index.ts": 749, "server/services/inspection/inspection-publish.service.ts": 746, "scripts/check-messaging-rules.mjs": 735, "app/routes/template-edit.tsx": 723, diff --git a/server/api/integrations-test-steps.ts b/server/api/integrations-test-steps.ts new file mode 100644 index 000000000..e0c8a0dd3 --- /dev/null +++ b/server/api/integrations-test-steps.ts @@ -0,0 +1,141 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { eq } from 'drizzle-orm'; +import { createApiRouter } from '../lib/openapi-router'; +import { Errors } from '../lib/errors'; +import { withMcpMetadata } from '../lib/route-metadata-standards'; +import { requireRole } from '../lib/middleware/rbac'; +import { checkRateLimit } from '../lib/rate-limit'; +import { getDrizzle, createDrizzle } from '../lib/route-helpers'; +import { tenantConfigs } from '../lib/db/schema'; +import { coerceEmailByoProvider, resolveEmailProvider } from '../lib/email/resolve-provider'; +import { recordIntegrationTest, type IntegrationTarget } from '../lib/integration-test-results'; +import { EmailTestStepBodySchema, StripeTestStepBodySchema, TestStepResultSchema } from '../lib/validations/integrations.schema'; +import { runEmailStep, type StepResult } from '../lib/integration/email-test-steps'; +import { runStripeStep } from '../lib/integration/stripe-test-steps'; +import { lookupResendDelivery, readLastSend, readSessionEmail, writeLastSend } from '../lib/integration/email-test-store'; +import { readWebhookLog } from '../lib/stripe-webhook-log'; + +/** + * Stepwise "Test connection": the caller runs ONE step per request and the server + * re-derives whatever that step depends on. Mounted on its own base path (see + * `server/index.ts`) for the same typed-client reason as the AI routes. + * + * The single-shot probes in `integrations.ts` stay as they are; these routes add + * the checks that prove a real send / a real payment account, one at a time. + */ + +const StepResponse = z.object({ success: z.literal(true), data: TestStepResultSchema }); + +const emailStepRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/email', + tags: ['integrations'], + summary: 'Run one step of the stepwise email connection test', + middleware: [requireRole('owner', 'manager')], + request: { body: { content: { 'application/json': { schema: EmailTestStepBodySchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: StepResponse.openapi('EmailTestStepResponse') } }, description: 'The step ran; `data.status` says how it went' }, + 429: { description: 'Too many test steps for this workspace' }, + }, + operationId: 'runEmailTestStep', + description: 'Runs one step against the stored email provider credentials. The test email goes to the signed-in user\'s own address only; the request carries no recipient. Non-Resend providers support only key_valid.', +}, { scopes: ['admin'], tier: 'extended' })); + +const stripeStepRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/stripe', + tags: ['integrations'], + summary: 'Run one step of the stepwise Stripe connection test', + middleware: [requireRole('owner', 'manager')], + request: { body: { content: { 'application/json': { schema: StripeTestStepBodySchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: StepResponse.openapi('StripeTestStepResponse') } }, description: 'The step ran; `data.status` says how it went' }, + 429: { description: 'Too many test steps for this workspace' }, + }, + operationId: 'runStripeTestStep', + description: 'Read-only checks against the stored Stripe key: account reachable, charges/payouts enabled, and a verified webhook delivery seen. Creates nothing in the Stripe account.', +}, { scopes: ['admin'], tier: 'extended' })); + +/** Persist a decided step (pass/fail only). Best effort: never changes the response. */ +async function logStep( + env: { DB: D1Database }, tenantId: string, userId: string | null, + target: IntegrationTarget, provider: string | null, r: StepResult, +): Promise { + if (r.status !== 'pass' && r.status !== 'fail') return; + await recordIntegrationTest(createDrizzle(env.DB), { + tenantId, target, provider, ok: r.status === 'pass', detail: r.detail, testedByUserId: userId, step: r.step, + }).catch(() => {}); +} + +const integrationsTestStepsRoutes = createApiRouter() + .openapi(emailStepRoute, async (c) => { + const tenantId = c.get('tenantId'); + const user = c.get('user'); + if (!tenantId || !user?.sub) throw Errors.Unauthorized('Missing tenant scope'); + await checkRateLimit(c, 'integration-test-step', tenantId); + const { step } = c.req.valid('json'); + const db = getDrizzle(c); + const cfg = await db.select({ p: tenantConfigs.emailByoProvider }).from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, tenantId)).get(); + const provider = coerceEmailByoProvider(cfg?.p); + + let result: StepResult; + if (provider === 'resend') { + const apiKey = c.env.RESEND_API_KEY; + if (!apiKey) { + result = { step, status: 'fail', detail: 'No Resend API key is saved.' }; + } else { + const identity = await c.var.services.branding.getEmailIdentity(tenantId); + result = await runEmailStep(step, { + apiKey, + senderEmail: identity.senderEmail, + senderDisplayName: identity.senderDisplayName ?? identity.companyName, + replyTo: identity.replyTo, + // Session user only. The body schema has no recipient field. + sessionEmail: await readSessionEmail(db, tenantId, user.sub), + lastSend: await readLastSend(c.env.TENANT_CACHE, tenantId, user.sub), + saveLastSend: (s) => writeLastSend(c.env.TENANT_CACHE, tenantId, user.sub, s), + lookupDelivery: (id) => lookupResendDelivery(db, tenantId, id), + }); + } + } else if (step !== 'key_valid') { + result = { step, status: 'skipped', detail: 'Only the key check is available for this provider.' }; + } else { + const env = c.env; + const apiKey = provider === 'sendgrid' ? env.SENDGRID_API_KEY + : provider === 'postmark' ? env.POSTMARK_SERVER_TOKEN + : env.MAILGUN_API_KEY; + const domain = env.MAILGUN_DOMAIN; + if (!apiKey || (provider === 'mailgun' && !domain)) { + result = { step, status: 'fail', detail: 'The credentials for this provider are not fully saved.' }; + } else { + const adapter = resolveEmailProvider(provider, provider === 'mailgun' ? { apiKey, domain: domain! } : { apiKey }); + const v = adapter.validateCredentials ? await adapter.validateCredentials().catch(() => null) : { ok: true as const }; + result = v?.ok + ? { step, status: 'pass', detail: `${provider} accepted the credentials.` } + : { step, status: 'fail', detail: v ? `${provider} rejected the credentials.` : 'The credential check failed unexpectedly.' }; + } + } + await logStep(c.env, tenantId, user.sub, 'email', provider, result); + return c.json({ success: true as const, data: { ...result, provider } }, 200); + }) + .openapi(stripeStepRoute, async (c) => { + const tenantId = c.get('tenantId'); + const user = c.get('user'); + if (!tenantId || !user?.sub) throw Errors.Unauthorized('Missing tenant scope'); + await checkRateLimit(c, 'integration-test-step', tenantId); + const { step } = c.req.valid('json'); + const secretKey = c.env.STRIPE_SECRET_KEY; + const result = await runStripeStep(step, { + fetchAccount: secretKey + ? async () => new (await import('../services/stripe.service')).StripeService(secretKey).getAccountStatus() + : null, + webhookSecretConfigured: !!c.env.STRIPE_WEBHOOK_SECRET, + readDeliveries: () => readWebhookLog(c.env.TENANT_CACHE, tenantId), + }); + await logStep(c.env, tenantId, user.sub, 'stripe', null, result); + return c.json({ success: true as const, data: { ...result, provider: null } }, 200); + }); + +export type IntegrationsTestStepsApi = typeof integrationsTestStepsRoutes; +export default integrationsTestStepsRoutes; diff --git a/server/api/integrations.ts b/server/api/integrations.ts index 5db872b16..ce359def0 100644 --- a/server/api/integrations.ts +++ b/server/api/integrations.ts @@ -147,6 +147,7 @@ const TestResultSchema = z.object({ detail: z.string().nullable().describe('Non-sensitive outcome summary or provider error message.'), testedByUserId: z.string().nullable().describe('JWT sub of the user who ran the probe.'), testedAt: z.number().describe('Epoch milliseconds when the probe ran.'), + step: z.string().nullable().describe('Step id when the row records one step of a stepwise test; null for single-shot probes.'), }).openapi('IntegrationTestResult'); const testResultsRoute = createRoute(withMcpMetadata({ diff --git a/server/index.ts b/server/index.ts index 228fa6ee0..3f8b53fa0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -37,6 +37,7 @@ import testHooksRoutes from './api/test-hooks'; import identityRoutes from './api/identity'; import integrationsApiRoutes from './api/integrations'; import integrationsAiRoutes from './api/integrations-ai'; +import integrationsTestStepsRoutes from './api/integrations-test-steps'; import analyticsRoutes from './api/analytics'; import billingRoutes from './api/billing'; import usageRoutes from './api/usage'; @@ -330,6 +331,7 @@ const routes = app // under one path collapse to the first in the typed client, so the second's // routes would exist at runtime and be uncallable from a loader. .route('/api/integrations/ai', integrationsAiRoutes) + .route('/api/integrations/test-steps', integrationsTestStepsRoutes) .route('/api/analytics', analyticsRoutes) .route('/api/inspections', inspectionsRoutes) .route('/api/credentials', credentialsRoutes) diff --git a/server/lib/db/schema/tenant/integration.ts b/server/lib/db/schema/tenant/integration.ts index 43c7fe42a..aa631eaaa 100644 --- a/server/lib/db/schema/tenant/integration.ts +++ b/server/lib/db/schema/tenant/integration.ts @@ -303,6 +303,9 @@ export const integrationTestResults = sqliteTable('integration_test_results', { // User who ran the probe (JWT sub); NULL if unknown. testedByUserId: text('tested_by_user_id'), testedAt: integer('tested_at', { mode: 'timestamp_ms' }).notNull(), + // Which step of a stepwise test this row records (e.g. 'key_valid', + // 'send_test'); NULL for the single-shot probes. Appended last (forward-only). + step: text('step'), }, (t) => [ index('idx_integration_test_tenant_target').on(t.tenantId, t.target, t.testedAt), ]); diff --git a/server/lib/email/providers/resend.ts b/server/lib/email/providers/resend.ts index e0ec0d14d..fa32f84e2 100644 --- a/server/lib/email/providers/resend.ts +++ b/server/lib/email/providers/resend.ts @@ -229,7 +229,7 @@ export class ResendProvider implements EmailProvider { * `restricted_api_key` and reports `reason: 'restricted'`. */ async listDomains(): Promise< - | { ok: true; total: number; verified: number } + | { ok: true; total: number; verified: number; domains: Array<{ name: string; status: string }> } | { ok: false; reason: 'restricted' | 'unreachable' | 'error'; status: number | null } > { let res: Response; @@ -252,7 +252,13 @@ export class ResendProvider implements EmailProvider { const status = (d as { status?: unknown } | null)?.status; return typeof status === 'string' && status.toLowerCase() === 'verified'; }).length; - return { ok: true, total: data.length, verified }; + const domains = data.flatMap((d) => { + const row = d as { name?: unknown; status?: unknown } | null; + return typeof row?.name === 'string' + ? [{ name: row.name.toLowerCase(), status: typeof row.status === 'string' ? row.status.toLowerCase() : 'unknown' }] + : []; + }); + return { ok: true, total: data.length, verified, domains }; } /** Count of verified domains visible to this key (0 when the key cannot list). */ diff --git a/server/lib/integration-test-results.ts b/server/lib/integration-test-results.ts index 33f016e14..57530614d 100644 --- a/server/lib/integration-test-results.ts +++ b/server/lib/integration-test-results.ts @@ -11,7 +11,7 @@ * message) — never a key, token, or raw response body. */ import type { drizzle } from 'drizzle-orm/d1'; -import { and, desc, eq, inArray } from 'drizzle-orm'; +import { and, desc, eq, inArray, isNull } from 'drizzle-orm'; import { integrationTestResults } from './db/schema'; type Db = ReturnType; @@ -28,6 +28,8 @@ export interface IntegrationTestResult { detail: string | null; testedByUserId: string | null; testedAt: number; // epoch ms + /** Step id for a stepwise test row; null for single-shot probes. */ + step: string | null; } export interface RecordTestArgs { @@ -37,6 +39,8 @@ export interface RecordTestArgs { provider?: string | null; detail?: string | null; testedByUserId?: string | null; + /** Step id when this row records one step of a stepwise test. */ + step?: string | null; /** Injectable for tests; defaults to wall-clock. */ nowMs?: number; } @@ -74,15 +78,19 @@ export async function recordIntegrationTest(db: Db, args: RecordTestArgs): Promi detail: clampDetail(args.detail), testedByUserId: args.testedByUserId ?? null, testedAt: new Date(testedAt), + step: args.step ?? null, }); - // Prune to the newest KEEP_PER_TARGET for this (tenant, target). + // Prune to the newest KEEP_PER_TARGET for this (tenant, target, step): a stepwise + // run writes one row per step, and pruning the whole target would let a six-step + // run evict every earlier result, including the single-shot (step-less) ones. const rows = await db .select({ id: integrationTestResults.id }) .from(integrationTestResults) .where(and( eq(integrationTestResults.tenantId, args.tenantId), eq(integrationTestResults.target, args.target), + args.step ? eq(integrationTestResults.step, args.step) : isNull(integrationTestResults.step), )) .orderBy(desc(integrationTestResults.testedAt)) .all(); @@ -112,5 +120,6 @@ export async function listIntegrationTestResults(db: Db, tenantId: string): Prom detail: r.detail, testedByUserId: r.testedByUserId, testedAt: r.testedAt instanceof Date ? r.testedAt.getTime() : Number(r.testedAt), + step: r.step, })); } diff --git a/server/lib/integration/email-test-steps.ts b/server/lib/integration/email-test-steps.ts new file mode 100644 index 000000000..cbe6c9a89 --- /dev/null +++ b/server/lib/integration/email-test-steps.ts @@ -0,0 +1,175 @@ +/** + * Stepwise "Test connection" for a workspace's own Resend account. + * + * The single-shot probe only proved the key was accepted. Each step here proves + * one more thing a real send depends on, and reports one concrete reason when it + * fails, so a workspace can see WHERE its setup stops working: + * + * key_valid the key is accepted (401/403 = rejected, no reply = unreachable) + * key_scope full-access key, or send-only (cannot read the domain list) + * sender_domain_verified the saved sender's domain is on the account and verified + * send_test one real message, to the signed-in user's own address + * delivery the provider's delivered / bounced event for that message + * quota usage counters the send response carried + * + * Steps run one at a time. The runner never trusts the caller's idea of what + * already passed: the cheap read-only prerequisites are re-run here, and the + * delivery and quota steps read what the last `send_test` stored. A step whose + * prerequisite FAILED reports `skipped`; one whose prerequisite was merely + * skipped (a send-only key cannot list domains) still runs. + * + * Recipient: `send_test` mails `deps.sessionEmail` and nothing else. There is no + * recipient parameter anywhere in this module, so a test endpoint built on it + * cannot be turned into a way to mail an arbitrary address. + * + * `detail` strings are fixed, short and non-sensitive: never the key, never a + * provider response body. + */ +import { ResendProvider } from '../email/providers/resend'; +import type { EmailSendUsage } from '../email/provider'; + +export const EMAIL_STEP_IDS = [ + 'key_valid', 'key_scope', 'sender_domain_verified', 'send_test', 'delivery', 'quota', +] as const; +export type EmailStepId = (typeof EMAIL_STEP_IDS)[number]; + +export type StepStatus = 'pass' | 'fail' | 'skipped' | 'pending'; + +export interface StepResult { + step: string; + status: StepStatus; + detail: string; +} + +/** What the last `send_test` left behind, for the delivery and quota steps. */ +export interface LastSend { + messageId: string; + usage?: EmailSendUsage; +} + +export type DeliveryOutcome = 'delivered' | 'bounced' | 'none'; + +export interface EmailStepDeps { + apiKey: string; + senderEmail: string | null; + senderDisplayName: string | null; + replyTo: string | null; + /** The signed-in user's own address, read from their account row. */ + sessionEmail: string | null; + lastSend: LastSend | null; + saveLastSend(send: LastSend): Promise; + lookupDelivery(messageId: string): Promise; +} + +const result = (step: EmailStepId, status: StepStatus, detail: string): StepResult => ({ step, status, detail }); + +async function keyValid(provider: ResendProvider): Promise { + const probe = await provider.probeApiKey(); + if (!probe.reachable) return result('key_valid', 'fail', 'Could not reach Resend. Try again in a moment.'); + if (!probe.valid) return result('key_valid', 'fail', 'Resend rejected the saved API key.'); + return result('key_valid', 'pass', 'Resend accepted the API key.'); +} + +type DomainListing = Awaited>; + +function keyScope(listing: DomainListing): StepResult { + if (listing.ok) return result('key_scope', 'pass', 'Full-access key: the account\'s domains can be read.'); + if (listing.reason === 'restricted') { + return result('key_scope', 'pass', 'Send-only key: domains cannot be read, so the sender domain check is skipped. Continue with the test email.'); + } + if (listing.reason === 'unreachable') return result('key_scope', 'fail', 'Could not reach Resend. Try again in a moment.'); + return result('key_scope', 'fail', `Resend could not list domains (status ${listing.status ?? 'unknown'}).`); +} + +function senderDomain(listing: DomainListing, senderEmail: string | null): StepResult { + if (!listing.ok && listing.reason === 'restricted') { + return result('sender_domain_verified', 'skipped', 'A send-only key cannot read domains. The test email will show whether the sender is accepted.'); + } + if (!listing.ok) return result('sender_domain_verified', 'fail', 'Resend could not list domains.'); + const at = senderEmail?.lastIndexOf('@') ?? -1; + if (!senderEmail || at < 1) return result('sender_domain_verified', 'fail', 'No sender email is saved. Save one under Communication settings first.'); + const domain = senderEmail.slice(at + 1).trim().toLowerCase(); + const match = listing.domains.find((d) => d.name === domain); + if (!match) return result('sender_domain_verified', 'fail', `${domain} is not a domain on this Resend account.`); + if (match.status !== 'verified') { + return result('sender_domain_verified', 'fail', `${domain} is on the account but its status is "${match.status}", not verified.`); + } + return result('sender_domain_verified', 'pass', `${domain} is verified.`); +} + +/** Strip characters that would break out of a quoted display name. */ +function displayName(name: string | null): string { + return (name ?? '').replace(/[<>"\r\n]/g, '').trim(); +} + +async function sendTest(provider: ResendProvider, deps: EmailStepDeps): Promise { + if (!deps.senderEmail) return result('send_test', 'fail', 'No sender email is saved. Save one under Communication settings first.'); + if (!deps.sessionEmail) return result('send_test', 'fail', 'Your account has no email address to send the test to.'); + const name = displayName(deps.senderDisplayName); + // Straight to the provider adapter: a workspace testing its OWN account is not + // subject to the platform-funded send cooling window or the suppression list. + const sent = await provider.sendEmail({ + from: name ? `${name} <${deps.senderEmail}>` : deps.senderEmail, + to: deps.sessionEmail, + subject: 'Test email from your inspection software', + html: '

This is a test message confirming that your email provider settings can send.

', + text: 'This is a test message confirming that your email provider settings can send.', + ...(deps.replyTo ? { replyTo: deps.replyTo } : {}), + }); + if (sent.ok) { + if (sent.id) await deps.saveLastSend({ messageId: sent.id, ...(sent.usage ? { usage: sent.usage } : {}) }); + return result('send_test', 'pass', 'Test email accepted by Resend and sent to your own address.'); + } + switch (sent.kind) { + case 'sender_domain': return result('send_test', 'fail', 'Resend refused the sender address. Check that its domain is verified.'); + case 'quota_exceeded': return result('send_test', 'fail', `Your Resend ${sent.quotaWindow ?? 'sending'} quota is used up.`); + case 'rate_limited': return result('send_test', 'fail', 'Resend is rate limiting this account. Wait a moment and retry.'); + case 'suppressed': return result('send_test', 'fail', 'Resend suppressed your address as a recipient.'); + default: return result('send_test', 'fail', 'Resend did not accept the test email. Try again in a moment.'); + } +} + +async function delivery(deps: EmailStepDeps): Promise { + if (!deps.lastSend) return result('delivery', 'skipped', 'Send the test email first.'); + const outcome = await deps.lookupDelivery(deps.lastSend.messageId); + if (outcome === 'delivered') return result('delivery', 'pass', 'Resend reported the test email as delivered.'); + if (outcome === 'bounced') return result('delivery', 'fail', 'Resend reported the test email as bounced.'); + return result('delivery', 'pending', 'No delivery report yet. It needs the Resend webhook set up; check again shortly.'); +} + +function quota(deps: EmailStepDeps): StepResult { + if (!deps.lastSend) return result('quota', 'skipped', 'Send the test email first.'); + const u = deps.lastSend.usage; + if (!u) return result('quota', 'skipped', 'Resend did not report quota usage on the send response.'); + const parts = [ + u.dailyUsed !== undefined ? `${u.dailyUsed} sent today` : null, + u.monthlyUsed !== undefined ? `${u.monthlyUsed} sent this month` : null, + ].filter((p): p is string => p !== null); + return result('quota', 'pass', `${parts.join(', ')}.`); +} + +function blockedBy(step: EmailStepId, prior: StepResult): StepResult { + return result(step, 'skipped', `Skipped because ${prior.step} did not pass.`); +} + +/** Run ONE step, re-deriving whatever it depends on. Never throws on provider failure. */ +export async function runEmailStep(target: EmailStepId, deps: EmailStepDeps): Promise { + if (target === 'delivery') return delivery(deps); + if (target === 'quota') return quota(deps); + + const provider = new ResendProvider({ apiKey: deps.apiKey }); + const valid = await keyValid(provider); + if (target === 'key_valid') return valid; + if (valid.status === 'fail') return blockedBy(target, valid); + + const listing = await provider.listDomains(); + const scope = keyScope(listing); + if (target === 'key_scope') return scope; + if (scope.status === 'fail') return blockedBy(target, scope); + + const domain = senderDomain(listing, deps.senderEmail); + if (target === 'sender_domain_verified') return domain; + if (domain.status === 'fail') return blockedBy(target, domain); + + return sendTest(provider, deps); +} diff --git a/server/lib/integration/email-test-store.ts b/server/lib/integration/email-test-store.ts new file mode 100644 index 000000000..6067e0e9b --- /dev/null +++ b/server/lib/integration/email-test-store.ts @@ -0,0 +1,56 @@ +/** + * Storage-facing helpers for the stepwise email test, kept apart from the pure + * step runner so the runner can be exercised with plain fakes. + */ +import { and, eq } from 'drizzle-orm'; +import type { drizzle } from 'drizzle-orm/d1'; +import { processedWebhookEvents, users } from '../db/schema'; +import type { DeliveryOutcome, LastSend } from './email-test-steps'; + +type Db = ReturnType; + +/** Long enough to run the delivery and quota steps after the send; short enough to expire unattended. */ +const LAST_SEND_TTL_S = 60 * 60; + +const lastSendKey = (tenantId: string, userId: string) => `email-test-last-send:${tenantId}:${userId}`; + +export async function readLastSend(kv: KVNamespace | undefined, tenantId: string, userId: string): Promise { + if (!kv) return null; + try { + const raw = (await kv.get(lastSendKey(tenantId, userId), { type: 'json' })) as LastSend | null; + return raw && typeof raw.messageId === 'string' ? raw : null; + } catch { + return null; + } +} + +export async function writeLastSend(kv: KVNamespace | undefined, tenantId: string, userId: string, send: LastSend): Promise { + if (!kv) return; + try { + await kv.put(lastSendKey(tenantId, userId), JSON.stringify(send), { expirationTtl: LAST_SEND_TTL_S }); + } catch { /* diagnostics only — a missing record just skips the later steps */ } +} + +/** + * The signed-in user's own address. Looked up by the verified JWT subject within + * the verified tenant; no request field participates. + */ +export async function readSessionEmail(db: Db, tenantId: string, userId: string): Promise { + const row = await db.select({ email: users.email }).from(users) + .where(and(eq(users.id, userId), eq(users.tenantId, tenantId))).get(); + return row?.email?.trim() || null; +} + +/** + * Whether Resend has reported this message as delivered or bounced. The webhook + * receiver dedup-records every verified event under + * `:resend:::`, which is the + * only trace of a delivered event (delivered events are not stored elsewhere). + */ +export async function lookupResendDelivery(db: Db, tenantId: string, messageId: string): Promise { + const has = async (eventId: string) => !!(await db.select({ id: processedWebhookEvents.eventId }) + .from(processedWebhookEvents).where(eq(processedWebhookEvents.eventId, eventId)).get()); + if (await has(`${tenantId}:resend:bounced:${messageId}:email.bounced`)) return 'bounced'; + if (await has(`${tenantId}:resend:delivered:${messageId}:email.delivered`)) return 'delivered'; + return 'none'; +} diff --git a/server/lib/integration/stripe-test-steps.ts b/server/lib/integration/stripe-test-steps.ts new file mode 100644 index 000000000..bffc248dc --- /dev/null +++ b/server/lib/integration/stripe-test-steps.ts @@ -0,0 +1,97 @@ +/** + * Stepwise "Test connection" for a workspace's own Stripe account. + * + * Three read-only steps, each proving one more thing: + * + * key_valid the stored secret key can read its own account + * account_capabilities charges_enabled / payouts_enabled / details_submitted + * webhook a verified delivery has actually arrived (from the + * per-tenant delivery log; Stripe's signature cannot be + * triggered from our side, so "arrived and verified" is + * the only observable proof) + * + * NOT implemented here, on purpose: any step that creates a payment. A test-mode + * charge-and-refund and a live-mode create-then-cancel intent both write to the + * workspace's Stripe account, and the live one leaves a record there. They need a + * confirmation flow (what will be created, in which mode, that it leaves a + * cancelled record) designed with the interface, so they are a separate piece of + * work and nothing in this module may be extended to create one silently. + * + * `detail` strings are fixed and non-sensitive: never the key or a response body. + */ +import type { StepResult, StepStatus } from './email-test-steps'; + +export const STRIPE_STEP_IDS = ['key_valid', 'account_capabilities', 'webhook'] as const; +export type StripeStepId = (typeof STRIPE_STEP_IDS)[number]; + +export interface StripeAccountStatus { + accountName: string; + chargesEnabled: boolean; + payoutsEnabled: boolean; + detailsSubmitted: boolean; +} + +export interface StripeWebhookDelivery { + ts: string; + eventType: string; + result: 'processed' | 'received' | 'signature_failed' | 'tenant_mismatch'; +} + +export interface StripeStepDeps { + /** Null when no secret key is stored. */ + fetchAccount: (() => Promise) | null; + webhookSecretConfigured: boolean; + /** Recent deliveries, newest first. */ + readDeliveries(): Promise; +} + +const result = (step: StripeStepId, status: StepStatus, detail: string): StepResult => ({ step, status, detail }); + +async function loadAccount(deps: StripeStepDeps): Promise { + if (!deps.fetchAccount) return null; + try { return await deps.fetchAccount(); } catch { return null; } +} + +function keyValid(account: StripeAccountStatus | null, deps: StripeStepDeps): StepResult { + if (!deps.fetchAccount) return result('key_valid', 'fail', 'No Stripe secret key is saved.'); + if (!account) return result('key_valid', 'fail', 'Stripe rejected the saved secret key, or could not be reached.'); + return result('key_valid', 'pass', `Connected to ${account.accountName}.`); +} + +function capabilities(account: StripeAccountStatus): StepResult { + const missing = [ + account.chargesEnabled ? null : 'charges are not enabled', + account.payoutsEnabled ? null : 'payouts are not enabled', + account.detailsSubmitted ? null : 'account details are not submitted', + ].filter((m): m is string => m !== null); + if (missing.length > 0) { + return result('account_capabilities', 'fail', `Stripe reports that ${missing.join(', ')}. Finish onboarding in the Stripe dashboard.`); + } + return result('account_capabilities', 'pass', 'Charges and payouts are enabled and account details are submitted.'); +} + +async function webhook(deps: StripeStepDeps): Promise { + if (!deps.webhookSecretConfigured) return result('webhook', 'fail', 'No webhook signing secret is saved.'); + const log = await deps.readDeliveries(); + const good = log.find((e) => e.result === 'processed' || e.result === 'received'); + if (good) return result('webhook', 'pass', `Last verified delivery: ${good.eventType} at ${good.ts}.`); + if (log.some((e) => e.result === 'signature_failed')) { + return result('webhook', 'fail', 'Deliveries are arriving but fail signature checks. Re-copy the signing secret from the Stripe dashboard.'); + } + if (log.length > 0) return result('webhook', 'fail', 'Deliveries are arriving for a different workspace than this one.'); + return result('webhook', 'skipped', 'No deliveries recorded yet. Send a test event from the Stripe dashboard, then run this step again.'); +} + +/** Run ONE step, re-deriving its prerequisites. Never throws on provider failure. */ +export async function runStripeStep(target: StripeStepId, deps: StripeStepDeps): Promise { + const account = await loadAccount(deps); + const valid = keyValid(account, deps); + if (target === 'key_valid') return valid; + if (valid.status === 'fail' || !account) return result(target, 'skipped', 'Skipped because key_valid did not pass.'); + + const caps = capabilities(account); + if (target === 'account_capabilities') return caps; + if (caps.status === 'fail') return result(target, 'skipped', 'Skipped because account_capabilities did not pass.'); + + return webhook(deps); +} diff --git a/server/lib/rate-limit.ts b/server/lib/rate-limit.ts index 443b442b8..faef0d870 100644 --- a/server/lib/rate-limit.ts +++ b/server/lib/rate-limit.ts @@ -6,15 +6,17 @@ import { Errors } from './errors'; * Check the Cloudflare Rate Limiter for the given prefix + CF-Connecting-IP key. * No-ops when RATE_LIMITER binding is absent (local dev). * Throws RateLimited if the limit is exceeded. + * `scope` replaces the client IP in the key (e.g. a tenant id) so a limit can be + * shared by everyone acting for one workspace. */ -export async function checkRateLimit(c: Context, prefix: string): Promise { +export async function checkRateLimit(c: Context, prefix: string, scope?: string): Promise { if (!c.env.RATE_LIMITER) return; // Test/dev escape hatch (defaults to enforced): the seeded E2E suite drives // ~140 specs' logins from a single runner IP, which blows past the 10/60s // login limiter and 429s beforeAll hooks flakily. Only the ephemeral E2E // .dev.vars sets this; production and self-host never do. if (c.env.DISABLE_RATE_LIMIT === '1') return; - const ip = c.req.header('CF-Connecting-IP') ?? 'unknown'; - const { success } = await c.env.RATE_LIMITER.limit({ key: `${prefix}:${ip}` }); + const who = scope ?? c.req.header('CF-Connecting-IP') ?? 'unknown'; + const { success } = await c.env.RATE_LIMITER.limit({ key: `${prefix}:${who}` }); if (!success) throw Errors.RateLimited(); } diff --git a/server/lib/validations/integrations.schema.ts b/server/lib/validations/integrations.schema.ts index 5ed5a96b8..2528f479d 100644 --- a/server/lib/validations/integrations.schema.ts +++ b/server/lib/validations/integrations.schema.ts @@ -80,3 +80,31 @@ export const AiConnectionTestResultSchema = z message: z.string().optional().describe('What to show. Never contains the provider response body.'), }) .openapi('AiConnectionTestResult'); + +/** + * Request body for POST /test-steps/email and /test-steps/stripe: which ONE step + * to run. Deliberately nothing else — in particular no recipient. Unknown keys + * are stripped by the default object behaviour, never read. + */ +export const EmailTestStepBodySchema = z + .object({ + step: z.enum(['key_valid', 'key_scope', 'sender_domain_verified', 'send_test', 'delivery', 'quota']) + .describe('Which step to run. Earlier steps are re-checked server-side, not trusted from the client.'), + }) + .openapi('EmailTestStepBody'); + +export const StripeTestStepBodySchema = z + .object({ + step: z.enum(['key_valid', 'account_capabilities', 'webhook']).describe('Which step to run.'), + }) + .openapi('StripeTestStepBody'); + +/** One step outcome. `detail` is short, fixed and non-sensitive. */ +export const TestStepResultSchema = z + .object({ + step: z.string().describe('Step id.'), + status: z.enum(['pass', 'fail', 'skipped', 'pending']), + detail: z.string().describe('Short non-sensitive explanation; on a failure, the reason.'), + provider: z.string().nullable().describe('Email provider the test ran against; null for Stripe.'), + }) + .openapi('TestStepResult'); diff --git a/server/services/stripe.service.ts b/server/services/stripe.service.ts index bd766e775..f0c38ca75 100644 --- a/server/services/stripe.service.ts +++ b/server/services/stripe.service.ts @@ -117,4 +117,15 @@ export class StripeService { accountName: acct.settings?.dashboard?.display_name || acct.email || acct.id, }; } + + /** The own-account capability flags the "Test connection" steps report on. */ + async getAccountStatus(): Promise<{ accountName: string; chargesEnabled: boolean; payoutsEnabled: boolean; detailsSubmitted: boolean }> { + const acct = await this.stripe.accounts.retrieve(null); + return { + accountName: acct.settings?.dashboard?.display_name || acct.email || acct.id, + chargesEnabled: acct.charges_enabled === true, + payoutsEnabled: acct.payouts_enabled === true, + detailsSubmitted: acct.details_submitted === true, + }; + } } diff --git a/tests/unit/email/resend-usage-and-probe.spec.ts b/tests/unit/email/resend-usage-and-probe.spec.ts index 00c233bf5..5045d28ff 100644 --- a/tests/unit/email/resend-usage-and-probe.spec.ts +++ b/tests/unit/email/resend-usage-and-probe.spec.ts @@ -130,13 +130,13 @@ describe('ResendProvider.listDomains', () => { { name: 'd.com', status: 'failed' }, ] }, 200)); vi.stubGlobal('fetch', fetchMock); - expect(await list()).toEqual({ ok: true, total: 4, verified: 2 }); + expect(await list()).toMatchObject({ ok: true, total: 4, verified: 2 }); expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.resend.com/domains'); }); it('returns zero totals for an empty account', async () => { vi.stubGlobal('fetch', vi.fn(async () => json({ data: [] }, 200))); - expect(await list()).toEqual({ ok: true, total: 0, verified: 0 }); + expect(await list()).toEqual({ ok: true, total: 0, verified: 0, domains: [] }); }); it('reports a send-only key as restricted', async () => { diff --git a/tests/unit/integrations/email-test-steps.spec.ts b/tests/unit/integrations/email-test-steps.spec.ts new file mode 100644 index 000000000..3a7bfce7e --- /dev/null +++ b/tests/unit/integrations/email-test-steps.spec.ts @@ -0,0 +1,218 @@ +/** + * Stepwise email connection test: every branch of the pure step runner, driven + * by a fake `fetch`. No network, no real keys. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { runEmailStep, type EmailStepDeps, type LastSend } from '../../../server/lib/integration/email-test-steps'; +import { runStripeStep, type StripeStepDeps } from '../../../server/lib/integration/stripe-test-steps'; + +afterEach(() => { vi.unstubAllGlobals(); }); + +const json = (body: unknown, status: number, headers: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } }); + +interface Net { + probe?: () => Response | Promise; + domains?: () => Response | Promise; + send?: () => Response | Promise; +} +/** Route the fake fetch by URL + method; the send path and the key probe share POST /emails. */ +function stubNet(net: Net) { + const calls: Array<{ url: string; body: unknown }> = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + calls.push({ url: String(url), body }); + if (String(url).endsWith('/domains')) return (net.domains ?? (() => json({ data: [] }, 200)))(); + // The key probe posts an empty body; a real send carries recipients. + return body && Object.keys(body).length > 0 + ? (net.send ?? (() => json({ id: 'msg_1' }, 200)))() + : (net.probe ?? (() => json({}, 422)))(); + })); + return calls; +} +const down = () => { throw new TypeError('fetch failed'); }; + +function deps(over: Partial = {}): EmailStepDeps & { saved: LastSend[] } { + const saved: LastSend[] = []; + return { + apiKey: 're_fake', + senderEmail: 'hello@mail.example.com', + senderDisplayName: 'Example Inspections', + replyTo: 'owner@example.com', + sessionEmail: 'me@example.com', + lastSend: null, + saveLastSend: async (s) => { saved.push(s); }, + lookupDelivery: async () => 'none', + ...over, + saved, + }; +} +const verifiedDomain = () => json({ data: [{ name: 'mail.example.com', status: 'verified' }] }, 200); + +describe('key_valid', () => { + it('fails as unreachable on a network failure, never as a pass', async () => { + stubNet({ probe: down, domains: down }); + const r = await runEmailStep('key_valid', deps()); + expect(r.status).toBe('fail'); + expect(r.detail).toMatch(/could not reach/i); + }); + it('fails on 401', async () => { + stubNet({ probe: () => json({}, 401) }); + const r = await runEmailStep('key_valid', deps()); + expect(r).toMatchObject({ step: 'key_valid', status: 'fail' }); + expect(r.detail).toMatch(/rejected/i); + }); + it('passes when Resend answers 422 to the empty probe', async () => { + stubNet({}); + expect((await runEmailStep('key_valid', deps())).status).toBe('pass'); + }); +}); + +describe('key_scope', () => { + it('reports a send-only key as a pass that points at the send test', async () => { + stubNet({ domains: () => json({ name: 'restricted_api_key', message: 'x' }, 401) }); + const r = await runEmailStep('key_scope', deps()); + expect(r.status).toBe('pass'); + expect(r.detail).toMatch(/send-only/i); + }); + it('reports a full-access key', async () => { + stubNet({ domains: verifiedDomain }); + expect((await runEmailStep('key_scope', deps())).detail).toMatch(/full-access/i); + }); + it('is skipped, not run, when the key was rejected', async () => { + const calls = stubNet({ probe: () => json({}, 403) }); + const r = await runEmailStep('key_scope', deps()); + expect(r.status).toBe('skipped'); + expect(calls.some((c) => c.url.endsWith('/domains'))).toBe(false); + }); +}); + +describe('sender_domain_verified', () => { + it('passes for a verified sender domain', async () => { + stubNet({ domains: verifiedDomain }); + expect(await runEmailStep('sender_domain_verified', deps())).toMatchObject({ status: 'pass' }); + }); + it('fails for an unverified domain and names its status', async () => { + stubNet({ domains: () => json({ data: [{ name: 'mail.example.com', status: 'pending' }] }, 200) }); + const r = await runEmailStep('sender_domain_verified', deps()); + expect(r.status).toBe('fail'); + expect(r.detail).toContain('pending'); + }); + it('fails when the domain is not on the account (a verified OTHER domain does not count)', async () => { + stubNet({ domains: () => json({ data: [{ name: 'other.com', status: 'verified' }] }, 200) }); + const r = await runEmailStep('sender_domain_verified', deps()); + expect(r.status).toBe('fail'); + expect(r.detail).toMatch(/not a domain on this/i); + }); + it('is skipped (not failed) for a send-only key', async () => { + stubNet({ domains: () => json({ name: 'restricted_api_key' }, 401) }); + expect((await runEmailStep('sender_domain_verified', deps())).status).toBe('skipped'); + }); + it('fails when no sender is saved', async () => { + stubNet({ domains: verifiedDomain }); + expect((await runEmailStep('sender_domain_verified', deps({ senderEmail: null }))).status).toBe('fail'); + }); +}); + +describe('send_test', () => { + it('sends once to the session address with the saved sender and Reply-To, and stores the message id', async () => { + const calls = stubNet({ + domains: verifiedDomain, + send: () => json({ id: 'msg_42' }, 200, { 'x-resend-daily-quota': '3', 'x-resend-monthly-quota': '40' }), + }); + const d = deps(); + const r = await runEmailStep('send_test', d); + expect(r.status).toBe('pass'); + const sent = calls.filter((c) => c.url.endsWith('/emails') && c.body && Object.keys(c.body as object).length > 0); + expect(sent).toHaveLength(1); + expect(sent[0]!.body).toMatchObject({ + from: 'Example Inspections ', to: 'me@example.com', reply_to: 'owner@example.com', + }); + expect(d.saved).toEqual([{ messageId: 'msg_42', usage: { dailyUsed: 3, monthlyUsed: 40 } }]); + }); + it('proceeds with a send-only key (the domain step was merely skipped)', async () => { + stubNet({ domains: () => json({ name: 'restricted_api_key' }, 401) }); + expect((await runEmailStep('send_test', deps())).status).toBe('pass'); + }); + it('does not send when the domain step failed', async () => { + const calls = stubNet({ domains: () => json({ data: [] }, 200) }); + const r = await runEmailStep('send_test', deps()); + expect(r.status).toBe('skipped'); + expect(calls.some((c) => (c.body as object | null) && Object.keys(c.body as object).length > 0)).toBe(false); + }); + it('fails on a 429 rate limit with a retry hint', async () => { + stubNet({ domains: verifiedDomain, send: () => json({ name: 'rate_limit_exceeded', message: 'slow down' }, 429, { 'retry-after': '2' }) }); + const r = await runEmailStep('send_test', deps()); + expect(r.status).toBe('fail'); + expect(r.detail).toMatch(/rate limit/i); + }); + it('fails on an exhausted quota (429 daily_quota_exceeded)', async () => { + stubNet({ domains: verifiedDomain, send: () => json({ name: 'daily_quota_exceeded', message: 'no' }, 429) }); + expect((await runEmailStep('send_test', deps())).detail).toMatch(/daily quota/i); + }); + it('fails when Resend refuses the sender domain (403 validation_error)', async () => { + stubNet({ domains: verifiedDomain, send: () => json({ name: 'validation_error', message: 'domain not verified' }, 403) }); + expect((await runEmailStep('send_test', deps())).detail).toMatch(/sender address/i); + }); + it('fails when there is no session address, without calling the send endpoint', async () => { + const calls = stubNet({ domains: verifiedDomain }); + const r = await runEmailStep('send_test', deps({ sessionEmail: null })); + expect(r.status).toBe('fail'); + expect(calls.some((c) => (c.body as object | null) && Object.keys(c.body as object).length > 0)).toBe(false); + }); + it('never puts the API key or provider message in detail', async () => { + stubNet({ domains: verifiedDomain, send: () => json({ name: 'validation_error', message: 'secret-provider-text re_fake' }, 403) }); + const r = await runEmailStep('send_test', deps()); + expect(r.detail).not.toContain('re_fake'); + expect(r.detail).not.toContain('secret-provider-text'); + }); +}); + +describe('delivery and quota (read what send_test stored; no network)', () => { + it('are skipped before any test email was sent', async () => { + const calls = stubNet({}); + expect((await runEmailStep('delivery', deps())).status).toBe('skipped'); + expect((await runEmailStep('quota', deps())).status).toBe('skipped'); + expect(calls).toHaveLength(0); + }); + it('delivery: delivered passes, bounced fails, nothing yet is pending', async () => { + const last = { messageId: 'msg_1' }; + expect((await runEmailStep('delivery', deps({ lastSend: last, lookupDelivery: async () => 'delivered' }))).status).toBe('pass'); + expect((await runEmailStep('delivery', deps({ lastSend: last, lookupDelivery: async () => 'bounced' }))).status).toBe('fail'); + expect((await runEmailStep('delivery', deps({ lastSend: last }))).status).toBe('pending'); + }); + it('quota: reads the counters the send carried, else skips', async () => { + const withUsage = await runEmailStep('quota', deps({ lastSend: { messageId: 'm', usage: { dailyUsed: 3, monthlyUsed: 40 } } })); + expect(withUsage).toMatchObject({ status: 'pass', detail: '3 sent today, 40 sent this month.' }); + expect((await runEmailStep('quota', deps({ lastSend: { messageId: 'm' } }))).status).toBe('skipped'); + }); +}); + +describe('stripe steps', () => { + const acct = { accountName: 'Acme', chargesEnabled: true, payoutsEnabled: true, detailsSubmitted: true }; + const sd = (over: Partial = {}): StripeStepDeps => ({ + fetchAccount: async () => acct, webhookSecretConfigured: true, readDeliveries: async () => [], ...over, + }); + it('key_valid: fails with no key and when Stripe rejects it', async () => { + expect((await runStripeStep('key_valid', sd({ fetchAccount: null }))).status).toBe('fail'); + expect((await runStripeStep('key_valid', sd({ fetchAccount: async () => { throw new Error('401'); } }))).status).toBe('fail'); + expect(await runStripeStep('key_valid', sd())).toMatchObject({ status: 'pass', detail: 'Connected to Acme.' }); + }); + it('account_capabilities: names what is missing', async () => { + const r = await runStripeStep('account_capabilities', sd({ fetchAccount: async () => ({ ...acct, payoutsEnabled: false }) })); + expect(r.status).toBe('fail'); + expect(r.detail).toContain('payouts are not enabled'); + expect((await runStripeStep('account_capabilities', sd())).status).toBe('pass'); + }); + it('webhook: pass / signature failure / no deliveries / no secret', async () => { + const ok = { ts: '2026-10-01T00:00:00Z', eventType: 'payment_intent.succeeded', result: 'processed' as const }; + expect((await runStripeStep('webhook', sd({ readDeliveries: async () => [ok] }))).status).toBe('pass'); + const bad = { ...ok, result: 'signature_failed' as const }; + expect((await runStripeStep('webhook', sd({ readDeliveries: async () => [bad] }))).detail).toMatch(/signature/i); + expect((await runStripeStep('webhook', sd())).status).toBe('skipped'); + expect((await runStripeStep('webhook', sd({ webhookSecretConfigured: false }))).status).toBe('fail'); + }); + it('later steps are skipped when the key failed', async () => { + expect((await runStripeStep('webhook', sd({ fetchAccount: null }))).status).toBe('skipped'); + }); +}); diff --git a/tests/unit/integrations/integration-test-step-column.spec.ts b/tests/unit/integrations/integration-test-step-column.spec.ts new file mode 100644 index 000000000..5a86ab6f1 --- /dev/null +++ b/tests/unit/integrations/integration-test-step-column.spec.ts @@ -0,0 +1,42 @@ +/** + * The `step` column: schema position, migration, and that recordIntegrationTest + * writes it. The prune rule is unchanged (still per tenant + target). + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { getTableConfig } from 'drizzle-orm/sqlite-core'; +import { integrationTestResults } from '../../../server/lib/db/schema'; +import { recordIntegrationTest, idsToPrune, KEEP_PER_TARGET } from '../../../server/lib/integration-test-results'; + +describe('integration_test_results.step', () => { + it('is a nullable text column appended last', () => { + const cols = getTableConfig(integrationTestResults).columns; + const last = cols[cols.length - 1]!; + expect(last.name).toBe('step'); + expect(last.notNull).toBe(false); + expect(last.getSQLType()).toBe('text'); + }); + + it('has a forward migration that adds exactly that column', () => { + // migrefs-allow: the test pins the exact SQL of the migration that adds the column + const sql = readFileSync('migrations/0001_integration_test_step.sql', 'utf8'); + expect(sql.trim()).toBe('ALTER TABLE `integration_test_results` ADD `step` text;'); + }); + + it('recordIntegrationTest persists the step, and null when none is given', async () => { + const inserted: Array> = []; + const db = { + insert: () => ({ values: async (v: Record) => { inserted.push(v); } }), + select: () => ({ from: () => ({ where: () => ({ orderBy: () => ({ all: async () => [] }) }) }) }), + }; + const base = { tenantId: 't1', target: 'email' as const, ok: true, detail: 'ok' }; + await recordIntegrationTest(db as never, { ...base, step: 'send_test' }); + await recordIntegrationTest(db as never, base); + expect(inserted.map((r) => r.step)).toEqual(['send_test', null]); + }); + + it('keeps the cap at 5, applied per (target, step)', () => { + expect(KEEP_PER_TARGET).toBe(5); + expect(idsToPrune(['a', 'b', 'c', 'd', 'e', 'f'], KEEP_PER_TARGET)).toEqual(['f']); + }); +}); diff --git a/tests/unit/integrations/test-steps-route.spec.ts b/tests/unit/integrations/test-steps-route.spec.ts new file mode 100644 index 000000000..22952967c --- /dev/null +++ b/tests/unit/integrations/test-steps-route.spec.ts @@ -0,0 +1,133 @@ +/** + * POST /api/integrations/test-steps/{email,stripe}: recipient safety, per-step + * persistence, rate limiting. Storage helpers are mocked; the real step runner + * runs against a fake fetch. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { HonoConfig } from '../../../server/types/hono'; + +const h = vi.hoisted(() => ({ + record: vi.fn(async (..._a: unknown[]) => undefined), + sessionEmailArgs: [] as unknown[][], +})); +vi.mock('../../../server/lib/integration-test-results', async (orig) => ({ + ...(await orig()), + recordIntegrationTest: h.record, +})); +vi.mock('../../../server/lib/route-helpers', async (orig) => { + const fakeDb = { select: () => ({ from: () => ({ where: () => ({ get: async () => ({ p: 'resend' }) }) }) }) }; + return { ...(await orig()), getDrizzle: () => fakeDb, createDrizzle: () => fakeDb }; +}); +vi.mock('../../../server/lib/integration/email-test-store', () => ({ + readSessionEmail: async (...a: unknown[]) => { h.sessionEmailArgs.push(a); return 'session-user@example.com'; }, + readLastSend: async () => null, + writeLastSend: async () => undefined, + lookupResendDelivery: async () => 'none', +})); + +import routes from '../../../server/api/integrations-test-steps'; + +const json = (body: unknown, status: number) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +const identity = { senderEmail: 'hello@mail.example.com', senderDisplayName: 'Acme', companyName: null, replyTo: 'r@example.com' }; + +function app(role = 'owner') { + const a = new OpenAPIHono(); + a.onError((err, c) => c.json({ success: false }, ((err as { status?: number }).status ?? 500) as 429)); + a.use('*', async (c, next) => { + const set = (c as unknown as { set(k: string, v: unknown): void }).set.bind(c); + set('userRole', role); + set('tenantId', 'tenant-1'); + set('user', { sub: 'user-1' }); + set('services', { branding: { getEmailIdentity: async () => identity } }); + await next(); + }); + a.route('/api/integrations/test-steps', routes); + return a; +} +const post = (path: string, body: unknown, env: Record = {}, role?: string) => + app(role).request(`/api/integrations/test-steps/${path}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }, { DB: {}, JWT_SECRET: 'x'.repeat(32), RESEND_API_KEY: 're_fake', ...env }); + +let sends: Array>; +beforeEach(() => { + h.record.mockClear(); + h.sessionEmailArgs.length = 0; + sends = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/domains')) return json({ data: [{ name: 'mail.example.com', status: 'verified' }] }, 200); + const body = JSON.parse(String(init?.body ?? '{}')) as Record; + if (Object.keys(body).length === 0) return json({}, 422); + sends.push(body); + return json({ id: 'msg_1' }, 200); + })); +}); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('email send_test recipient', () => { + it('mails the signed-in user and looks the address up by verified tenant + user id', async () => { + const res = await post('email', { step: 'send_test' }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ success: true, data: { step: 'send_test', status: 'pass', provider: 'resend' } }); + expect(sends).toHaveLength(1); + expect(sends[0]).toMatchObject({ to: 'session-user@example.com', reply_to: 'r@example.com' }); + expect(h.sessionEmailArgs[0]!.slice(1)).toEqual(['tenant-1', 'user-1']); + }); + + it('NEGATIVE CONTROL: recipient-shaped fields in the body are ignored', async () => { + const res = await post('email', { + step: 'send_test', to: 'victim@example.org', email: 'victim@example.org', recipient: 'victim@example.org', + tenantId: 'other-tenant', userId: 'other-user', + }); + expect(res.status).toBe(200); + expect(sends).toHaveLength(1); + expect(sends[0]!.to).toBe('session-user@example.com'); + expect(JSON.stringify(sends)).not.toContain('victim@example.org'); + expect(h.sessionEmailArgs[0]!.slice(1)).toEqual(['tenant-1', 'user-1']); + }); + + it('rejects an unknown step id', async () => { + expect((await post('email', { step: 'nope' })).status).toBe(400); + }); + + it('is closed to inspectors', async () => { + expect((await post('email', { step: 'key_valid' }, {}, 'inspector')).status).toBeGreaterThanOrEqual(401); + expect(sends).toHaveLength(0); + }); +}); + +describe('step persistence', () => { + it('records a decided step with its step id', async () => { + await post('email', { step: 'key_valid' }); + expect(h.record.mock.calls[0]![1]).toMatchObject({ + tenantId: 'tenant-1', target: 'email', provider: 'resend', ok: true, step: 'key_valid', testedByUserId: 'user-1', + }); + }); + it('does not record a skipped step', async () => { + await post('email', { step: 'quota' }); + expect(h.record).not.toHaveBeenCalled(); + }); + it('reports a missing key in-band as a failed step', async () => { + const res = await post('email', { step: 'key_valid' }, { RESEND_API_KEY: undefined }); + expect(await res.json()).toMatchObject({ data: { status: 'fail', detail: 'No Resend API key is saved.' } }); + }); +}); + +describe('rate limit', () => { + it('429 when the limiter denies, keyed by tenant', async () => { + const limit = vi.fn(async () => ({ success: false })); + const res = await post('email', { step: 'key_valid' }, { RATE_LIMITER: { limit } }); + expect(res.status).toBe(429); + expect(limit).toHaveBeenCalledWith({ key: 'integration-test-step:tenant-1' }); + }); +}); + +describe('stripe route', () => { + it('without a stored key, key_valid fails in-band and is recorded for stripe', async () => { + const res = await post('stripe', { step: 'key_valid' }); + expect(await res.json()).toMatchObject({ data: { step: 'key_valid', status: 'fail', provider: null } }); + expect(h.record.mock.calls[0]![1]).toMatchObject({ target: 'stripe', step: 'key_valid', ok: false }); + }); +}); From eec2ea7c68eade6e764fee5d9e65517fba4dc8de Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:21:27 -0400 Subject: [PATCH 04/16] fix(auth): complete standalone TOTP sign-in through the /login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A correct TOTP code entered on /login re-rendered the challenge with "Verification could not be completed": no session, no redirect. The API accepted the code; the page could not use the answer. Three parts, each sufficient to keep it broken: 1. POST /api/auth/login/2fa set the session cookie and returned only { redirect }. Unlike the password step, it had no x-token-relay branch, and Login2faResponseSchema had no `token` field. 2. login.tsx called the 2FA endpoint without `x-token-relay: 1`, then read data.token from the body. 3. The BFF client does not forward the API's Set-Cookie, so the session can only be created from the JWT in the body (createSessionWithToken). Fix: the 2FA handler mirrors the password step - `token` is in `data` only when the caller sends `x-token-relay: 1`; browser callers still get just the HttpOnly cookie. The schema declares the optional field, and the page sends the header. CLAUDE.md and docs/reference/api.md now name the second-factor step in the token-relay rule. Tests, each shown red against the unfixed code first: - tests/unit/auth/login-2fa-token-relay.spec.ts drives the real app: with the header, data.token verifies against the deployment keyring; without it, no token in the body and the cookie is still set. - login.twofactor.server.test.ts no longer invents `{ data: { token } }`: responses are built through Login2faResponseSchema and only carry the token when the action sent the relay header. - auth-two-factor.spec.ts signs in through the /login page with a TOTP code and lands on /inspections authenticated, with a password-only page login in the same run as a control. Chrome: /login — password then TOTP code landed on /inspections signed in, light and dark Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012dwrjSYhhqbh7Zd9iy2NTq --- CLAUDE.md | 2 +- app/routes/login.tsx | 8 +- app/routes/login.twofactor.server.test.ts | 37 ++++- docs/reference/api.md | 4 +- server/api/auth/totp.ts | 12 +- server/lib/validations/auth.schema.ts | 5 +- tests/e2e/auth-two-factor.spec.ts | 43 +++++- tests/unit/auth/login-2fa-token-relay.spec.ts | 126 ++++++++++++++++++ 8 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 tests/unit/auth/login-2fa-token-relay.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3debd3ff6..9c9e51013 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,7 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls - **iat claim**: `signJwt()` auto-injects `iat: Math.floor(Date.now() / 1000)` when the caller omits it. Without `iat`, KV session invalidation (`pwchanged:{userId}`) cannot work. - **No HS256 fallback**: There is NO legacy HS256 path. Pre-launch architectural choice — see rotation scripts and docs. The remaining `JWT_SECRET` env binding is KDF input only, never a JWT signing key. State it that way rather than listing its consumers: there are dozens of files reading it (secret crypto, signed-URL and session HMACs, token derivation), and every enumeration written here has gone stale. - **Key rotation flow**: To rotate, provision `JWT_PRIVATE_KEY_V` + `JWT_PUBLIC_KEY_V` first (verify-only window), then flip `JWT_CURRENT_KID` to the new version. Old tokens remain verifiable until V is retired. -- **Token NOT in response body — one carved-out exception.** Login, setup and join MUST NOT return the JWT to a browser; delivery is `Set-Cookie` (HttpOnly). The exception is the in-process BFF, which identifies itself with `x-token-relay: 1` and then receives `token` in the body, because Workers `fetch()` may strip `Set-Cookie` on a server-to-server hop and the BFF has to write its own session cookie. A browser never sends that header. Do not add a second exception without one here. +- **Token NOT in response body — one carved-out exception.** Login (both the password step and the second-factor step, `POST /api/auth/login/2fa`), setup and join MUST NOT return the JWT to a browser; delivery is `Set-Cookie` (HttpOnly). The exception is the in-process BFF, which identifies itself with `x-token-relay: 1` and then receives `token` in the body, because Workers `fetch()` may strip `Set-Cookie` on a server-to-server hop and the BFF has to write its own session cookie. A browser never sends that header. Do not add a second exception without one here. - **Cookie name**: Always use `__Host-inspector_token` (enforces `Secure`, `Path=/`, no `Domain`). - **setCookie attributes**: Every `setCookie()` MUST include `httpOnly: true, secure: true, sameSite: 'Strict', path: '/'`. - **deleteCookie secure**: Every `deleteCookie()` MUST include `{ path: '/', secure: true }`. Omitting `secure` on `__Host-` cookies throws a runtime exception. diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 3db76710e..381e87398 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -73,7 +73,13 @@ async function completeTwoFactor( try { const api = createApi(context); - const res = await api.auth.login["2fa"].$post({ json: { challengeToken, code } }); + // Same BFF signal as the password step below: this client does not forward + // the API's Set-Cookie, so without the header the session token never + // reaches this action and a correct code reads as a failure. + const res = await api.auth.login["2fa"].$post( + { json: { challengeToken, code } }, + { headers: { "x-token-relay": "1" } }, + ); if (!res.ok) { // 401 covers both a wrong code and an expired challenge, and the two need // different advice: retyping helps with one and cannot help with the other. diff --git a/app/routes/login.twofactor.server.test.ts b/app/routes/login.twofactor.server.test.ts index fb8beed73..56c2ec2a5 100644 --- a/app/routes/login.twofactor.server.test.ts +++ b/app/routes/login.twofactor.server.test.ts @@ -45,6 +45,7 @@ vi.mock("../../server/lib/deployment-profile", () => ({ import { action } from "./login"; import { routeArgs } from "../../tests/helpers/route-args"; +import { Login2faResponseSchema } from "../../server/lib/validations/auth.schema"; const CONTEXT = {} as Parameters[0]["context"]; const json = (body: unknown, status = 200) => @@ -59,6 +60,26 @@ function post(fields: Record) { )); } +/** + * What `POST /api/auth/login/2fa` answers a successful code with, built through + * the REAL response schema rather than typed out here. A hand-written + * `{ data: { token } }` is how this file used to pass while the page could not + * sign anyone in: the server never sent a token, and nothing here could notice. + * Parsing strips any field the schema does not declare, and the token is only + * present when the caller identified itself as the BFF — the same rule the + * handler applies (see tests/unit/auth/login-2fa-token-relay.spec.ts, which + * holds the handler to it). + */ +function twoFaSuccess(token: string) { + return (_args: unknown, options?: { headers?: Record }) => { + const relay = options?.headers?.["x-token-relay"] === "1"; + return Promise.resolve(json(Login2faResponseSchema.parse({ + success: true, + data: { redirect: "/inspections", ...(relay ? { token } : {}) }, + }))); + }; +} + const CHALLENGE = "challenge.jwt.value"; const GOOD = { email: "a@b.test", password: "hunter2hunter2" }; @@ -95,7 +116,7 @@ describe("login — a 2FA account is handed a challenge, not a refusal", () => { describe("login — answering the challenge", () => { it("posts the code to /login/2fa and never re-posts credentials", async () => { - twoFaPost.mockResolvedValue(json({ data: { token: "session.jwt" } })); + twoFaPost.mockImplementation(twoFaSuccess("session.jwt")); await post({ challengeToken: CHALLENGE, code: "123456" }); expect(loginPost).not.toHaveBeenCalled(); @@ -104,8 +125,18 @@ describe("login — answering the challenge", () => { .toEqual({ challengeToken: CHALLENGE, code: "123456" }); }); + it("identifies itself as the BFF, so the API hands back the session token", async () => { + // The BFF client does not forward the API's Set-Cookie. Without this + // header the API keeps the JWT out of the body, the action has nothing + // to open a session with, and a correct code reads as a failure. + twoFaPost.mockImplementation(twoFaSuccess("session.jwt")); + await post({ challengeToken: CHALLENGE, code: "123456" }); + const options = twoFaPost.mock.calls[0][1] as { headers?: Record } | undefined; + expect(options?.headers?.["x-token-relay"]).toBe("1"); + }); + it("creates the session at the requested destination on success", async () => { - twoFaPost.mockResolvedValue(json({ data: { token: "session.jwt" } })); + twoFaPost.mockImplementation(twoFaSuccess("session.jwt")); await post({ challengeToken: CHALLENGE, code: "123456", returnTo: "/oauth/authorize?x=1" }); expect(createSession).toHaveBeenCalledWith(CONTEXT, "session.jwt", "/oauth/authorize?x=1"); }); @@ -132,7 +163,7 @@ describe("login — answering the challenge", () => { // A 2FA submission carries no email or password. Parsed as a login it // would answer "email is required" to somebody who has already proved // their password — the branch order in the action is what prevents it. - twoFaPost.mockResolvedValue(json({ data: { token: "session.jwt" } })); + twoFaPost.mockImplementation(twoFaSuccess("session.jwt")); const out = await post({ challengeToken: CHALLENGE, code: "123456" }); expect(out).toBeInstanceOf(Response); }); diff --git a/docs/reference/api.md b/docs/reference/api.md index d365e4a6c..2665c687d 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -204,7 +204,9 @@ The browser never sends that header. with **no** `Set-Cookie` — challenge tokens travel JSON-only, so a stolen session cookie alone can never bypass the second factor. The challenge is valid for five minutes; post it back with the TOTP code to `POST /api/auth/login/2fa` to get a -session. +session. That response follows the same delivery rule as this one: the session +arrives as the cookie, and only a caller sending `x-token-relay: 1` also gets +`token` in `data`. Returns `401` if credentials are invalid. diff --git a/server/api/auth/totp.ts b/server/api/auth/totp.ts index fde387e48..208d675e4 100644 --- a/server/api/auth/totp.ts +++ b/server/api/auth/totp.ts @@ -296,7 +296,17 @@ const totpRoutes = createApiRouter() }, keyring); setCookie(c, AUTH_COOKIE_NAME, sessionToken, authCookieOptions()); - return c.json({ success: true, data: { redirect: '/inspections' } }, 200); + + // Token Relay BFF, exactly as the password step in server/api/auth.ts: + // the login page calls this in-process and its client does not forward + // Set-Cookie, so the only way it can open a session is to receive the + // JWT in the body. Only the BFF sends this header; a browser caller + // gets the cookie and never the token. + const isBff = c.req.header('x-token-relay') === '1'; + return c.json({ + success: true, + data: { redirect: '/inspections', ...(isBff ? { token: sessionToken } : {}) }, + }, 200); }); export default totpRoutes; diff --git a/server/lib/validations/auth.schema.ts b/server/lib/validations/auth.schema.ts index 7002f6400..093f7354f 100644 --- a/server/lib/validations/auth.schema.ts +++ b/server/lib/validations/auth.schema.ts @@ -120,7 +120,10 @@ export const TotpSetupResponseSchema = createApiResponseSchema(z.object({ })); export const Login2faResponseSchema = createApiResponseSchema(z.union([ - z.object({ redirect: z.string().describe('TODO describe redirect field for the OpenInspection MCP integration') }), + z.object({ + redirect: z.string().describe('TODO describe redirect field for the OpenInspection MCP integration'), + token: z.string().optional().describe('Session JWT. Present ONLY for the in-process BFF caller that sends `x-token-relay: 1`; browsers receive the session as the HttpOnly cookie and never in the body.'), + }), z.object({ requires2fa: z.literal(true).describe('TODO describe requires2fa field for the OpenInspection MCP integration'), challengeToken: z.string().describe('TODO describe challengeToken field for the OpenInspection MCP integration') }), ])); diff --git a/tests/e2e/auth-two-factor.spec.ts b/tests/e2e/auth-two-factor.spec.ts index 8adb6502a..bd7882c65 100644 --- a/tests/e2e/auth-two-factor.spec.ts +++ b/tests/e2e/auth-two-factor.spec.ts @@ -22,7 +22,7 @@ * out of the same account. */ import { test, expect } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; +import type { APIRequestContext, Page } from '@playwright/test'; import { TOTP, Secret } from 'otpauth'; import { csrfHeaders } from './helpers/csrf'; @@ -109,6 +109,31 @@ async function post(request: APIRequestContext, path: string, data: unknown, tok const sessionFrom = (res: { headers(): Record }) => (res.headers()['set-cookie'] ?? '').match(/__Host-inspector_token=([^;]+)/)?.[1] ?? ''; +/** + * Sign in through the /login PAGE, as a person does, up to the point where the + * page either lands on /inspections or asks for a code. + * + * The API-level tests below cannot see the BFF hop: the page calls the API + * in-process and has to receive the JWT in the body to open a session, which a + * Playwright `request` never exercises. A correct code that the API accepted + * still failed on this page for exactly that reason. + */ +async function submitPassword(page: Page): Promise { + await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle' }); + await page.fill('input[name=email]', EMAIL); + await page.fill('input[name=password]', PASSWORD); + await page.click('button[type=submit]'); +} + +/** Landed AND authenticated: a reload of /inspections must not bounce to /login. */ +async function expectSignedIn(page: Page): Promise { + await page.waitForURL('**/inspections', { timeout: 15_000 }); + const names = (await page.context().cookies()).map((c) => c.name); + expect(names, 'the BFF opened a session').toContain('__session'); + await page.reload({ waitUntil: 'networkidle' }); + expect(new URL(page.url()).pathname, 'still signed in after a reload').toBe('/inspections'); +} + test.describe.configure({ mode: 'serial' }); test.describe('two-factor sign-in', () => { @@ -116,6 +141,13 @@ test.describe('two-factor sign-in', () => { let recoveryCodes: string[] = []; let session = ''; + test('CONTROL — the login page signs in a password-only account', async ({ page }) => { + // Same page, same account, same run, before 2FA is on: proves the + // page-level step below CAN pass, so a red there is the second factor. + await submitPassword(page); + await expectSignedIn(page); + }); + test('enrolling issues a secret, a QR and eight recovery codes', async ({ request }) => { const login = await post(request, '/api/auth/login', { email: EMAIL, password: PASSWORD }); expect(login.status(), 'password-only login before 2FA').toBe(200); @@ -162,6 +194,15 @@ test.describe('two-factor sign-in', () => { expect(sessionFrom(done), 'a real session cookie').not.toBe(''); }); + test('the login page completes a sign-in with a TOTP code', async ({ page }) => { + await submitPassword(page); + const code = page.locator('input[name=code]'); + await expect(code, 'the page asks for the second factor').toBeVisible({ timeout: 15_000 }); + await code.fill(codeFor(secret)); + await page.click('button[type=submit]'); + await expectSignedIn(page); + }); + test('a wrong code is refused WITHOUT spending the challenge', async ({ request }) => { // Dropping the challenge on a mistyped digit returns the person to the // password form, which reads as "your password was wrong" — a working diff --git a/tests/unit/auth/login-2fa-token-relay.spec.ts b/tests/unit/auth/login-2fa-token-relay.spec.ts new file mode 100644 index 000000000..1c21c9c9c --- /dev/null +++ b/tests/unit/auth/login-2fa-token-relay.spec.ts @@ -0,0 +1,126 @@ +/** + * POST /api/auth/login/2fa — the token-relay contract with the login page. + * + * The login page does not talk to the browser's cookie jar. It calls the API + * in-process through the BFF client, and that client does not forward the + * API's Set-Cookie. The only way the page can open a session is to receive the + * JWT in the body and write its own cookie (`createSessionWithToken`). The + * password login has always honoured that: a caller sending `x-token-relay: 1` + * gets `data.token`. The second-factor endpoint did not, so a correct TOTP code + * entered on /login produced "Verification could not be completed" and no + * session — and every test passed, because the page's unit test mocked a + * `data.token` the server never sent, and the E2E spec only called the API. + * + * Driven through the REAL app (`server/index.ts`), real middleware chain, real + * keyring and an in-memory D1 (see helpers/tenant-email-harness.ts). The token + * is checked by verifying it against the deployment's own keyring, not by + * shape. The negative case — no header, no token in the body — is the rule in + * CLAUDE.md's JWT section: a browser must never be handed the JWT. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { TOTP, Secret } from 'otpauth'; +import { app } from '../../../server/index'; +import { makeExecutionContext } from '../helpers/exec-ctx'; +import * as schema from '../../../server/lib/db/schema'; +import { hashPassword } from '../../../server/lib/password'; +import { buildKeyring, verifyJwt } from '../../../server/lib/jwt-keyring'; +import { TotpService } from '../../../server/services/totp.service'; +import { createHarness, OWNER_ID, TENANT_ID, type Harness } from '../helpers/tenant-email-harness'; + +const EMAIL = 'owner@harbor-light.test'; +const PASSWORD = 'Correct-Horse-Battery-9'; + +const codeFor = (secret: string) => + new TOTP({ secret: Secret.fromBase32(secret), algorithm: 'SHA1', digits: 6, period: 30 }).generate(); + +/** + * The API's CSRF check is a stateless double-submit (cookie equals header); the + * BFF mints its own pair per request (app/lib/csrf.ts), so the test does too. + */ +const CSRF = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + +function postJson(body: Record, headers: Record = {}): RequestInit { + return { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-csrf-token': CSRF, + cookie: `__Host-csrf_token=${CSRF}`, + ...headers, + }, + body: JSON.stringify(body), + }; +} + +async function call(h: Harness, path: string, init: RequestInit): Promise { + const exec = makeExecutionContext({ label: path }); + const res = await app.request(path, init, h.env, exec.ctx); + await exec.settle(); + return res; +} + +/** Enrol the seeded owner: a real password and an active TOTP secret. */ +async function enrol(h: Harness): Promise { + const secret = new TotpService().generateSecret(); + await h.db.update(schema.users).set({ + passwordHash: await hashPassword(PASSWORD), + totpSecret: secret, + totpEnabled: true, + totpVerifiedAt: new Date(), + }).where(eq(schema.users.id, OWNER_ID)); + return secret; +} + +/** The first half of the sign-in: the password buys a challenge. */ +async function challenge(h: Harness): Promise { + const res = await call(h, '/api/auth/login', postJson({ email: EMAIL, password: PASSWORD }, { 'x-token-relay': '1' })); + const text = await res.text(); + expect(res.status, text).toBe(200); + const body = JSON.parse(text) as { data: { requires2fa?: boolean; challengeToken?: string; token?: string } }; + expect(body.data.requires2fa, 'a 2FA account gets a challenge').toBe(true); + expect(body.data.token, 'and no session before the second factor, even for the BFF').toBeUndefined(); + return body.data.challengeToken!; +} + +describe('POST /api/auth/login/2fa — token relay (standalone)', () => { + let h: Harness; + afterEach(() => h.close()); + + it('returns a verifiable session JWT in data.token to a caller sending x-token-relay: 1', { timeout: 60_000 }, async () => { + h = await createHarness('standalone'); + const secret = await enrol(h); + const challengeToken = await challenge(h); + + const res = await call(h, '/api/auth/login/2fa', postJson({ challengeToken, code: codeFor(secret) }, { 'x-token-relay': '1' })); + const text = await res.text(); + expect(res.status, text).toBe(200); + const body = JSON.parse(text) as { data: { redirect?: string; token?: string } }; + expect(body.data.redirect).toBe('/inspections'); + expect(typeof body.data.token, `the BFF needs the JWT to open a session: ${text.slice(0, 300)}`).toBe('string'); + + // Verified with the deployment's own keyring — a string that merely + // looks like a JWT would not open a session either. + const keyring = await buildKeyring(h.env as unknown as Record); + const claims = await verifyJwt(body.data.token!, keyring); + expect(claims['sub']).toBe(OWNER_ID); + expect(claims['custom:tenantId']).toBe(TENANT_ID); + expect(claims['t'], 'a session, not another challenge').toBeUndefined(); + }); + + it('does NOT put the JWT in the body for a caller without the header, and still sets the cookie', { timeout: 60_000 }, async () => { + h = await createHarness('standalone'); + const secret = await enrol(h); + const challengeToken = await challenge(h); + + const res = await call(h, '/api/auth/login/2fa', postJson({ challengeToken, code: codeFor(secret) })); + const text = await res.text(); + expect(res.status, text).toBe(200); + const body = JSON.parse(text) as { data: { redirect?: string; token?: string } }; + expect(body.data.redirect).toBe('/inspections'); + expect(body.data.token, 'a browser must never be handed the JWT').toBeUndefined(); + // Positive control for the same response: the browser path still + // gets its session, just not in the body. + expect(res.headers.get('set-cookie') ?? '').toMatch(/__Host-inspector_token=[^;]+/); + }); +}); From 54bbe7402eacf740f680765ead36753580656491 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:05 -0400 Subject: [PATCH 05/16] feat(settings): one "Test connection" button that runs a step-by-step Resend check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Email API keys the Resend "Test connection" button now runs a checklist instead of a single probe: key accepted, key permissions, sender domain verified, a real test email to the signed-in user, sending quota and delivery confirmation. The old single-shot button and its action intent are removed, so there is one control. Each step is one request through the route action (guarded submit, no client fetch); a failed step ends the run, and the last stored result per step is shown again after a reload. "Check delivery again" re-runs only the delivery step while the provider's report is still outstanding. Step rows are excluded from the one-line "Last tested" status, which keeps showing single-shot probes only. The new router is wired into the typed API client. English and Spanish strings added; three now-unused keys removed. Chrome: /settings/communication — one Test connection button on the Email API keys tab; with no key saved it runs the checklist in dark and light mode, the first step shows the failure reason and the later steps stay blank Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- .../settings/ConnectionTestStatus.tsx | 3 +- app/components/settings/EmailSecretsPanel.tsx | 20 +-- app/components/settings/EmailStepTest.tsx | 135 ++++++++++++++++++ .../settings/connection-test-status.test.ts | 2 +- app/lib/api-client.server.ts | 3 + app/lib/api-mounts.ts | 1 + app/lib/connection-test.ts | 3 + app/lib/email-test-step.server.ts | 24 ++++ app/lib/email-test-steps.test.ts | 37 +++++ app/lib/email-test-steps.ts | 42 ++++++ app/routes/settings-communication.tsx | 22 +-- messages/en/settings-components.json | 16 ++- messages/es-419/settings-components.json | 16 ++- 13 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 app/components/settings/EmailStepTest.tsx create mode 100644 app/lib/email-test-step.server.ts create mode 100644 app/lib/email-test-steps.test.ts create mode 100644 app/lib/email-test-steps.ts diff --git a/app/components/settings/ConnectionTestStatus.tsx b/app/components/settings/ConnectionTestStatus.tsx index bf57ce357..3b633d474 100644 --- a/app/components/settings/ConnectionTestStatus.tsx +++ b/app/components/settings/ConnectionTestStatus.tsx @@ -65,7 +65,8 @@ export function ConnectionTestStatus({ const mine = useMemo( () => results - .filter((r) => r.target === target) + // Step rows belong to the stepwise checklist, not the one-line status. + .filter((r) => r.target === target && r.step === null) .sort((a, b) => b.testedAt - a.testedAt), [results, target], ); diff --git a/app/components/settings/EmailSecretsPanel.tsx b/app/components/settings/EmailSecretsPanel.tsx index 78b8b3b61..782cb02ef 100644 --- a/app/components/settings/EmailSecretsPanel.tsx +++ b/app/components/settings/EmailSecretsPanel.tsx @@ -4,6 +4,7 @@ import { RadioCardGroup } from "@core/shared-ui"; import type { useFetcher } from "react-router"; import { SecretField } from "~/components/SecretField"; import { TestConnectionButton } from "~/components/settings/TestConnectionButton"; +import { EmailStepTest } from "~/components/settings/EmailStepTest"; import { ConnectionTestStatus, type ConnectionTestResult } from "~/components/settings/ConnectionTestStatus"; import type { action } from "~/routes/settings-communication"; import { m } from "~/paraglide/messages"; @@ -37,8 +38,6 @@ export function EmailSecretsPanel({ secretFieldError, secretFormError, savingEmailSecrets, - resendTestFetcher, - resendTest, emailValidateFetcher, initialProvider = "resend", webhookBaseUrl = "", @@ -59,8 +58,6 @@ export function EmailSecretsPanel({ secretFieldError: (name: string) => string | undefined; secretFormError: (intent: string) => string | null; savingEmailSecrets: boolean; - resendTestFetcher: ActionFetcher; - resendTest: ActionFetcher["data"]; emailValidateFetcher: ActionFetcher; initialProvider?: EmailByoProvider; /** Origin used to build the deliverability webhook URL hint (e.g. https://app…). */ @@ -241,19 +238,8 @@ export function EmailSecretsPanel({ - {/* Test connection — probes the STORED Resend key; shown for Resend only */} - {provider === "resend" && ( - - {resendTest && "intent" in resendTest && resendTest.intent === "test-resend" && resendTest.test && "domains" in resendTest.test && ( - - {m.settings_emailsecrets_resend_connected({ count: resendTest.test.domains })} - - )} - {resendTest && "intent" in resendTest && resendTest.intent === "test-resend" && "ok" in resendTest && !resendTest.ok && ( - {resendTest.error} - )} - - )} + {/* Test connection for Resend: key, permissions, sender domain, real test email, quota, delivery */} + {provider === "resend" && } {/* Validate credentials — generic probe for sendgrid / postmark / mailgun. The hidden `provider` input is a child of TestConnectionButton so it diff --git a/app/components/settings/EmailStepTest.tsx b/app/components/settings/EmailStepTest.tsx new file mode 100644 index 000000000..7b69d5310 --- /dev/null +++ b/app/components/settings/EmailStepTest.tsx @@ -0,0 +1,135 @@ +/** + * Stepwise Resend connection test: a checklist that runs one server step per + * request (key accepted → permissions → sender domain → real test email → + * quota → delivery). The server re-derives each step's prerequisites, so the + * order here only decides what the user sees fill in. + * + * Each step goes through the route action (`test-email-step`), never a client + * fetch. The test email always goes to the signed-in user; the form carries no + * recipient field. + */ +import { useEffect, useState } from "react"; +import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; +import { + EMAIL_TEST_STEPS, + persistedOutcomes, + type EmailTestStepId, + type StepOutcome, + type StepStatus, +} from "~/lib/email-test-steps"; +import type { ConnectionTestResult } from "~/lib/connection-test"; +import type { action } from "~/routes/settings-communication"; +import { m } from "~/paraglide/messages"; + +type Outcomes = Partial>; + +const LABEL: Record string> = { + key_valid: () => m.settings_emailsteps_key_valid(), + key_scope: () => m.settings_emailsteps_key_scope(), + sender_domain_verified: () => m.settings_emailsteps_sender_domain_verified(), + send_test: () => m.settings_emailsteps_send_test(), + quota: () => m.settings_emailsteps_quota(), + delivery: () => m.settings_emailsteps_delivery(), +}; + +const STATUS_LABEL: Record string> = { + pass: () => m.settings_emailsteps_status_pass(), + fail: () => m.settings_emailsteps_status_fail(), + skipped: () => m.settings_emailsteps_status_skipped(), + pending: () => m.settings_emailsteps_status_pending(), +}; + +const GLYPH: Record = { pass: "✓", fail: "✗", skipped: "–", pending: "…" }; +const TONE: Record = { + pass: "text-ih-ok-fg", + fail: "text-ih-bad-fg", + skipped: "text-ih-fg-3", + pending: "text-ih-fg-3", +}; + +export function EmailStepTest({ results }: { results: ConnectionTestResult[] }) { + const { fetcher, submit, busy: submitting } = useGuardedSubmit(); + const [outcomes, setOutcomes] = useState(() => persistedOutcomes(results)); + const [queue, setQueue] = useState([]); + const [active, setActive] = useState(null); + const [failed, setFailed] = useState(false); + + const start = (steps: EmailTestStepId[]) => { + const [first, ...rest] = steps; + setFailed(false); + setOutcomes((prev) => { + const next = { ...prev }; + for (const s of steps) delete next[s]; + return next; + }); + setQueue(rest); + setActive(first); + submit({ intent: "test-email-step", step: first }, { method: "post" }); + }; + + useEffect(() => { + if (fetcher.state !== "idle" || !active) return; + const d = fetcher.data; + if (!d || d.intent !== "test-email-step" || !("outcome" in d) || d.step !== active) return; + if (!d.outcome) { + setFailed(true); + setQueue([]); + setActive(null); + return; + } + const outcome = d.outcome; + setOutcomes((prev) => ({ ...prev, [active]: outcome })); + // A failed step ends the run: what follows would only repeat the same cause. + const [next, ...rest] = outcome.status === "fail" ? [] : queue; + if (next) { + setQueue(rest); + setActive(next); + submit({ intent: "test-email-step", step: next }, { method: "post" }); + } else { + setActive(null); + } + }, [fetcher.state, fetcher.data, active, queue, submit]); + + const busy = active !== null || submitting; + const btn = + "h-8 px-3 rounded-md border border-ih-border bg-ih-bg-card text-[12px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors disabled:opacity-60"; + + return ( +
+
+ + {m.settings_emailsteps_notice()} +
+ +
    + {EMAIL_TEST_STEPS.map((step) => { + const o = outcomes[step]; + const status = o?.status; + return ( +
  • + + {status ? GLYPH[status] : active === step ? "…" : "·"} + + {LABEL[step]()} + {status ? {STATUS_LABEL[status]()} : null} + {o?.detail ? ( + — {o.detail} + ) : null} + {step === "delivery" && status === "pending" && !busy ? ( + + ) : null} +
  • + ); + })} +
+ {failed ? {m.settings_emailsteps_error()} : null} +
+ ); +} diff --git a/app/components/settings/connection-test-status.test.ts b/app/components/settings/connection-test-status.test.ts index efd8c4483..53bf9d1d4 100644 --- a/app/components/settings/connection-test-status.test.ts +++ b/app/components/settings/connection-test-status.test.ts @@ -50,7 +50,7 @@ afterEach(() => { function row(over: Partial): ConnectionTestResult { return { target: 'sms', provider: null, ok: true, detail: null, - testedByUserId: null, testedAt: NOW - MIN, ...over, + testedByUserId: null, testedAt: NOW - MIN, step: null, ...over, }; } diff --git a/app/lib/api-client.server.ts b/app/lib/api-client.server.ts index 33493c1eb..19a2fee64 100644 --- a/app/lib/api-client.server.ts +++ b/app/lib/api-client.server.ts @@ -37,6 +37,7 @@ import type { InspectionTagApi, IntegrationsApi, IntegrationsAiApi, + IntegrationsTestStepsApi, InvoicesApi, MarketplaceApi, McpGrantsApi, @@ -170,6 +171,7 @@ export interface Api { inspectionTag: ReturnType>; integrations: ReturnType>; integrationsAi: ReturnType>; + integrationsTestSteps: ReturnType>; imports: ReturnType>; invoices: ReturnType>; marketplace: ReturnType>; @@ -278,6 +280,7 @@ export function createApi(context: LoadContext, opts: CreateApiOptions = {}): Ap inspectionTag: mk(MOUNT.inspectionTag), integrations: mk(MOUNT.integrations), integrationsAi: mk(MOUNT.integrationsAi), + integrationsTestSteps: mk(MOUNT.integrationsTestSteps), imports: mk(MOUNT.imports), invoices: mk(MOUNT.invoices), marketplace: mk(MOUNT.marketplace), diff --git a/app/lib/api-mounts.ts b/app/lib/api-mounts.ts index 2d01fd9c5..521ccf99d 100644 --- a/app/lib/api-mounts.ts +++ b/app/lib/api-mounts.ts @@ -50,6 +50,7 @@ export const MOUNT: Record = { inspectionTag: "/api/inspections", integrations: "/api/integrations", integrationsAi: "/api/integrations/ai", + integrationsTestSteps: "/api/integrations/test-steps", imports: "/api/imports", invoices: "/api/invoices", marketplace: "/api/templates/marketplace", diff --git a/app/lib/connection-test.ts b/app/lib/connection-test.ts index 7e4467b21..fc13c852c 100644 --- a/app/lib/connection-test.ts +++ b/app/lib/connection-test.ts @@ -13,6 +13,8 @@ export interface ConnectionTestResult { detail: string | null; testedByUserId: string | null; testedAt: number; // epoch ms + /** Step id of a stepwise test row; null for single-shot probes. */ + step: string | null; } const TARGETS = new Set(["sms", "email", "stripe", "gemini"]); @@ -38,5 +40,6 @@ export async function parseTestResults( detail: typeof r.detail === "string" ? r.detail : null, testedByUserId: typeof r.testedByUserId === "string" ? r.testedByUserId : null, testedAt: typeof r.testedAt === "number" ? r.testedAt : 0, + step: typeof r.step === "string" ? r.step : null, })); } diff --git a/app/lib/email-test-step.server.ts b/app/lib/email-test-step.server.ts new file mode 100644 index 000000000..c415e8e8f --- /dev/null +++ b/app/lib/email-test-step.server.ts @@ -0,0 +1,24 @@ +import type { createApi } from "~/lib/api-client.server"; +import { isEmailTestStepId } from "~/lib/email-test-steps"; +import { m } from "~/paraglide/messages"; + +/** + * Runs one step of the stepwise email test for the settings action. A step that + * ran but failed is still `ok` (the outcome says how it went); `outcome: null` + * means the request itself failed (unknown step, rate limit, transport). + */ +export async function runEmailTestStepIntent(api: ReturnType, intent: string, rawStep: unknown) { + if (!isEmailTestStepId(rawStep)) { + return { intent, ok: false, error: m.settings_emailsteps_error(), step: null, outcome: null }; + } + const res = await api.integrationsTestSteps.email.$post({ json: { step: rawStep } }); + const body = res.ok ? await res.json().catch(() => null) : null; + const data = body && "data" in body ? body.data : null; + return { + intent, + ok: Boolean(data), + error: data ? null : m.settings_emailsteps_error(), + step: rawStep, + outcome: data ? { status: data.status, detail: data.detail ?? null } : null, + }; +} diff --git a/app/lib/email-test-steps.test.ts b/app/lib/email-test-steps.test.ts new file mode 100644 index 000000000..b2f0e0f7b --- /dev/null +++ b/app/lib/email-test-steps.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { isEmailTestStepId, persistedOutcomes } from "./email-test-steps"; + +describe("persistedOutcomes", () => { + const row = (over: Record) => ({ + target: "email", step: "key_valid", ok: true, detail: null, testedAt: 1, ...over, + }) as Parameters[0][number]; + + it("keeps the newest result per step", () => { + const out = persistedOutcomes([ + row({ step: "key_valid", ok: false, detail: "old", testedAt: 1 }), + row({ step: "key_valid", ok: true, detail: "new", testedAt: 2 }), + ]); + expect(out.key_valid).toEqual({ status: "pass", detail: "new" }); + }); + + it("ignores single-shot rows, other targets and unknown steps", () => { + const out = persistedOutcomes([ + row({ step: null }), + row({ target: "stripe", step: "key_valid" }), + row({ step: "not_a_step" }), + ]); + expect(out).toEqual({}); + }); + + it("maps a failed row to fail", () => { + expect(persistedOutcomes([row({ ok: false, detail: "bad" })]).key_valid).toEqual({ status: "fail", detail: "bad" }); + }); +}); + +describe("isEmailTestStepId", () => { + it("accepts a known id and rejects everything else", () => { + expect(isEmailTestStepId("send_test")).toBe(true); + expect(isEmailTestStepId("send_test ")).toBe(false); + expect(isEmailTestStepId(null)).toBe(false); + }); +}); diff --git a/app/lib/email-test-steps.ts b/app/lib/email-test-steps.ts new file mode 100644 index 000000000..6bf55a969 --- /dev/null +++ b/app/lib/email-test-steps.ts @@ -0,0 +1,42 @@ +/** + * Client-safe description of the stepwise email connection test: the ordered + * step ids and the shape of one step's outcome. The server owns the logic + * (`server/lib/integration/email-test-steps.ts`); this file only names things. + */ +export const EMAIL_TEST_STEPS = [ + "key_valid", + "key_scope", + "sender_domain_verified", + "send_test", + "quota", + "delivery", +] as const; + +export type EmailTestStepId = (typeof EMAIL_TEST_STEPS)[number]; + +export type StepStatus = "pass" | "fail" | "skipped" | "pending"; + +export interface StepOutcome { + status: StepStatus; + detail: string | null; +} + +export function isEmailTestStepId(v: unknown): v is EmailTestStepId { + return typeof v === "string" && (EMAIL_TEST_STEPS as readonly string[]).includes(v); +} + +/** + * Outcomes the server already stored (pass/fail only; skipped and pending are + * never recorded), newest first per step, so the checklist survives a reload. + */ +export function persistedOutcomes( + rows: ReadonlyArray<{ target: string; step: string | null; ok: boolean; detail: string | null; testedAt: number }>, +): Partial> { + const out: Partial> = {}; + const newestFirst = [...rows].sort((a, b) => b.testedAt - a.testedAt); + for (const r of newestFirst) { + if (r.target !== "email" || !isEmailTestStepId(r.step) || out[r.step]) continue; + out[r.step] = { status: r.ok ? "pass" : "fail", detail: r.detail }; + } + return out; +} diff --git a/app/routes/settings-communication.tsx b/app/routes/settings-communication.tsx index 7954f24aa..ecb2660a3 100644 --- a/app/routes/settings-communication.tsx +++ b/app/routes/settings-communication.tsx @@ -16,6 +16,7 @@ import { requireAdminLoader } from "~/lib/access.server"; import { AccessDenied } from "~/components/AccessDenied"; import { EmailDeliveryPanel } from "~/components/settings/EmailDeliveryPanel"; import { EmailSecretsPanel } from "~/components/settings/EmailSecretsPanel"; +import { runEmailTestStepIntent } from "~/lib/email-test-step.server"; import { SmsDeliveryPanel, type SmsModeValue } from "~/components/settings/SmsDeliveryPanel"; import { GoogleCalendarPanel } from "~/components/settings/GoogleCalendarPanel"; import { ManagedComplianceWizard, type ManagedComplianceData } from "~/components/settings/ManagedComplianceWizard"; @@ -254,22 +255,7 @@ export async function action({ request, context }: Route.ActionArgs) { return saveSecrets(api, intent, body, m.settings_comms_email_secrets_save_error()); } - if (intent === "test-resend") { - const res = await api.integrations.resend.test.$post(); - const body = (await res.json().catch(() => null)) as - | { data?: { domains: number }; error?: { message?: string } } - | null; - if (!res.ok || !body?.data) { - return { - intent, - ok: false, - error: body?.error?.message ?? m.settings_connection_test_failed(), - field: null, - test: null, - }; - } - return { intent, ok: true, error: null, field: null, test: body.data }; - } + if (intent === "test-email-step") return runEmailTestStepIntent(api, intent, form.get("step")); if (intent === "validate-email-provider") { const VALID_PROVIDERS = ["resend", "sendgrid", "postmark", "mailgun"] as const; @@ -498,7 +484,6 @@ export default function SettingsCommunication() { : loaderResult.compliance; const actionData = useActionData(); const nav = useNavigation(); - const resendTestFetcher = useFetcher(); const emailValidateFetcher = useFetcher(); const smsTestFetcher = useFetcher(); const session = useSessionContext(); @@ -604,7 +589,6 @@ export default function SettingsCommunication() { return null; }; - const resendTest = resendTestFetcher.data; // Whether the tenant's OWN selected email provider has its credentials saved // (drives the Email delivery panel's provider-aware guardrail + status copy). @@ -669,8 +653,6 @@ export default function SettingsCommunication() { secretFieldError={secretFieldError} secretFormError={secretFormError} savingEmailSecrets={savingEmailSecrets} - resendTestFetcher={resendTestFetcher} - resendTest={resendTest} emailValidateFetcher={emailValidateFetcher} initialProvider={emailByoProvider} webhookBaseUrl={typeof window !== "undefined" ? window.location.origin : ""} diff --git a/messages/en/settings-components.json b/messages/en/settings-components.json index 6b066d3f2..a6bac6d4c 100644 --- a/messages/en/settings-components.json +++ b/messages/en/settings-components.json @@ -426,7 +426,6 @@ "settings_emailsecrets_deliverability_desc": "Configure {provider} to send bounce & spam-complaint events here so suppressed addresses are never emailed again.", "settings_emailsecrets_webhook_url_label": "Webhook URL", "settings_emailsecrets_save": "Save {provider} key(s)", - "settings_emailsecrets_resend_connected": "Connected — {count} verified domain(s)", "settings_emailsecrets_validate_idle": "Validate credentials", "settings_emailsecrets_validate_busy": "Validating…", "settings_emailsecrets_creds_verified": "Credentials verified.", @@ -696,5 +695,18 @@ "settings_isn_keys_hint": "In ISN: Settings → My Access Keys. The keys carry the full permissions of the ISN user who owns them.", "settings_isn_access_key_label": "Access key", "settings_isn_secret_key_label": "Secret access key", - "settings_isn_save_error": "Could not save the ISN settings." + "settings_isn_save_error": "Could not save the ISN settings.", + "settings_emailsteps_notice": "Sends one test email to your own address.", + "settings_emailsteps_recheck": "Check delivery again", + "settings_emailsteps_error": "The test could not be run. Try again in a minute.", + "settings_emailsteps_key_valid": "API key accepted", + "settings_emailsteps_key_scope": "Key permissions", + "settings_emailsteps_sender_domain_verified": "Sender domain verified", + "settings_emailsteps_send_test": "Test email sent to you", + "settings_emailsteps_quota": "Sending quota", + "settings_emailsteps_delivery": "Delivery confirmed", + "settings_emailsteps_status_pass": "Passed", + "settings_emailsteps_status_fail": "Failed", + "settings_emailsteps_status_skipped": "Skipped", + "settings_emailsteps_status_pending": "Waiting" } diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index cc5cc80e9..a48d33748 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -426,7 +426,6 @@ "settings_emailsecrets_deliverability_desc": "Configure {provider} para que envíe aquí los eventos de rebote y de queja por spam, de modo que nunca se vuelva a escribir a las direcciones suprimidas.", "settings_emailsecrets_webhook_url_label": "URL del webhook", "settings_emailsecrets_save": "Guardar la(s) clave(s) de {provider}", - "settings_emailsecrets_resend_connected": "Conectado — {count} dominio(s) verificado(s)", "settings_emailsecrets_validate_idle": "Validar las credenciales", "settings_emailsecrets_validate_busy": "Validando…", "settings_emailsecrets_creds_verified": "Credenciales verificadas.", @@ -696,5 +695,18 @@ "settings_isn_keys_hint": "En ISN: Settings → My Access Keys. Las claves tienen todos los permisos del usuario de ISN al que pertenecen.", "settings_isn_access_key_label": "Clave de acceso", "settings_isn_secret_key_label": "Clave de acceso secreta", - "settings_isn_save_error": "No se pudo guardar la configuración de ISN." + "settings_isn_save_error": "No se pudo guardar la configuración de ISN.", + "settings_emailsteps_notice": "Envía un correo de prueba a tu propia dirección.", + "settings_emailsteps_recheck": "Volver a comprobar la entrega", + "settings_emailsteps_error": "No se pudo ejecutar la prueba. Inténtalo de nuevo en un minuto.", + "settings_emailsteps_key_valid": "Clave de API aceptada", + "settings_emailsteps_key_scope": "Permisos de la clave", + "settings_emailsteps_sender_domain_verified": "Dominio del remitente verificado", + "settings_emailsteps_send_test": "Correo de prueba enviado a ti", + "settings_emailsteps_quota": "Cuota de envío", + "settings_emailsteps_delivery": "Entrega confirmada", + "settings_emailsteps_status_pass": "Correcto", + "settings_emailsteps_status_fail": "Falló", + "settings_emailsteps_status_skipped": "Omitido", + "settings_emailsteps_status_pending": "En espera" } From 8421d4540677de7ce347f0129ef410945beb35f9 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:04:38 -0400 Subject: [PATCH 06/16] fix(auth): give /login its own response schema; correct the token-relay rule POST /api/auth/login shared AuthResponseSchema with /join and /setup, which declares only { redirect }. The login handler also returns token (to a caller sending x-token-relay: 1) and the second-factor challenge, so the declared shape was wrong for login, and the page test could only hand-write a mock the schema never described. - LoginResponseSchema (login only): { redirect, token? } or { requires2fa, challengeToken }. AuthResponseSchema stays as is for join and setup, which never put the JWT in the body. - The page test builds every password-login mock through LoginResponseSchema and carries token only when the call sent the relay header, like the second-factor helper. Dropping the header from the page now fails it. - login-2fa-token-relay.spec.ts covers the password step against the real handler: token with the header (verified against the keyring), none without, cookie set either way, bodies parsed with the declared schema. - CLAUDE.md and docs/reference/api.md said setup and join use the relay. They do not: their pages read the session JWT from the Set-Cookie header of the in-process API response. Only login (both steps) relays the token in the body, and only for x-token-relay: 1. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012dwrjSYhhqbh7Zd9iy2NTq --- CLAUDE.md | 2 +- app/routes/login.twofactor.server.test.ts | 30 +++++++++-- docs/reference/api.md | 15 ++++-- server/api/auth.ts | 4 +- server/lib/validations/auth.schema.ts | 24 ++++++++- tests/unit/auth/login-2fa-token-relay.spec.ts | 50 ++++++++++++++++++- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c9e51013..4d7de616b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,7 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls - **iat claim**: `signJwt()` auto-injects `iat: Math.floor(Date.now() / 1000)` when the caller omits it. Without `iat`, KV session invalidation (`pwchanged:{userId}`) cannot work. - **No HS256 fallback**: There is NO legacy HS256 path. Pre-launch architectural choice — see rotation scripts and docs. The remaining `JWT_SECRET` env binding is KDF input only, never a JWT signing key. State it that way rather than listing its consumers: there are dozens of files reading it (secret crypto, signed-URL and session HMACs, token derivation), and every enumeration written here has gone stale. - **Key rotation flow**: To rotate, provision `JWT_PRIVATE_KEY_V` + `JWT_PUBLIC_KEY_V` first (verify-only window), then flip `JWT_CURRENT_KID` to the new version. Old tokens remain verifiable until V is retired. -- **Token NOT in response body — one carved-out exception.** Login (both the password step and the second-factor step, `POST /api/auth/login/2fa`), setup and join MUST NOT return the JWT to a browser; delivery is `Set-Cookie` (HttpOnly). The exception is the in-process BFF, which identifies itself with `x-token-relay: 1` and then receives `token` in the body, because Workers `fetch()` may strip `Set-Cookie` on a server-to-server hop and the BFF has to write its own session cookie. A browser never sends that header. Do not add a second exception without one here. +- **Token NOT in response body — one carved-out exception.** No auth endpoint returns the JWT to a browser; delivery is `Set-Cookie` (HttpOnly). The exception is login only — both the password step (`POST /api/auth/login`) and the second-factor step (`POST /api/auth/login/2fa`): a caller sending `x-token-relay: 1` also receives `token` in the body, because the in-process BFF client does not forward the API's `Set-Cookie` to the browser and the login page has to write its own session cookie (`LoginResponseSchema` / `Login2faResponseSchema` declare it). A browser never sends that header. Setup and join are NOT part of the exception: they never put the JWT in the body, whatever the headers, and their pages read the session JWT out of the `Set-Cookie` header on the in-process API response instead. Do not add a second exception without one here. - **Cookie name**: Always use `__Host-inspector_token` (enforces `Secure`, `Path=/`, no `Domain`). - **setCookie attributes**: Every `setCookie()` MUST include `httpOnly: true, secure: true, sameSite: 'Strict', path: '/'`. - **deleteCookie secure**: Every `deleteCookie()` MUST include `{ path: '/', secure: true }`. Omitting `secure` on `__Host-` cookies throws a runtime exception. diff --git a/app/routes/login.twofactor.server.test.ts b/app/routes/login.twofactor.server.test.ts index 56c2ec2a5..ad1d7e04d 100644 --- a/app/routes/login.twofactor.server.test.ts +++ b/app/routes/login.twofactor.server.test.ts @@ -45,7 +45,7 @@ vi.mock("../../server/lib/deployment-profile", () => ({ import { action } from "./login"; import { routeArgs } from "../../tests/helpers/route-args"; -import { Login2faResponseSchema } from "../../server/lib/validations/auth.schema"; +import { Login2faResponseSchema, LoginResponseSchema } from "../../server/lib/validations/auth.schema"; const CONTEXT = {} as Parameters[0]["context"]; const json = (body: unknown, status = 200) => @@ -80,6 +80,27 @@ function twoFaSuccess(token: string) { }; } +/** + * What `POST /api/auth/login` answers a correct password with, built the same + * way: through `LoginResponseSchema`, carrying the session token only when the + * call identified itself as the BFF (the handler's rule, held to it by + * tests/unit/auth/login-2fa-token-relay.spec.ts). A page that stopped sending the + * header would get no token here, exactly as it would get none from the API. + */ +function loginSuccess(token: string) { + return (_args: unknown, options?: { headers?: Record }) => { + const relay = options?.headers?.["x-token-relay"] === "1"; + return Promise.resolve(json(LoginResponseSchema.parse({ + success: true, + data: { redirect: "/inspections", ...(relay ? { token } : {}) }, + }))); + }; +} + +/** The password step's answer for a 2FA account, through the same schema. */ +const challengeReply = (challengeToken: string) => + json(LoginResponseSchema.parse({ success: true, data: { requires2fa: true, challengeToken } })); + const CHALLENGE = "challenge.jwt.value"; const GOOD = { email: "a@b.test", password: "hunter2hunter2" }; @@ -90,14 +111,15 @@ beforeEach(() => { describe("login — a 2FA account is handed a challenge, not a refusal", () => { it("CONTROL — a non-2FA login still mints a session, so the branch below is the only change", async () => { - loginPost.mockResolvedValue(json({ data: { token: "session.jwt" } })); + loginPost.mockImplementation(loginSuccess("session.jwt")); await post(GOOD); expect(createSession).toHaveBeenCalledTimes(1); + expect(createSession).toHaveBeenCalledWith(CONTEXT, "session.jwt", "/inspections"); expect(twoFaPost).not.toHaveBeenCalled(); }); it("returns the challenge token instead of an error", async () => { - loginPost.mockResolvedValue(json({ data: { requires2fa: true, challengeToken: CHALLENGE } })); + loginPost.mockResolvedValue(challengeReply(CHALLENGE)); const out = await post(GOOD); // Not a session: the second factor has not been supplied yet. expect(createSession).not.toHaveBeenCalled(); @@ -108,7 +130,7 @@ describe("login — a 2FA account is handed a challenge, not a refusal", () => { // The OAuth consent bounce resumes on the far side of the code form. A // version that dropped it would send 2FA users somewhere else than // everyone else, only on the flow that has a destination. - loginPost.mockResolvedValue(json({ data: { requires2fa: true, challengeToken: CHALLENGE } })); + loginPost.mockResolvedValue(challengeReply(CHALLENGE)); const out = await post({ ...GOOD, returnTo: "/oauth/authorize?x=1" }); expect(out).toMatchObject({ returnTo: "/oauth/authorize?x=1" }); }); diff --git a/docs/reference/api.md b/docs/reference/api.md index 2665c687d..2f650c31b 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -195,9 +195,11 @@ The JWT is delivered **only** as the `__Host-inspector_token` HttpOnly cookie. It is not in the body, and browser JavaScript must never store a token — `CLAUDE.md` § JWT & Auth Security Rules makes both of those rules. The one exception is the server-side BFF: a caller sending `x-token-relay: 1` also gets -`token` in `data`, because Workers `fetch()` may strip `Set-Cookie` on a -server-to-server hop and the BFF has to put the token in its own session cookie. -The browser never sends that header. +`token` in `data`, because the in-process BFF client does not forward the API's +`Set-Cookie` to the browser, and the login page has to put the token in its own +session cookie. The browser never sends that header. Only login (this step and +`POST /api/auth/login/2fa`) honours it; join and setup never return the token in +the body (see below). **If the user has 2FA enabled, no session is granted here.** The response is `{ "success": true, "data": { "requires2fa": true, "challengeToken": "…" } }` @@ -231,8 +233,11 @@ Accept a team invite token, create the user account, and set the `__Host-inspect { "success": true, "data": { "redirect": "/inspections" } } ``` -Sets the `__Host-inspector_token` HttpOnly cookie. As with login, the JWT is not -returned to a browser and must not be stored in `localStorage`. +Sets the `__Host-inspector_token` HttpOnly cookie. The JWT is never in the +body — unlike login, this route ignores `x-token-relay`, and so does +`POST /api/auth/setup`. Their pages open a session by reading the JWT out of the +`Set-Cookie` header on the in-process API response. It must not be stored in +`localStorage`. Returns `400` if the token is expired or already used. diff --git a/server/api/auth.ts b/server/api/auth.ts index ae6acb1a7..d21fa646e 100644 --- a/server/api/auth.ts +++ b/server/api/auth.ts @@ -17,7 +17,7 @@ import { JoinTeamSchema, ForgotPasswordSchema, ResetPasswordSchema, - AuthResponseSchema, + AuthResponseSchema, LoginResponseSchema, SetupSchema, } from '../lib/validations/auth.schema'; import { createApiResponseSchema, SuccessResponseSchema } from '../lib/validations/shared.schema'; @@ -50,7 +50,7 @@ const loginRoute = createRoute(withMcpMetadata({ responses: { 200: { content: { - 'application/json': { schema: AuthResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } + 'application/json': { schema: LoginResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Login successful' }, diff --git a/server/lib/validations/auth.schema.ts b/server/lib/validations/auth.schema.ts index 093f7354f..af389d514 100644 --- a/server/lib/validations/auth.schema.ts +++ b/server/lib/validations/auth.schema.ts @@ -16,7 +16,29 @@ export const LoginSchema = z.object({ }); /** - * Validation schema for the login response. + * Response of `POST /api/auth/login` — the password step only. + * + * Two shapes: a session (`redirect`, plus `token` for the in-process BFF that + * sends `x-token-relay: 1`), or a second-factor challenge when the account has + * TOTP enabled. Join and setup keep `AuthResponseSchema`: they never put the + * JWT in the body, so declaring `token` there would document a field nothing + * sends. + */ +export const LoginResponseSchema = createApiResponseSchema(z.union([ + z.object({ + redirect: z.string().openapi({ example: '/inspections' }).describe('Path the client navigates to once the session cookie is set.'), + token: z.string().optional().describe('Session JWT. Present ONLY for the in-process BFF caller that sends `x-token-relay: 1`; browsers receive the session as the HttpOnly cookie and never in the body.'), + }), + z.object({ + requires2fa: z.literal(true).describe('The password was correct and the account has TOTP enabled; no session has been granted yet.'), + challengeToken: z.string().describe('Five-minute challenge to post back with the TOTP code to `POST /api/auth/login/2fa`.'), + }), +])); + +/** + * Response of `POST /api/auth/join` and `POST /api/auth/setup`. The session is + * delivered only as the HttpOnly cookie; neither route returns the JWT in the + * body, whatever headers the caller sends. */ export const AuthResponseSchema = createApiResponseSchema( z.object({ diff --git a/tests/unit/auth/login-2fa-token-relay.spec.ts b/tests/unit/auth/login-2fa-token-relay.spec.ts index 1c21c9c9c..f215fff89 100644 --- a/tests/unit/auth/login-2fa-token-relay.spec.ts +++ b/tests/unit/auth/login-2fa-token-relay.spec.ts @@ -1,5 +1,5 @@ /** - * POST /api/auth/login/2fa — the token-relay contract with the login page. + * POST /api/auth/login and /login/2fa — the token-relay contract with the login page. * * The login page does not talk to the browser's cookie jar. It calls the API * in-process through the BFF client, and that client does not forward the @@ -27,6 +27,7 @@ import { hashPassword } from '../../../server/lib/password'; import { buildKeyring, verifyJwt } from '../../../server/lib/jwt-keyring'; import { TotpService } from '../../../server/services/totp.service'; import { createHarness, OWNER_ID, TENANT_ID, type Harness } from '../helpers/tenant-email-harness'; +import { LoginResponseSchema } from '../../../server/lib/validations/auth.schema'; const EMAIL = 'owner@harbor-light.test'; const PASSWORD = 'Correct-Horse-Battery-9'; @@ -124,3 +125,50 @@ describe('POST /api/auth/login/2fa — token relay (standalone)', () => { expect(res.headers.get('set-cookie') ?? '').toMatch(/__Host-inspector_token=[^;]+/); }); }); + +/** + * The password step for an account WITHOUT a second factor: the same rule, on + * the route whose response schema used to be shared with join and setup and so + * did not declare `token` at all. Each body is parsed with the route's own + * declared schema, so the documented shape and the handler cannot drift apart + * without one of these failing. + */ +describe('POST /api/auth/login — token relay (standalone, no second factor)', () => { + let h: Harness; + afterEach(() => h.close()); + + async function withPassword(): Promise { + h = await createHarness('standalone'); + await h.db.update(schema.users) + .set({ passwordHash: await hashPassword(PASSWORD), totpEnabled: false }) + .where(eq(schema.users.id, OWNER_ID)); + } + + it('returns a verifiable session JWT in data.token to a caller sending x-token-relay: 1', { timeout: 60_000 }, async () => { + await withPassword(); + const res = await call(h, '/api/auth/login', postJson({ email: EMAIL, password: PASSWORD }, { 'x-token-relay': '1' })); + const text = await res.text(); + expect(res.status, text).toBe(200); + const body = LoginResponseSchema.parse(JSON.parse(text)); + expect(body.data, text.slice(0, 300)).toHaveProperty('redirect', '/inspections'); + const token = (body.data as { token?: string }).token; + expect(typeof token, `the BFF needs the JWT to open a session: ${text.slice(0, 300)}`).toBe('string'); + + const keyring = await buildKeyring(h.env as unknown as Record); + const claims = await verifyJwt(token!, keyring); + expect(claims['sub']).toBe(OWNER_ID); + expect(claims['custom:tenantId']).toBe(TENANT_ID); + }); + + it('does NOT put the JWT in the body for a caller without the header, and still sets the cookie', { timeout: 60_000 }, async () => { + await withPassword(); + const res = await call(h, '/api/auth/login', postJson({ email: EMAIL, password: PASSWORD })); + const text = await res.text(); + expect(res.status, text).toBe(200); + const raw = JSON.parse(text) as { data: Record }; + // Asserted on the raw body, before any schema parse touches it. + expect(raw.data, 'a browser must never be handed the JWT').not.toHaveProperty('token'); + expect(LoginResponseSchema.parse(raw).data).toEqual({ redirect: '/inspections' }); + expect(res.headers.get('set-cookie') ?? '').toMatch(/__Host-inspector_token=[^;]+/); + }); +}); From 1bbaf4daa649669477f3f73b527950eedeb94198 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:16:56 -0400 Subject: [PATCH 07/16] feat(integrations): step-by-step Stripe connection test behind one Test connection button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalise the email step checklist into a shared StepChecklist and add a Stripe checklist (secret key accepted, account can take payments, webhook delivery verified). The Resend panel now has a single Test connection button that runs its checklist as well; the old single-shot Stripe test intent and its button are removed. The key check reports whether the key is live or test mode. Read-only: nothing is created in the Stripe account. Chrome: /settings/integrations — one Test connection button on the Stripe panel; with no key saved the first step fails and the later steps stay blank in dark and light mode Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- app/components/settings/EmailStepTest.tsx | 133 ++--------------- app/components/settings/StepChecklist.tsx | 141 ++++++++++++++++++ app/components/settings/StripeStepTest.tsx | 23 +++ .../integrations/StripePaymentsPanel.tsx | 27 +--- app/lib/email-test-step.server.ts | 24 --- app/lib/email-test-steps.test.ts | 37 ----- app/lib/email-test-steps.ts | 42 ------ app/lib/test-step.server.ts | 35 +++++ app/lib/test-steps.test.ts | 43 ++++++ app/lib/test-steps.ts | 52 +++++++ app/routes/settings-communication.tsx | 2 +- app/routes/settings-integrations.tsx | 24 +-- messages/en/settings-components.json | 18 +-- messages/es-419/settings-components.json | 18 +-- server/api/integrations-test-steps.ts | 5 +- server/lib/integration/stripe-test-steps.ts | 4 +- .../integrations/email-test-steps.spec.ts | 4 + 17 files changed, 346 insertions(+), 286 deletions(-) create mode 100644 app/components/settings/StepChecklist.tsx create mode 100644 app/components/settings/StripeStepTest.tsx delete mode 100644 app/lib/email-test-step.server.ts delete mode 100644 app/lib/email-test-steps.test.ts delete mode 100644 app/lib/email-test-steps.ts create mode 100644 app/lib/test-step.server.ts create mode 100644 app/lib/test-steps.test.ts create mode 100644 app/lib/test-steps.ts diff --git a/app/components/settings/EmailStepTest.tsx b/app/components/settings/EmailStepTest.tsx index 7b69d5310..3a48e1cfb 100644 --- a/app/components/settings/EmailStepTest.tsx +++ b/app/components/settings/EmailStepTest.tsx @@ -1,29 +1,9 @@ -/** - * Stepwise Resend connection test: a checklist that runs one server step per - * request (key accepted → permissions → sender domain → real test email → - * quota → delivery). The server re-derives each step's prerequisites, so the - * order here only decides what the user sees fill in. - * - * Each step goes through the route action (`test-email-step`), never a client - * fetch. The test email always goes to the signed-in user; the form carries no - * recipient field. - */ -import { useEffect, useState } from "react"; -import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; -import { - EMAIL_TEST_STEPS, - persistedOutcomes, - type EmailTestStepId, - type StepOutcome, - type StepStatus, -} from "~/lib/email-test-steps"; +import { StepChecklist } from "~/components/settings/StepChecklist"; import type { ConnectionTestResult } from "~/lib/connection-test"; -import type { action } from "~/routes/settings-communication"; +import { EMAIL_TEST_STEPS } from "~/lib/test-steps"; import { m } from "~/paraglide/messages"; -type Outcomes = Partial>; - -const LABEL: Record string> = { +const LABEL: Record<(typeof EMAIL_TEST_STEPS)[number], () => string> = { key_valid: () => m.settings_emailsteps_key_valid(), key_scope: () => m.settings_emailsteps_key_scope(), sender_domain_verified: () => m.settings_emailsteps_sender_domain_verified(), @@ -32,104 +12,17 @@ const LABEL: Record string> = { delivery: () => m.settings_emailsteps_delivery(), }; -const STATUS_LABEL: Record string> = { - pass: () => m.settings_emailsteps_status_pass(), - fail: () => m.settings_emailsteps_status_fail(), - skipped: () => m.settings_emailsteps_status_skipped(), - pending: () => m.settings_emailsteps_status_pending(), -}; - -const GLYPH: Record = { pass: "✓", fail: "✗", skipped: "–", pending: "…" }; -const TONE: Record = { - pass: "text-ih-ok-fg", - fail: "text-ih-bad-fg", - skipped: "text-ih-fg-3", - pending: "text-ih-fg-3", -}; - +/** Resend "Test connection": six steps; the test email always goes to the signed-in user. */ export function EmailStepTest({ results }: { results: ConnectionTestResult[] }) { - const { fetcher, submit, busy: submitting } = useGuardedSubmit(); - const [outcomes, setOutcomes] = useState(() => persistedOutcomes(results)); - const [queue, setQueue] = useState([]); - const [active, setActive] = useState(null); - const [failed, setFailed] = useState(false); - - const start = (steps: EmailTestStepId[]) => { - const [first, ...rest] = steps; - setFailed(false); - setOutcomes((prev) => { - const next = { ...prev }; - for (const s of steps) delete next[s]; - return next; - }); - setQueue(rest); - setActive(first); - submit({ intent: "test-email-step", step: first }, { method: "post" }); - }; - - useEffect(() => { - if (fetcher.state !== "idle" || !active) return; - const d = fetcher.data; - if (!d || d.intent !== "test-email-step" || !("outcome" in d) || d.step !== active) return; - if (!d.outcome) { - setFailed(true); - setQueue([]); - setActive(null); - return; - } - const outcome = d.outcome; - setOutcomes((prev) => ({ ...prev, [active]: outcome })); - // A failed step ends the run: what follows would only repeat the same cause. - const [next, ...rest] = outcome.status === "fail" ? [] : queue; - if (next) { - setQueue(rest); - setActive(next); - submit({ intent: "test-email-step", step: next }, { method: "post" }); - } else { - setActive(null); - } - }, [fetcher.state, fetcher.data, active, queue, submit]); - - const busy = active !== null || submitting; - const btn = - "h-8 px-3 rounded-md border border-ih-border bg-ih-bg-card text-[12px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors disabled:opacity-60"; - return ( -
-
- - {m.settings_emailsteps_notice()} -
- -
    - {EMAIL_TEST_STEPS.map((step) => { - const o = outcomes[step]; - const status = o?.status; - return ( -
  • - - {status ? GLYPH[status] : active === step ? "…" : "·"} - - {LABEL[step]()} - {status ? {STATUS_LABEL[status]()} : null} - {o?.detail ? ( - — {o.detail} - ) : null} - {step === "delivery" && status === "pending" && !busy ? ( - - ) : null} -
  • - ); - })} -
- {failed ? {m.settings_emailsteps_error()} : null} -
+ LABEL[s as keyof typeof LABEL]()} + results={results} + notice={m.settings_emailsteps_notice()} + recheckStep="delivery" + /> ); } diff --git a/app/components/settings/StepChecklist.tsx b/app/components/settings/StepChecklist.tsx new file mode 100644 index 000000000..2dc9d7ba0 --- /dev/null +++ b/app/components/settings/StepChecklist.tsx @@ -0,0 +1,141 @@ +/** + * Stepwise "Test connection": a checklist that runs one server step per request. + * The server re-derives each step's prerequisites, so the order here only + * decides what the user sees fill in. Used by the Resend panel (six steps) and + * the Stripe panel (three read-only steps). + * + * Each step goes through the route action (`intent`), never a client fetch. A + * failed step ends the run: what follows would only repeat the same cause. + */ +import { useEffect, useState } from "react"; +import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; +import { persistedOutcomes, type StepOutcome, type StepStatus } from "~/lib/test-steps"; +import type { ConnectionTestResult } from "~/lib/connection-test"; +import { m } from "~/paraglide/messages"; + +/** What the route action returns for one step (see `app/lib/test-step.server.ts`). */ +interface StepActionData { + intent?: string; + step?: string | null; + outcome?: StepOutcome | null; +} + +const STATUS_LABEL: Record string> = { + pass: () => m.settings_teststeps_status_pass(), + fail: () => m.settings_teststeps_status_fail(), + skipped: () => m.settings_teststeps_status_skipped(), + pending: () => m.settings_teststeps_status_pending(), +}; + +const GLYPH: Record = { pass: "✓", fail: "✗", skipped: "–", pending: "…" }; +const TONE: Record = { + pass: "text-ih-ok-fg", + fail: "text-ih-bad-fg", + skipped: "text-ih-fg-3", + pending: "text-ih-fg-3", +}; + +export function StepChecklist({ + target, + intent, + steps, + label, + results, + notice, + recheckStep, +}: { + target: ConnectionTestResult["target"]; + /** The route action intent that runs ONE step (posted with a `step` field). */ + intent: string; + steps: readonly string[]; + label: (step: string) => string; + results: ConnectionTestResult[]; + /** One line shown beside the button, for a step with a side effect. */ + notice?: string; + /** A step that can stay "waiting" and offers a re-check link. */ + recheckStep?: string; +}) { + const { fetcher, submit, busy: submitting } = useGuardedSubmit(); + const [outcomes, setOutcomes] = useState>(() => persistedOutcomes(results, target, steps)); + const [queue, setQueue] = useState([]); + const [active, setActive] = useState(null); + const [failed, setFailed] = useState(false); + + const start = (run: string[]) => { + const [first, ...rest] = run; + setFailed(false); + setOutcomes((prev) => { + const next = { ...prev }; + for (const s of run) delete next[s]; + return next; + }); + setQueue(rest); + setActive(first); + submit({ intent, step: first }, { method: "post" }); + }; + + useEffect(() => { + if (fetcher.state !== "idle" || !active) return; + const d = fetcher.data; + if (!d || d.intent !== intent || d.step !== active) return; + const outcome = d.outcome; + if (!outcome) { + setFailed(true); + setQueue([]); + setActive(null); + return; + } + setOutcomes((prev) => ({ ...prev, [active]: outcome })); + const [next, ...rest] = outcome.status === "fail" ? [] : queue; + if (next) { + setQueue(rest); + setActive(next); + submit({ intent, step: next }, { method: "post" }); + } else { + setActive(null); + } + }, [fetcher.state, fetcher.data, active, queue, submit, intent]); + + const busy = active !== null || submitting; + const btn = + "h-8 px-3 rounded-md border border-ih-border bg-ih-bg-card text-[12px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors disabled:opacity-60"; + + return ( +
+
+ + {notice ? {notice} : null} +
+ +
    + {steps.map((step) => { + const o = outcomes[step]; + const status = o?.status; + return ( +
  • + + {status ? GLYPH[status] : active === step ? "…" : "·"} + + {label(step)} + {status ? {STATUS_LABEL[status]()} : null} + {o?.detail ? ( + — {o.detail} + ) : null} + {step === recheckStep && status === "pending" && !busy ? ( + + ) : null} +
  • + ); + })} +
+ {failed ? {m.settings_teststeps_error()} : null} +
+ ); +} diff --git a/app/components/settings/StripeStepTest.tsx b/app/components/settings/StripeStepTest.tsx new file mode 100644 index 000000000..b087e7039 --- /dev/null +++ b/app/components/settings/StripeStepTest.tsx @@ -0,0 +1,23 @@ +import { StepChecklist } from "~/components/settings/StepChecklist"; +import type { ConnectionTestResult } from "~/lib/connection-test"; +import { STRIPE_TEST_STEPS } from "~/lib/test-steps"; +import { m } from "~/paraglide/messages"; + +const LABEL: Record<(typeof STRIPE_TEST_STEPS)[number], () => string> = { + key_valid: () => m.settings_stripesteps_key_valid(), + account_capabilities: () => m.settings_stripesteps_account_capabilities(), + webhook: () => m.settings_stripesteps_webhook(), +}; + +/** Stripe "Test connection": three read-only steps; nothing is charged or created. */ +export function StripeStepTest({ results }: { results: ConnectionTestResult[] }) { + return ( + LABEL[s as keyof typeof LABEL]()} + results={results} + /> + ); +} diff --git a/app/components/settings/integrations/StripePaymentsPanel.tsx b/app/components/settings/integrations/StripePaymentsPanel.tsx index a4a317abc..8a29673f1 100644 --- a/app/components/settings/integrations/StripePaymentsPanel.tsx +++ b/app/components/settings/integrations/StripePaymentsPanel.tsx @@ -1,12 +1,11 @@ import { useState } from "react"; -import { Form, type useFetcher, type useRevalidator } from "react-router"; +import { Form, type useRevalidator } from "react-router"; import { SecretField } from "~/components/SecretField"; -import { TestConnectionButton } from "~/components/settings/TestConnectionButton"; +import { StripeStepTest } from "~/components/settings/StripeStepTest"; import { ConnectionTestStatus, type ConnectionTestResult } from "~/components/settings/ConnectionTestStatus"; import { WebhookStatusBadge } from "./WebhookStatusBadge"; import { useChromeDateTimeFormat, useDisplayTimeZone } from "~/hooks/useSessionContext"; import { formatShapedDateTime } from "~/lib/format-date"; -import type { action } from "~/routes/settings-integrations"; import { m } from "~/paraglide/messages"; type WebhookLogEntry = { ts: string; eventType: string; result: string }; @@ -43,7 +42,6 @@ interface StripePaymentsPanelProps { saving: boolean; serverField: string | null; serverError: string | null | undefined; - testFetcher: ReturnType>; revalidator: ReturnType; /** Persisted "Test connection" history (shared loader list, filtered to stripe). */ testResults?: ConnectionTestResult[]; @@ -56,7 +54,6 @@ export function StripePaymentsPanel({ saving, serverField, serverError, - testFetcher, revalidator, testResults = [], }: StripePaymentsPanelProps) { @@ -144,24 +141,8 @@ export function StripePaymentsPanel({ - {/* Test connection — probes the STORED secret key, no re-entry needed */} - - {testFetcher.data?.intent === "test-stripe" && testFetcher.data.test && ( - - {m.settings_stripe_test_connected_prefix()} {testFetcher.data.test.accountName} - - {testFetcher.data.test.livemode ? m.settings_stripe_livemode_live() : m.settings_stripe_livemode_test()} - - - )} - {testFetcher.data?.intent === "test-stripe" && !testFetcher.data.success && ( - {testFetcher.data.error} - )} - + {/* Test connection — three read-only checks against the STORED secret key */} + {/* Persisted last-tested status + recent history (survives reloads). */} diff --git a/app/lib/email-test-step.server.ts b/app/lib/email-test-step.server.ts deleted file mode 100644 index c415e8e8f..000000000 --- a/app/lib/email-test-step.server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { createApi } from "~/lib/api-client.server"; -import { isEmailTestStepId } from "~/lib/email-test-steps"; -import { m } from "~/paraglide/messages"; - -/** - * Runs one step of the stepwise email test for the settings action. A step that - * ran but failed is still `ok` (the outcome says how it went); `outcome: null` - * means the request itself failed (unknown step, rate limit, transport). - */ -export async function runEmailTestStepIntent(api: ReturnType, intent: string, rawStep: unknown) { - if (!isEmailTestStepId(rawStep)) { - return { intent, ok: false, error: m.settings_emailsteps_error(), step: null, outcome: null }; - } - const res = await api.integrationsTestSteps.email.$post({ json: { step: rawStep } }); - const body = res.ok ? await res.json().catch(() => null) : null; - const data = body && "data" in body ? body.data : null; - return { - intent, - ok: Boolean(data), - error: data ? null : m.settings_emailsteps_error(), - step: rawStep, - outcome: data ? { status: data.status, detail: data.detail ?? null } : null, - }; -} diff --git a/app/lib/email-test-steps.test.ts b/app/lib/email-test-steps.test.ts deleted file mode 100644 index b2f0e0f7b..000000000 --- a/app/lib/email-test-steps.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { isEmailTestStepId, persistedOutcomes } from "./email-test-steps"; - -describe("persistedOutcomes", () => { - const row = (over: Record) => ({ - target: "email", step: "key_valid", ok: true, detail: null, testedAt: 1, ...over, - }) as Parameters[0][number]; - - it("keeps the newest result per step", () => { - const out = persistedOutcomes([ - row({ step: "key_valid", ok: false, detail: "old", testedAt: 1 }), - row({ step: "key_valid", ok: true, detail: "new", testedAt: 2 }), - ]); - expect(out.key_valid).toEqual({ status: "pass", detail: "new" }); - }); - - it("ignores single-shot rows, other targets and unknown steps", () => { - const out = persistedOutcomes([ - row({ step: null }), - row({ target: "stripe", step: "key_valid" }), - row({ step: "not_a_step" }), - ]); - expect(out).toEqual({}); - }); - - it("maps a failed row to fail", () => { - expect(persistedOutcomes([row({ ok: false, detail: "bad" })]).key_valid).toEqual({ status: "fail", detail: "bad" }); - }); -}); - -describe("isEmailTestStepId", () => { - it("accepts a known id and rejects everything else", () => { - expect(isEmailTestStepId("send_test")).toBe(true); - expect(isEmailTestStepId("send_test ")).toBe(false); - expect(isEmailTestStepId(null)).toBe(false); - }); -}); diff --git a/app/lib/email-test-steps.ts b/app/lib/email-test-steps.ts deleted file mode 100644 index 6bf55a969..000000000 --- a/app/lib/email-test-steps.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Client-safe description of the stepwise email connection test: the ordered - * step ids and the shape of one step's outcome. The server owns the logic - * (`server/lib/integration/email-test-steps.ts`); this file only names things. - */ -export const EMAIL_TEST_STEPS = [ - "key_valid", - "key_scope", - "sender_domain_verified", - "send_test", - "quota", - "delivery", -] as const; - -export type EmailTestStepId = (typeof EMAIL_TEST_STEPS)[number]; - -export type StepStatus = "pass" | "fail" | "skipped" | "pending"; - -export interface StepOutcome { - status: StepStatus; - detail: string | null; -} - -export function isEmailTestStepId(v: unknown): v is EmailTestStepId { - return typeof v === "string" && (EMAIL_TEST_STEPS as readonly string[]).includes(v); -} - -/** - * Outcomes the server already stored (pass/fail only; skipped and pending are - * never recorded), newest first per step, so the checklist survives a reload. - */ -export function persistedOutcomes( - rows: ReadonlyArray<{ target: string; step: string | null; ok: boolean; detail: string | null; testedAt: number }>, -): Partial> { - const out: Partial> = {}; - const newestFirst = [...rows].sort((a, b) => b.testedAt - a.testedAt); - for (const r of newestFirst) { - if (r.target !== "email" || !isEmailTestStepId(r.step) || out[r.step]) continue; - out[r.step] = { status: r.ok ? "pass" : "fail", detail: r.detail }; - } - return out; -} diff --git a/app/lib/test-step.server.ts b/app/lib/test-step.server.ts new file mode 100644 index 000000000..5048c48e7 --- /dev/null +++ b/app/lib/test-step.server.ts @@ -0,0 +1,35 @@ +import type { createApi } from "~/lib/api-client.server"; +import { isEmailTestStepId, isStripeTestStepId } from "~/lib/test-steps"; +import { m } from "~/paraglide/messages"; + +type Api = ReturnType; +type Body = { data?: { status: "pass" | "fail" | "skipped" | "pending"; detail?: string | null } } | null; + +/** + * Runs one step of a stepwise connection test for a settings action. A step that + * ran but failed is still `ok` (the outcome says how it went); `outcome: null` + * means the request itself failed (unknown step, rate limit, transport). + */ +function shape(intent: string, step: string | null, data: NonNullable['data']> | undefined) { + return { + intent, + ok: Boolean(data), + error: data ? null : m.settings_teststeps_error(), + step, + outcome: data ? { status: data.status, detail: data.detail ?? null } : null, + }; +} + +export async function runEmailTestStepIntent(api: Api, intent: string, rawStep: unknown) { + if (!isEmailTestStepId(rawStep)) return shape(intent, null, undefined); + const res = await api.integrationsTestSteps.email.$post({ json: { step: rawStep } }); + const body = res.ok ? ((await res.json().catch(() => null)) as Body) : null; + return shape(intent, rawStep, body?.data); +} + +export async function runStripeTestStepIntent(api: Api, intent: string, rawStep: unknown) { + if (!isStripeTestStepId(rawStep)) return shape(intent, null, undefined); + const res = await api.integrationsTestSteps.stripe.$post({ json: { step: rawStep } }); + const body = res.ok ? ((await res.json().catch(() => null)) as Body) : null; + return shape(intent, rawStep, body?.data); +} diff --git a/app/lib/test-steps.test.ts b/app/lib/test-steps.test.ts new file mode 100644 index 000000000..32ea3339a --- /dev/null +++ b/app/lib/test-steps.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { EMAIL_TEST_STEPS, STRIPE_TEST_STEPS, isEmailTestStepId, isStripeTestStepId, persistedOutcomes } from "./test-steps"; + +describe("persistedOutcomes", () => { + const row = (over: Record) => ({ + target: "email", step: "key_valid", ok: true, detail: null, testedAt: 1, ...over, + }) as Parameters[0][number]; + + it("keeps the newest result per step", () => { + const out = persistedOutcomes([ + row({ ok: false, detail: "old", testedAt: 1 }), + row({ ok: true, detail: "new", testedAt: 2 }), + ], "email", EMAIL_TEST_STEPS); + expect(out.key_valid).toEqual({ status: "pass", detail: "new" }); + }); + + it("ignores single-shot rows, other targets and unknown steps", () => { + const out = persistedOutcomes([ + row({ step: null }), + row({ target: "stripe", step: "key_valid" }), + row({ step: "not_a_step" }), + ], "email", EMAIL_TEST_STEPS); + expect(out).toEqual({}); + }); + + it("reads each target's own steps", () => { + const rows = [row({ target: "stripe", step: "webhook", ok: false, detail: "bad" }), row({ step: "webhook" })]; + expect(persistedOutcomes(rows, "stripe", STRIPE_TEST_STEPS).webhook).toEqual({ status: "fail", detail: "bad" }); + // email has no "webhook" step, so the same rows yield nothing for it + expect(persistedOutcomes(rows, "email", EMAIL_TEST_STEPS)).toEqual({}); + }); +}); + +describe("step id guards", () => { + it("accept a known id of their own target and reject everything else", () => { + expect(isEmailTestStepId("send_test")).toBe(true); + expect(isEmailTestStepId("webhook")).toBe(false); + expect(isStripeTestStepId("webhook")).toBe(true); + expect(isStripeTestStepId("send_test")).toBe(false); + expect(isEmailTestStepId("send_test ")).toBe(false); + expect(isEmailTestStepId(null)).toBe(false); + }); +}); diff --git a/app/lib/test-steps.ts b/app/lib/test-steps.ts new file mode 100644 index 000000000..a11c69ad4 --- /dev/null +++ b/app/lib/test-steps.ts @@ -0,0 +1,52 @@ +/** + * Client-safe description of the stepwise connection tests: the ordered step ids + * per target and the shape of one step's outcome. The server owns the logic + * (`server/lib/integration/{email,stripe}-test-steps.ts`); this file only names + * things. + */ +export const EMAIL_TEST_STEPS = [ + "key_valid", + "key_scope", + "sender_domain_verified", + "send_test", + "quota", + "delivery", +] as const; +export const STRIPE_TEST_STEPS = ["key_valid", "account_capabilities", "webhook"] as const; + +export type EmailTestStepId = (typeof EMAIL_TEST_STEPS)[number]; +export type StripeTestStepId = (typeof STRIPE_TEST_STEPS)[number]; + +export type StepStatus = "pass" | "fail" | "skipped" | "pending"; + +export interface StepOutcome { + status: StepStatus; + detail: string | null; +} + +export function isEmailTestStepId(v: unknown): v is EmailTestStepId { + return typeof v === "string" && (EMAIL_TEST_STEPS as readonly string[]).includes(v); +} + +export function isStripeTestStepId(v: unknown): v is StripeTestStepId { + return typeof v === "string" && (STRIPE_TEST_STEPS as readonly string[]).includes(v); +} + +/** + * Outcomes the server already stored for `target` (pass/fail only; skipped and + * pending are never recorded), newest first per step, so the checklist survives + * a reload. + */ +export function persistedOutcomes( + rows: ReadonlyArray<{ target: string; step: string | null; ok: boolean; detail: string | null; testedAt: number }>, + target: string, + steps: readonly string[], +): Record { + const out: Record = {}; + const newestFirst = [...rows].sort((a, b) => b.testedAt - a.testedAt); + for (const r of newestFirst) { + if (r.target !== target || r.step === null || !steps.includes(r.step) || out[r.step]) continue; + out[r.step] = { status: r.ok ? "pass" : "fail", detail: r.detail }; + } + return out; +} diff --git a/app/routes/settings-communication.tsx b/app/routes/settings-communication.tsx index ecb2660a3..05906498f 100644 --- a/app/routes/settings-communication.tsx +++ b/app/routes/settings-communication.tsx @@ -16,7 +16,7 @@ import { requireAdminLoader } from "~/lib/access.server"; import { AccessDenied } from "~/components/AccessDenied"; import { EmailDeliveryPanel } from "~/components/settings/EmailDeliveryPanel"; import { EmailSecretsPanel } from "~/components/settings/EmailSecretsPanel"; -import { runEmailTestStepIntent } from "~/lib/email-test-step.server"; +import { runEmailTestStepIntent } from "~/lib/test-step.server"; import { SmsDeliveryPanel, type SmsModeValue } from "~/components/settings/SmsDeliveryPanel"; import { GoogleCalendarPanel } from "~/components/settings/GoogleCalendarPanel"; import { ManagedComplianceWizard, type ManagedComplianceData } from "~/components/settings/ManagedComplianceWizard"; diff --git a/app/routes/settings-integrations.tsx b/app/routes/settings-integrations.tsx index 357b5d409..11a520bdc 100644 --- a/app/routes/settings-integrations.tsx +++ b/app/routes/settings-integrations.tsx @@ -3,12 +3,12 @@ import { useActionData, useNavigation, useRevalidator, - useFetcher, } from "react-router"; import { SettingsCrumb } from "~/components/SettingsCrumb"; import type { Route } from "./+types/settings-integrations"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; +import { runStripeTestStepIntent } from "~/lib/test-step.server"; import { useFlash } from "~/hooks/useFlash"; import { useSessionContext } from "~/hooks/useSessionContext"; import { requireAdminLoader } from "~/lib/access.server"; @@ -109,22 +109,10 @@ export async function action({ request, context }: Route.ActionArgs) { return { intent, success: true, error: null, field: null, test: null }; } - if (intent === "test-stripe") { - const api = createApi(context, { token }); - const res = await api.integrations.stripe.test.$post(); - const body = (await res.json().catch(() => null)) as - | { data?: { accountName: string; livemode: boolean }; error?: { message?: string } } - | null; - if (!res.ok || !body?.data) { - return { - intent, - success: false, - error: body?.error?.message ?? m.settings_connection_test_failed(), - field: null, - test: null, - }; - } - return { intent, success: true, error: null, field: null, test: body.data }; + if (intent === "test-stripe-step") { + // This route's other results carry `success` and `field`; keep the union uniform. + const r = await runStripeTestStepIntent(createApi(context, { token }), "test-stripe-step", fd.get("step")); + return { ...r, success: r.ok, field: null }; } if (intent === "save-video") { @@ -233,7 +221,6 @@ export default function SettingsIntegrations() { const actionData = useActionData(); const nav = useNavigation(); const revalidator = useRevalidator(); - const testFetcher = useFetcher(); const ctx = useSessionContext(); const { flashVisible } = useFlash( @@ -300,7 +287,6 @@ export default function SettingsIntegrations() { saving={saving} serverField={serverField} serverError={actionData?.error} - testFetcher={testFetcher} revalidator={revalidator} testResults={testResults} /> diff --git a/messages/en/settings-components.json b/messages/en/settings-components.json index a6bac6d4c..df14ef888 100644 --- a/messages/en/settings-components.json +++ b/messages/en/settings-components.json @@ -286,9 +286,6 @@ "settings_stripe_webhook_secret_label": "Webhook Signing Secret", "settings_stripe_webhook_secret_hint": "Verifies payment notifications. Found after you add the webhook endpoint below (starts with whsec_).", "settings_stripe_save": "Save Stripe keys", - "settings_stripe_test_connected_prefix": "Connected:", - "settings_stripe_livemode_live": "Live", - "settings_stripe_livemode_test": "Test", "settings_stripe_webhook_endpoint": "Webhook endpoint", "settings_stripe_webhook_desc_prefix": "In Stripe → Developers → Webhooks, add an endpoint for the", "settings_stripe_webhook_desc_suffix": "event pointing at this URL, then paste its signing secret above. If you registered an endpoint before, re-point it to this URL — the signing secret stays the same.", @@ -697,16 +694,19 @@ "settings_isn_secret_key_label": "Secret access key", "settings_isn_save_error": "Could not save the ISN settings.", "settings_emailsteps_notice": "Sends one test email to your own address.", - "settings_emailsteps_recheck": "Check delivery again", - "settings_emailsteps_error": "The test could not be run. Try again in a minute.", + "settings_teststeps_recheck": "Check delivery again", + "settings_teststeps_error": "The test could not be run. Try again in a minute.", "settings_emailsteps_key_valid": "API key accepted", "settings_emailsteps_key_scope": "Key permissions", "settings_emailsteps_sender_domain_verified": "Sender domain verified", "settings_emailsteps_send_test": "Test email sent to you", "settings_emailsteps_quota": "Sending quota", "settings_emailsteps_delivery": "Delivery confirmed", - "settings_emailsteps_status_pass": "Passed", - "settings_emailsteps_status_fail": "Failed", - "settings_emailsteps_status_skipped": "Skipped", - "settings_emailsteps_status_pending": "Waiting" + "settings_teststeps_status_pass": "Passed", + "settings_teststeps_status_fail": "Failed", + "settings_teststeps_status_skipped": "Skipped", + "settings_stripesteps_key_valid": "Secret key accepted", + "settings_stripesteps_account_capabilities": "Account can take payments", + "settings_stripesteps_webhook": "Webhook delivery verified", + "settings_teststeps_status_pending": "Waiting" } diff --git a/messages/es-419/settings-components.json b/messages/es-419/settings-components.json index a48d33748..92f4cb7d2 100644 --- a/messages/es-419/settings-components.json +++ b/messages/es-419/settings-components.json @@ -286,9 +286,6 @@ "settings_stripe_webhook_secret_label": "Secreto de firma del webhook", "settings_stripe_webhook_secret_hint": "Verifica las notificaciones de pago. Se obtiene después de agregar el endpoint del webhook de abajo (comienza con whsec_).", "settings_stripe_save": "Guardar las claves de Stripe", - "settings_stripe_test_connected_prefix": "Conectado:", - "settings_stripe_livemode_live": "Producción", - "settings_stripe_livemode_test": "Prueba", "settings_stripe_webhook_endpoint": "Endpoint del webhook", "settings_stripe_webhook_desc_prefix": "En Stripe → Developers → Webhooks, agregue un endpoint para el evento", "settings_stripe_webhook_desc_suffix": "que apunte a esta URL y luego pegue arriba su secreto de firma. Si ya registró un endpoint antes, vuelva a apuntarlo a esta URL: el secreto de firma no cambia.", @@ -697,16 +694,19 @@ "settings_isn_secret_key_label": "Clave de acceso secreta", "settings_isn_save_error": "No se pudo guardar la configuración de ISN.", "settings_emailsteps_notice": "Envía un correo de prueba a tu propia dirección.", - "settings_emailsteps_recheck": "Volver a comprobar la entrega", - "settings_emailsteps_error": "No se pudo ejecutar la prueba. Inténtalo de nuevo en un minuto.", + "settings_teststeps_recheck": "Volver a comprobar la entrega", + "settings_teststeps_error": "No se pudo ejecutar la prueba. Inténtalo de nuevo en un minuto.", "settings_emailsteps_key_valid": "Clave de API aceptada", "settings_emailsteps_key_scope": "Permisos de la clave", "settings_emailsteps_sender_domain_verified": "Dominio del remitente verificado", "settings_emailsteps_send_test": "Correo de prueba enviado a ti", "settings_emailsteps_quota": "Cuota de envío", "settings_emailsteps_delivery": "Entrega confirmada", - "settings_emailsteps_status_pass": "Correcto", - "settings_emailsteps_status_fail": "Falló", - "settings_emailsteps_status_skipped": "Omitido", - "settings_emailsteps_status_pending": "En espera" + "settings_teststeps_status_pass": "Correcto", + "settings_teststeps_status_fail": "Falló", + "settings_teststeps_status_skipped": "Omitido", + "settings_stripesteps_key_valid": "Clave secreta aceptada", + "settings_stripesteps_account_capabilities": "La cuenta puede cobrar pagos", + "settings_stripesteps_webhook": "Entrega de webhook verificada", + "settings_teststeps_status_pending": "En espera" } diff --git a/server/api/integrations-test-steps.ts b/server/api/integrations-test-steps.ts index e0c8a0dd3..5f233a4e9 100644 --- a/server/api/integrations-test-steps.ts +++ b/server/api/integrations-test-steps.ts @@ -128,7 +128,10 @@ const integrationsTestStepsRoutes = createApiRouter() const secretKey = c.env.STRIPE_SECRET_KEY; const result = await runStripeStep(step, { fetchAccount: secretKey - ? async () => new (await import('../services/stripe.service')).StripeService(secretKey).getAccountStatus() + ? async () => ({ + ...(await new (await import('../services/stripe.service')).StripeService(secretKey).getAccountStatus()), + livemode: secretKey.startsWith('sk_live_') || secretKey.startsWith('rk_live_'), + }) : null, webhookSecretConfigured: !!c.env.STRIPE_WEBHOOK_SECRET, readDeliveries: () => readWebhookLog(c.env.TENANT_CACHE, tenantId), diff --git a/server/lib/integration/stripe-test-steps.ts b/server/lib/integration/stripe-test-steps.ts index bffc248dc..0aa838093 100644 --- a/server/lib/integration/stripe-test-steps.ts +++ b/server/lib/integration/stripe-test-steps.ts @@ -29,6 +29,8 @@ export interface StripeAccountStatus { chargesEnabled: boolean; payoutsEnabled: boolean; detailsSubmitted: boolean; + /** From the key's prefix (the Account API does not expose it); absent when unknown. */ + livemode?: boolean; } export interface StripeWebhookDelivery { @@ -55,7 +57,7 @@ async function loadAccount(deps: StripeStepDeps): Promise { expect((await runStripeStep('key_valid', sd({ fetchAccount: async () => { throw new Error('401'); } }))).status).toBe('fail'); expect(await runStripeStep('key_valid', sd())).toMatchObject({ status: 'pass', detail: 'Connected to Acme.' }); }); + it('key_valid: says which mode the key is in when known', async () => { + expect((await runStripeStep('key_valid', sd({ fetchAccount: async () => ({ ...acct, livemode: false }) }))).detail).toBe('Connected to Acme (test mode).'); + expect((await runStripeStep('key_valid', sd({ fetchAccount: async () => ({ ...acct, livemode: true }) }))).detail).toBe('Connected to Acme (live mode).'); + }); it('account_capabilities: names what is missing', async () => { const r = await runStripeStep('account_capabilities', sd({ fetchAccount: async () => ({ ...acct, payoutsEnabled: false }) })); expect(r.status).toBe('fail'); From 57ec37b088a20e841fa5654870a218150c8c0303 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:18:00 -0400 Subject: [PATCH 08/16] feat(email): confirm a person with Turnstile instead of refusing client email in a new company's first 24 hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the cooling window a send is now allowed when the request carries a Turnstile token that Cloudflare accepts. The web app asks for the challenge right before an action that emails a client (send report, agreements and reminders, payment requests, publish, client messages, resend, template test send), keeps the one-time token in a 60-second cookie, and the worker entry relays it to the API as a header. The API verifies it lazily, once per request, and only when a send would otherwise be refused. A request with no token (API or MCP client) is refused exactly as before with the same error code. Outside the window nothing is asked of anyone, and account-mechanics classes stay exempt. Automation sends that run later without a request still defer to the unlock time as before. File-size baselines raised by a few lines each (inspection-edit, inspector-portal, middleware/di): each adds one hook call and one awaited challenge line to a large route or middleware that already carried a cap; the shared logic lives in new small files. Chrome: /settings/communication/templates — sending a test email opens the confirm-you-are-a-person dialog, then the send completes with Test sent Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- app/components/OutboundVerification.test.tsx | 69 +++++++++ app/components/OutboundVerification.tsx | 146 ++++++++++++++++++ app/components/agreements/RequestDetail.tsx | 5 +- app/components/editor/PublishModal.tsx | 4 +- .../publish-modal-cooling-notice.test.tsx | 3 +- app/components/inspection/SendReportModal.tsx | 9 +- .../inspector-portal/CommunicationSection.tsx | 7 +- .../inspector-portal/PublishReportModal.tsx | 9 +- .../inspector-portal/RequestPaymentModal.tsx | 9 +- .../settings/TemplateEditorModal.tsx | 7 +- app/hooks/useSessionContext.ts | 2 +- app/lib/human-token.ts | 21 +++ app/routes/auth-layout.tsx | 6 + app/routes/inspection-edit.tsx | 5 +- app/routes/inspector-portal.tsx | 5 +- app/routes/messages.tsx | 4 + messages/en/editor-2.json | 2 +- messages/en/misc.json | 8 +- messages/es-419/editor-2.json | 2 +- messages/es-419/misc.json | 8 +- scripts/file-size-baseline.json | 6 +- server/api/session-context.ts | 8 +- server/lib/email/build-email-service.ts | 6 +- server/lib/email/human-verifier.ts | 22 +++ server/lib/email/outbound-cooling-window.ts | 20 +++ server/lib/email/tenant-email-resolver.ts | 5 +- server/lib/errors.ts | 2 +- server/lib/middleware/di.ts | 6 +- tests/unit/email/human-token-relay.spec.ts | 41 +++++ .../outbound-cooling-verification.spec.ts | 50 ++++++ workers/app.ts | 3 +- workers/human-token.ts | 39 +++++ 32 files changed, 508 insertions(+), 31 deletions(-) create mode 100644 app/components/OutboundVerification.test.tsx create mode 100644 app/components/OutboundVerification.tsx create mode 100644 app/lib/human-token.ts create mode 100644 server/lib/email/human-verifier.ts create mode 100644 tests/unit/email/human-token-relay.spec.ts create mode 100644 tests/unit/email/outbound-cooling-verification.spec.ts create mode 100644 workers/human-token.ts diff --git a/app/components/OutboundVerification.test.tsx b/app/components/OutboundVerification.test.tsx new file mode 100644 index 000000000..993c58797 --- /dev/null +++ b/app/components/OutboundVerification.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +let emitToken: ((t: string) => void) | null = null; +vi.mock("~/lib/turnstile", () => ({ + useTurnstileWidget: (_k: unknown, _r: unknown, _s: unknown, onToken: (t: string) => void) => { + emitToken = onToken; + }, +})); + +import { OutboundVerificationProvider, useEnsureHuman } from "./OutboundVerification"; + +function Probe({ onResult }: { onResult: (ok: boolean) => void }) { + const ensureHuman = useEnsureHuman(); + return ; +} + +const setup = (windowOpen: boolean, siteKey: string | null = "site-key") => { + const onResult = vi.fn(); + render( + + + , + ); + return onResult; +}; + +const click = async () => { await act(async () => { fireEvent.click(screen.getByText("send")); }); }; + +beforeEach(() => { + emitToken = null; + document.cookie = "oi-human-token=; Max-Age=0; Path=/"; +}); + +describe("OutboundVerificationProvider", () => { + it("resolves true at once, with no dialog, outside the cooling window", async () => { + const onResult = setup(false); + await click(); + expect(onResult).toHaveBeenCalledWith(true); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("opens the challenge inside the window and resolves true once a token is solved, leaving it for the next request", async () => { + const onResult = setup(true); + await click(); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(onResult).not.toHaveBeenCalled(); + await act(async () => emitToken!("0.token-value-1234567890")); + expect(onResult).toHaveBeenCalledWith(true); + expect(document.cookie).toContain("oi-human-token="); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("resolves false and stores nothing when the person cancels", async () => { + const onResult = setup(true); + await click(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Cancel" })); }); + expect(onResult).toHaveBeenCalledWith(false); + expect(document.cookie).not.toContain("oi-human-token=0"); + }); + + it("lets the server decide when there is no site key", async () => { + const onResult = setup(true, null); + await click(); + expect(onResult).toHaveBeenCalledWith(true); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/app/components/OutboundVerification.tsx b/app/components/OutboundVerification.tsx new file mode 100644 index 000000000..351950888 --- /dev/null +++ b/app/components/OutboundVerification.tsx @@ -0,0 +1,146 @@ +import { createContext, useCallback, useContext, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { Button, Modal } from "@core/shared-ui"; +import { setHumanToken } from "~/lib/human-token"; +import { useTurnstileWidget } from "~/lib/turnstile"; +import { m } from "~/paraglide/messages"; + +/** + * Inside a new company's first 24 hours, a message to a client goes out only + * after the sender proves they are a person. Every surface that sends one calls + * `await ensureHuman()` before submitting; it resolves true at once outside the + * window, and otherwise after a fresh Turnstile challenge is solved (the token + * is left for the next request, see `~/lib/human-token`). False means the + * person closed the dialog and the send should not go ahead. + * + * A new challenge every time is deliberate: a token redeems once. + */ +type EnsureHuman = () => Promise; + +const EnsureHumanContext = createContext(async () => true); + +export function useEnsureHuman(): EnsureHuman { + return useContext(EnsureHumanContext); +} + +/** + * `onSubmit` for a native form whose submit has no handler of its own: holds + * the browser submit, runs the challenge, and re-fires it once the person is + * verified. A second press during the challenge is ignored. Cancelling submits + * nothing. + */ +export function useHumanGatedSubmit() { + const ensureHuman = useEnsureHuman(); + const busy = useRef(false); + const pass = useRef(false); + return useCallback( + async (e: FormEvent) => { + if (pass.current) { + pass.current = false; + return; + } + e.preventDefault(); + const form = e.currentTarget; + if (busy.current) return; + busy.current = true; + try { + if (!(await ensureHuman())) return; + pass.current = true; + form.requestSubmit(); + } finally { + busy.current = false; + } + }, + [ensureHuman], + ); +} + +interface Pending { + id: number; + resolve: (ok: boolean) => void; +} + +export function OutboundVerificationProvider({ + windowOpen, + siteKey, + children, +}: { + /** True only while the cooling window is open (the server says so). */ + windowOpen: boolean; + siteKey: string | null | undefined; + children: ReactNode; +}) { + const [pending, setPending] = useState(null); + const counter = useRef(0); + + const ensureHuman = useCallback(() => { + // Without a site key the widget cannot render; let the server decide, it + // refuses with the same code if it needed the token. + if (!windowOpen || !siteKey) return Promise.resolve(true); + return new Promise((resolve) => { + counter.current += 1; + setPending({ id: counter.current, resolve }); + }); + }, [windowOpen, siteKey]); + + const finish = useCallback((ok: boolean) => { + setPending((p) => { + p?.resolve(ok); + return null; + }); + }, []); + + return ( + + {children} + {pending && ( + + )} + + ); +} + +function VerifyDialog({ + step, + siteKey, + onDone, +}: { + step: number; + siteKey: string | null | undefined; + onDone: (ok: boolean) => void; +}) { + const widgetRef = useRef(null); + const [loadFailed, setLoadFailed] = useState(false); + + useTurnstileWidget( + siteKey, + widgetRef, + step, + (token) => { + setHumanToken(token); + onDone(true); + }, + { onLoadFailed: () => setLoadFailed(true) }, + ); + + return ( + onDone(false)} + title={m.outbound_verify_title()} + size="sm" + footer={ + + } + > +

{m.outbound_verify_body()}

+
+ {loadFailed && ( +

+ {m.outbound_verify_load_failed()} +

+ )} + + ); +} diff --git a/app/components/agreements/RequestDetail.tsx b/app/components/agreements/RequestDetail.tsx index 57c0ed788..a0d3d0085 100644 --- a/app/components/agreements/RequestDetail.tsx +++ b/app/components/agreements/RequestDetail.tsx @@ -4,6 +4,7 @@ import { SignerList, type SignerRow } from "~/components/agreements/SignerList"; import type { action } from "~/routes/resources/agreement-signers"; import { AGREEMENT_SIGNERS_ACTION } from "~/routes/resources/agreement-signers"; import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; +import { useEnsureHuman } from "~/components/OutboundVerification"; import { m } from "~/paraglide/messages"; /** @@ -18,6 +19,7 @@ export function RequestDetail({ requestId }: { requestId: string }) { // Separate fetchers per competing mutation (RR rule: shared fetcher aborts in-flight). // #106 - a reminder sends a real email to a real signer. The two load // fetchers around it stay raw: they are selects, not writes. + const ensureHuman = useEnsureHuman(); // submit-guard-allow-no-busy: the row swaps to a "sent" state on the reply. const { fetcher: remindFetcher, submit: submitRemind } = useGuardedSubmit(); const copyFetcher = useFetcher(); @@ -50,7 +52,8 @@ export function RequestDetail({ requestId }: { requestId: string }) { // Remind is fire-and-forget through its own fetcher; the result (including a // 429/409 friendly message) renders as an inline banner, never an alert. - const onRemind = (signerId: string) => { + const onRemind = async (signerId: string) => { + if (!(await ensureHuman())) return; submitRemind({ intent: "remind", requestId, signerId }, post); }; diff --git a/app/components/editor/PublishModal.tsx b/app/components/editor/PublishModal.tsx index 6be6a8524..2a49c8f3f 100644 --- a/app/components/editor/PublishModal.tsx +++ b/app/components/editor/PublishModal.tsx @@ -66,8 +66,8 @@ export function PublishModal({ open, progress, status, publishError, isSubmittin // first 24 hours. Said HERE, on the button that sets the expectation, because // the account-wide banner answers a different question: it says the window // exists, not that THIS publish is about to land in it. Publishing itself is - // unaffected — the report goes live and the client's email is re-scheduled to - // the unlock instant, so the wording promises a delay, never a loss. + // unaffected — the report goes live, and the person is asked to confirm they are + // a person before the client email goes out; the wording never promises a loss. // // Same `unlockAtMs` the banner reads, so the two cannot name different times; // it is non-null only while the window is open, and the server decides that diff --git a/app/components/editor/publish-modal-cooling-notice.test.tsx b/app/components/editor/publish-modal-cooling-notice.test.tsx index 92bb12647..69e60d0cc 100644 --- a/app/components/editor/publish-modal-cooling-notice.test.tsx +++ b/app/components/editor/publish-modal-cooling-notice.test.tsx @@ -61,8 +61,7 @@ describe('PublishModal — outbound cooling window', () => { unlockAtMs = Date.now() + 3_600_000; render(); const notice = screen.getByText(/less than a day old/i); - expect(notice.textContent).toMatch(/sends itself/i); - expect(notice.textContent).toMatch(/nothing to redo/i); + expect(notice.textContent).toMatch(/confirm you are a person/i); expect(notice.textContent).toMatch(/goes live now/i); }); diff --git a/app/components/inspection/SendReportModal.tsx b/app/components/inspection/SendReportModal.tsx index 82750115f..bddd43d50 100644 --- a/app/components/inspection/SendReportModal.tsx +++ b/app/components/inspection/SendReportModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useHumanGatedSubmit } from "~/components/OutboundVerification"; import type { useFetcher } from "react-router"; import { Modal, Button, Checkbox, Input, Select } from "@core/shared-ui"; import type { action } from "~/routes/inspector-portal"; @@ -73,6 +74,7 @@ export function SendReportModal({ fetcher: ReturnType>; onClose: () => void; }) { + const gatedSubmit = useHumanGatedSubmit(); // Computed in a lazy initializer, which holds only because this component is // MOUNTED per open (`{sendReportOpen && }`) — so every // opening re-reads the current people. If it is ever kept mounted behind an @@ -154,7 +156,12 @@ export function SendReportModal({ } > - + diff --git a/app/components/inspector-portal/CommunicationSection.tsx b/app/components/inspector-portal/CommunicationSection.tsx index 291f85c09..36c680269 100644 --- a/app/components/inspector-portal/CommunicationSection.tsx +++ b/app/components/inspector-portal/CommunicationSection.tsx @@ -33,6 +33,7 @@ import { useCallback, useEffect, useState } from "react"; import { useFetcher } from "react-router"; import { Card } from "@core/shared-ui"; import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; +import { useEnsureHuman } from "~/components/OutboundVerification"; import { m } from "~/paraglide/messages"; import { useDisplayLocale, useDisplayTimeZone } from "~/hooks/useSessionContext"; import { BlockHeading } from "./BlockHeading"; @@ -83,6 +84,7 @@ export function CommunicationSection({ // MessageThread owns the pending affordance here: it holds its own `sending` // state, disables Send while `onSend` is awaited, and refuses a second press // before that resolves. + const ensureHuman = useEnsureHuman(); // submit-guard-allow-no-busy: the composer disables its own Send button. const { fetcher: send, submit: submitSend } = useGuardedSubmit<{ ok: boolean }>(); // Resend posts to the PAGE action's send-report intent (BFF — the browser @@ -138,7 +140,7 @@ export function CommunicationSection({ if (resendState === "idle" && resend.data) load(); }, [resendState, resend.data, load]); - function handleResend(row: DeliveryRow) { + async function handleResend(row: DeliveryRow) { // Channel-faithful: the resend rides the row's OWN channel so it reaches // the same provider that failed. Email → send-report; SMS → send-sms. // An email endpoint must never be the fallback for an SMS failure. @@ -155,6 +157,7 @@ export function CommunicationSection({ return; } if (row.channel !== "email") return; + if (!(await ensureHuman())) return; const recipient = row.recipientContactId ? { contactId: row.recipientContactId, roleKey: row.roleKey } : { email: row.recipient, roleKey: row.roleKey }; @@ -174,6 +177,8 @@ export function CommunicationSection({ const effectiveRecipient = recipientId || lastInbound?.contactId || threadOptions[0]?.contactId || ""; async function handleSend(body: string) { + // Throw (not return) on cancel so MessageThread keeps the draft text. + if (!(await ensureHuman())) throw new Error("verification cancelled"); if (!effectiveRecipient) throw new Error("no recipient"); // The optimistic bubble goes up only for a call the guard accepted - // otherwise the thread would show a message nothing had been sent for. diff --git a/app/components/inspector-portal/PublishReportModal.tsx b/app/components/inspector-portal/PublishReportModal.tsx index b8c69119e..80b2db0c0 100644 --- a/app/components/inspector-portal/PublishReportModal.tsx +++ b/app/components/inspector-portal/PublishReportModal.tsx @@ -1,4 +1,5 @@ import { Link, type useFetcher } from "react-router"; +import { useHumanGatedSubmit } from "~/components/OutboundVerification"; import { Modal } from "@core/shared-ui"; import type { action } from "~/routes/inspector-portal"; import { m } from "~/paraglide/messages"; @@ -71,6 +72,7 @@ export function PublishReportModal({ error: string | undefined; onClose: () => void; }) { + const gatedSubmit = useHumanGatedSubmit(); return ( } > - + {blockingCount > 0 && (

diff --git a/app/components/inspector-portal/RequestPaymentModal.tsx b/app/components/inspector-portal/RequestPaymentModal.tsx index 5e17acf65..b347f6199 100644 --- a/app/components/inspector-portal/RequestPaymentModal.tsx +++ b/app/components/inspector-portal/RequestPaymentModal.tsx @@ -1,4 +1,5 @@ import type { useFetcher } from "react-router"; +import { useHumanGatedSubmit } from "~/components/OutboundVerification"; import { Modal } from "@core/shared-ui"; import type { action } from "~/routes/inspector-portal"; import { m } from "~/paraglide/messages"; @@ -28,6 +29,7 @@ export function RequestPaymentModal({ error: string | undefined; onClose: () => void; }) { + const gatedSubmit = useHumanGatedSubmit(); const title = resend ? m.hub_payment_title_resend() : m.hub_payment_title(); const submitLabel = resend ? m.hub_payment_submit_resend() : m.hub_payment_submit(); return ( @@ -55,7 +57,12 @@ export function RequestPaymentModal({ } > - +

{m.hub_payment_recipient_label()}

diff --git a/app/components/settings/TemplateEditorModal.tsx b/app/components/settings/TemplateEditorModal.tsx index 12ff83980..2d79ed10f 100644 --- a/app/components/settings/TemplateEditorModal.tsx +++ b/app/components/settings/TemplateEditorModal.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { useFetcher } from "react-router"; import { Button, Pill, Modal } from "@core/shared-ui"; +import { useHumanGatedSubmit } from "~/components/OutboundVerification"; import { m } from "~/paraglide/messages"; import { SUPPORTED_CONTACT_LOCALES } from "../../../server/lib/i18n/contact-locale"; import { localeLabel } from "~/lib/locales"; @@ -79,6 +80,7 @@ export function TemplateEditorModal({ const [subject, setSubject] = useState(template?.subject ?? prefill?.subject ?? ""); const [body, setBody] = useState(template?.body ?? prefill?.body ?? ""); const bodyRef = useRef(null); + const gatedSubmit = useHumanGatedSubmit(); const [testTo, setTestTo] = useState(""); const [testSent, setTestSent] = useState(false); @@ -349,7 +351,10 @@ export function TemplateEditorModal({ className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-card text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4" />
- + {isEmail && } diff --git a/app/hooks/useSessionContext.ts b/app/hooks/useSessionContext.ts index 972c219dc..fda792b07 100644 --- a/app/hooks/useSessionContext.ts +++ b/app/hooks/useSessionContext.ts @@ -96,7 +96,7 @@ export interface SessionContext { * once the window has elapsed, and when the server could not read the * anchor. The client never computes any of that; it renders what it is told. */ - outboundCoolingWindow: { unlockAtMs: number } | null; + outboundCoolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null; } /** diff --git a/app/lib/human-token.ts b/app/lib/human-token.ts new file mode 100644 index 000000000..60a479892 --- /dev/null +++ b/app/lib/human-token.ts @@ -0,0 +1,21 @@ +/** + * Hands a solved Turnstile token to the next server round trip. + * + * A cookie rather than a form field because every route action would otherwise + * have to forward it by hand; the worker entry (`workers/human-token.ts`) turns + * it into the header the API reads. One-time by construction: Cloudflare + * rejects a token it has already redeemed, so a stale cookie is harmless and + * the 60-second life only keeps it from lingering. + */ +export const HUMAN_TOKEN_COOKIE = "oi-human-token"; + +export function setHumanToken(token: string): void { + if (typeof document === "undefined") return; + const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${HUMAN_TOKEN_COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=60; SameSite=Strict${secure}`; +} + +export function clearHumanToken(): void { + if (typeof document === "undefined") return; + document.cookie = `${HUMAN_TOKEN_COOKIE}=; Path=/; Max-Age=0; SameSite=Strict`; +} diff --git a/app/routes/auth-layout.tsx b/app/routes/auth-layout.tsx index 1474ebb41..dcc112557 100644 --- a/app/routes/auth-layout.tsx +++ b/app/routes/auth-layout.tsx @@ -8,6 +8,7 @@ import { CommandPalette, CommandPaletteProvider } from "~/components/CommandPale import { Sidebar, MobileHeader } from "~/components/Sidebar"; import { RouteSkeleton } from "~/components/RouteSkeleton"; import { OutboundCoolingBanner } from "~/components/OutboundCoolingBanner"; +import { OutboundVerificationProvider } from "~/components/OutboundVerification"; import type { SessionContext } from "~/hooks/useSessionContext"; /** @@ -131,6 +132,10 @@ export default function AuthLayout() { return ( + {/* F4 — Suspension banner */} {context?.branding?.tenantStatus === "suspended" && (
@@ -177,6 +182,7 @@ export default function AuthLayout() { onOpenChange={setPaletteOpen} onNewInspection={() => navigate("/inspections/new")} /> + ); } diff --git a/app/routes/inspection-edit.tsx b/app/routes/inspection-edit.tsx index ae4f6e2e6..9081e0d2b 100644 --- a/app/routes/inspection-edit.tsx +++ b/app/routes/inspection-edit.tsx @@ -7,6 +7,7 @@ import { useInspectionState, type InspectionSchema, type ItemFilter } from "~/ho import { findingKey } from "~/hooks/findings/shared"; import { useDisplayLocale } from "~/hooks/useSessionContext"; import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; +import { useEnsureHuman } from "~/components/OutboundVerification"; import { useFindings, type AttachedRepairItem } from "~/hooks/useFindings"; import { usePhotoOps } from "~/hooks/usePhotoOps"; import { useScopeLoader } from "~/hooks/useScopeLoader"; @@ -163,6 +164,7 @@ export default function InspectionEditPage() { // message is surfaced inline in the publish modal instead. // #106 — publishing is the tier-1 surface: it mints a report version, signs // it and notifies the client, and a double click would do all three twice. + const ensureHuman = useEnsureHuman(); const { fetcher: publishFetcher, submit: submitPublish, busy: publishBusy } = useGuardedSubmit<{ ok: boolean; intent?: string; error?: string }>(); const [publishError, setPublishError] = useState(null); @@ -1875,7 +1877,8 @@ export default function InspectionEditPage() { publishError={publishError} isSubmitting={publishBusy} isAmendment={loaderData.nextPublishIsAmendment} onClose={() => { setPublishError(null); state.setShowPublishModal(false); }} - onPublish={(markComplete: boolean, extras: PublishExtras) => { + onPublish={async (markComplete: boolean, extras: PublishExtras) => { + if (!(await ensureHuman())) return; // Keep the modal open: the publish-result effect closes it on success // and shows the real server reason inline on failure. `markComplete` // also closes the order axis first (advisory — never blocks publish); diff --git a/app/routes/inspector-portal.tsx b/app/routes/inspector-portal.tsx index 81ee9e89c..525e5eec0 100644 --- a/app/routes/inspector-portal.tsx +++ b/app/routes/inspector-portal.tsx @@ -75,6 +75,7 @@ import { isAdminRole } from "~/lib/access"; import { m } from "~/paraglide/messages"; import { getCloudflareEnv } from "~/lib/load-context"; import { useModalFetcher } from "~/hooks/useModalFetcher"; +import { useEnsureHuman } from "~/components/OutboundVerification"; export function meta() { return [{ title: m.inspections_hub_meta_title() }]; @@ -723,7 +724,9 @@ export default function InspectionHubPage() { // IA-65 — signer management on the inspection. The send modal serializes its // signer rows into the action; the pre-sign modal is keyed by envelope id // (which row's "Sign now" was pressed) and closes itself on success. - const submitSendAgreement = (payload: SendAgreementPayload) => { + const ensureHuman = useEnsureHuman(); + const submitSendAgreement = async (payload: SendAgreementPayload) => { + if (!(await ensureHuman())) return; agreementModal.fetcher.submit( { intent: "send-agreement", diff --git a/app/routes/messages.tsx b/app/routes/messages.tsx index 97b37e3f8..a088ef0b0 100644 --- a/app/routes/messages.tsx +++ b/app/routes/messages.tsx @@ -21,6 +21,7 @@ import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; import { PageHeader, Card, Avatar } from "@core/shared-ui"; import { MessageThread, type ThreadMessage } from "~/components/messaging/MessageThread"; +import { useEnsureHuman } from "~/components/OutboundVerification"; import { useDisplayLocale, useDisplayTimeZone } from "~/hooks/useSessionContext"; import { useGuardedSubmit } from "~/hooks/useGuardedSubmit"; import { m } from "~/paraglide/messages"; @@ -144,6 +145,7 @@ function threadTime(ms: number, locale: string, timeZone: string): string { export default function MessagesPage() { const { threads, thread, selected } = useLoaderData(); const navigate = useNavigate(); + const ensureHuman = useEnsureHuman(); // #106 - a send delivers a real message to a real person. // MessageThread owns the pending affordance and will not fire `onSend` again // until the previous one resolves. @@ -176,6 +178,8 @@ export default function MessagesPage() { })); async function handleSend(body: string) { + // Throw (not return) on cancel so MessageThread keeps the draft text. + if (!(await ensureHuman())) throw new Error("verification cancelled"); if (!selected) throw new Error("no thread"); submitSend( { contactId: selected, body, inspectionId: mentionId }, diff --git a/messages/en/editor-2.json b/messages/en/editor-2.json index 864825232..dedcaa50d 100644 --- a/messages/en/editor-2.json +++ b/messages/en/editor-2.json @@ -188,7 +188,7 @@ "editor_publish_publishing": "Publishing…", "editor_publish_now": "Publish Now", "editor_publish_body": "Publishing will finalize this inspection and make the report available to clients.", - "editor_publish_cooling_notice": "Your company is less than a day old, so the client's report email waits until {unlockAt} and then sends itself — nothing to redo. The report goes live now.", + "editor_publish_cooling_notice": "Your company is less than a day old, so we will ask you to confirm you are a person before the client's report email goes out. This applies until {unlockAt}. The report goes live now.", "editor_publish_warning": "Warning: Only {rated} of {total} items have been rated ({pct}% complete).", "editor_publish_stat_items_rated": "Items rated", "editor_publish_stat_completion": "Completion", diff --git a/messages/en/misc.json b/messages/en/misc.json index 2da3bb95e..e43ff2c4f 100644 --- a/messages/en/misc.json +++ b/messages/en/misc.json @@ -254,6 +254,10 @@ "load_failed_generic": "This could not be loaded, so it may be incomplete. Reload before treating it as empty.", "media_gallery_unavailable": "Photos could not be loaded.", "load_failed_named": "{what} could not be loaded, so this may be incomplete. Reload before treating it as empty.", - "outbound_cooling_notice": "Your company is less than a day old, so client email on our shared sender — reports, agreements, invoices and automations — unlocks {unlockAt}. Everything else works now: inspections, reports, scheduling, sign-in email and team invitations.", - "outbound_cooling_notice_action": "Use your own email provider" + "outbound_cooling_notice": "Your company is less than a day old, so before client email goes out on our shared sender — reports, agreements, invoices and automations — we ask you to confirm you are a person. This applies until {unlockAt}. Everything else works now, and connecting your own email provider removes the check.", + "outbound_cooling_notice_action": "Use your own email provider", + "outbound_verify_title": "Confirm you are a person", + "outbound_verify_body": "Your company is new, so we check once before each client email goes out on our shared sender. It only takes a moment.", + "outbound_verify_cancel": "Cancel", + "outbound_verify_load_failed": "The check could not load. Check your connection and try again." } diff --git a/messages/es-419/editor-2.json b/messages/es-419/editor-2.json index 3a96c94c9..907ddaaeb 100644 --- a/messages/es-419/editor-2.json +++ b/messages/es-419/editor-2.json @@ -188,7 +188,7 @@ "editor_publish_publishing": "Publicando…", "editor_publish_now": "Publicar ahora", "editor_publish_body": "Publicar finalizará esta inspección y pondrá el informe a disposición de los clientes.", - "editor_publish_cooling_notice": "Su empresa tiene menos de un día, así que el correo del informe al cliente esperará hasta {unlockAt} y luego se enviará solo; no hay que repetir nada. El informe queda activo ahora.", + "editor_publish_cooling_notice": "Su empresa tiene menos de un día, así que le pediremos confirmar que es una persona antes de enviar el correo del informe al cliente. Esto aplica hasta el {unlockAt}. El informe se publica ahora.", "editor_publish_warning": "Advertencia: solo se han calificado {rated} de {total} elementos ({pct}% completo).", "editor_publish_stat_items_rated": "Elementos calificados", "editor_publish_stat_completion": "Avance", diff --git a/messages/es-419/misc.json b/messages/es-419/misc.json index 73f6e0d68..053820b76 100644 --- a/messages/es-419/misc.json +++ b/messages/es-419/misc.json @@ -254,6 +254,10 @@ "load_failed_generic": "Esto no se pudo cargar, así que puede estar incompleto. Vuelva a cargar antes de darlo por vacío.", "media_gallery_unavailable": "No se pudieron cargar las fotos.", "load_failed_named": "No se pudo cargar {what}, así que esto puede estar incompleto. Vuelva a cargar antes de darlo por vacío.", - "outbound_cooling_notice": "Su empresa tiene menos de un día, así que el correo a clientes desde nuestro remitente compartido — informes, acuerdos, facturas y automatizaciones — se habilita el {unlockAt}. Todo lo demás ya funciona: inspecciones, informes, agenda, correo de inicio de sesión e invitaciones de equipo.", - "outbound_cooling_notice_action": "Use su propio proveedor de correo" + "outbound_cooling_notice": "Su empresa tiene menos de un día, así que antes de enviar correo a clientes desde nuestro remitente compartido — informes, acuerdos, facturas y automatizaciones — le pedimos confirmar que es una persona. Esto aplica hasta el {unlockAt}. Todo lo demás ya funciona, y conectar su propio proveedor de correo elimina la verificación.", + "outbound_cooling_notice_action": "Use su propio proveedor de correo", + "outbound_verify_title": "Confirme que es una persona", + "outbound_verify_body": "Su empresa es nueva, así que verificamos una vez antes de cada correo a clientes desde nuestro remitente compartido. Solo toma un momento.", + "outbound_verify_cancel": "Cancelar", + "outbound_verify_load_failed": "No se pudo cargar la verificación. Revise su conexión e inténtelo de nuevo." } diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 174ed9d56..0b607d42f 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -1,6 +1,6 @@ { - "app/routes/inspection-edit.tsx": 2602, - "app/routes/inspector-portal.tsx": 1393, + "app/routes/inspection-edit.tsx": 2605, + "app/routes/inspector-portal.tsx": 1396, "scripts/verify-statutory-render.mjs": 1283, "server/durable-objects/inspection-doc.ts": 1028, "app/routes/inspections.tsx": 929, @@ -60,7 +60,7 @@ "scripts/lib/gate-registry.mjs": 465, "app/components/inspection/PeopleEditor.tsx": 463, "scripts/check-retention-manifest.mjs": 462, - "server/lib/middleware/di.ts": 460, + "server/lib/middleware/di.ts": 464, "server/lib/db/schema/tenant/core.ts": 459, "server/api/calendar.ts": 453, "app/components/portal/sections/RepairBuilderSection.tsx": 450, diff --git a/server/api/session-context.ts b/server/api/session-context.ts index f6949ca73..dc8cbaa23 100644 --- a/server/api/session-context.ts +++ b/server/api/session-context.ts @@ -58,6 +58,7 @@ import { getBaseUrl } from '../lib/url'; import { resolveTenantLegalUrls, type LegalMode } from '../lib/legal-links'; import { resolveVideoProvider, videoStreamServiceable } from '../services/video/resolve'; import { unlockAtMs } from '../lib/email/outbound-cooling-window'; +import { resolveTurnstileSiteKey } from '../lib/middleware/bot-protection'; /** * Portal #98 item 3 — is the outbound cooling window OPEN for this viewer, and @@ -287,7 +288,7 @@ const sessionContextRoutes = createApiRouter() // Fail-open, matching the send gate: an unreadable anchor leaves this // null, so a D1 blip never puts a banner in front of someone whose sends // work fine. - let outboundCoolingWindow: { unlockAtMs: number } | null = null; + let outboundCoolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null = null; if (tenantId && profile.mode === 'saas') { try { const tenantRow = await getDrizzle(c) @@ -295,9 +296,12 @@ const sessionContextRoutes = createApiRouter() .from(tenants) .where(eq(tenants.id, tenantId)) .get(); - outboundCoolingWindow = resolveCoolingWindowForSession({ + const open = resolveCoolingWindowForSession({ mode: profile.mode, createdAt: tenantRow?.createdAt, nowMs: Date.now(), }); + // The site key rides along so the web app can ask for the + // challenge before a send, matched to the secret the API verifies with. + outboundCoolingWindow = open ? { ...open, turnstileSiteKey: resolveTurnstileSiteKey(c.env) } : null; } catch (e) { logger.warn('[session-context] cooling-window anchor read failed', { error: (e as Error).message }); } diff --git a/server/lib/email/build-email-service.ts b/server/lib/email/build-email-service.ts index 2aac03152..2bddfcd7a 100644 --- a/server/lib/email/build-email-service.ts +++ b/server/lib/email/build-email-service.ts @@ -12,7 +12,7 @@ import { buildEmailSuppression } from './suppression'; import { buildEmailDedupe } from './dedupe'; import { buildNotificationPreferences } from '../notifications/preference-port'; import { buildUnsubscribeLinks } from '../notifications/unsubscribe-footer'; -import { buildOutboundCoolingWindow, coolingWindowApplies } from './outbound-cooling-window'; +import { buildOutboundCoolingWindow, coolingWindowApplies, type HumanVerifier } from './outbound-cooling-window'; import { getDeploymentProfile } from '../deployment-profile'; import { logger } from '../logger'; import { memoOnce } from '../request-scope'; @@ -116,7 +116,7 @@ export function assembleTenantEmailService( cfg: LoadedEmailConfig, meterTenantId?: string, quotaGuard?: PlanQuotaGuard, tenantTier?: string, - options: { recipientInitiated?: boolean } = {}, + options: { recipientInitiated?: boolean; humanVerified?: HumanVerifier } = {}, ): EmailService { const { emailIdentity, emailBrand, dbSecrets, emailOverrides, emailByoProvider } = cfg; // The ONE place an absent answer becomes `false` (a platform-default @@ -264,7 +264,7 @@ export function assembleTenantEmailService( // here with no cast, which is the whole point of that narrowing. const profile = getDeploymentProfile(env); const cooling = meterTenantId && coolingWindowApplies({ profile, platformFunded: !isByo, recipientInitiated: options.recipientInitiated }) - ? buildOutboundCoolingWindow(env.DB, meterTenantId) + ? buildOutboundCoolingWindow(env.DB, meterTenantId, Date.now, options.humanVerified) : undefined; // TEST-ONLY email sink (E2E). Capture every message to KV instead of diff --git a/server/lib/email/human-verifier.ts b/server/lib/email/human-verifier.ts new file mode 100644 index 000000000..12e83e138 --- /dev/null +++ b/server/lib/email/human-verifier.ts @@ -0,0 +1,22 @@ +import type { Context } from 'hono'; +import type { HumanVerifier } from './outbound-cooling-window'; +import { resolveTurnstile, verifyTurnstile } from '../middleware/bot-protection'; + +/** + * The request's Turnstile proof, if the web app attached one (`x-turnstile-token`, + * relayed by the worker entry from the browser's one-time cookie). + * + * Verified lazily and at most once: only a send inside a new company's cooling + * window ever asks, so most requests never pay for the siteverify call. Any + * failure to verify reads as "not a person", never as a pass. + */ +export function buildHumanVerifier(c: Context): HumanVerifier | undefined { + const token = c.req.header('x-turnstile-token')?.trim(); + if (!token) return undefined; + let once: Promise | undefined; + return () => (once ??= (async () => { + const policy = resolveTurnstile(c.env); + if (!policy.enforced) return false; + try { return await verifyTurnstile(token, policy.secret); } catch { return false; } + })()); +} diff --git a/server/lib/email/outbound-cooling-window.ts b/server/lib/email/outbound-cooling-window.ts index 7866d7cbf..4511d3dd5 100644 --- a/server/lib/email/outbound-cooling-window.ts +++ b/server/lib/email/outbound-cooling-window.ts @@ -18,6 +18,11 @@ * exempt for the same reason — which is also what makes the escape hatch * in the UI notice a real one rather than a consolation. * + * INSIDE THE WINDOW a send is allowed when the request carries a fresh Turnstile + * token (`HumanVerifier`). The window itself stays: it is the observation + * period, and what it no longer does is stop a person who has just passed a + * challenge. A request that cannot present a token is refused as before. + * * WHY THE COST IS DELIVERABILITY, NOT SENDS. `inspectorhub.io` publishes * `p=reject` with Resend and SES aligned. The damage from 50 free emails per * throwaway company is what it does to paying customers' report email, not @@ -139,6 +144,14 @@ export function unlockAtMs(tenantCreatedAtMs: number): number { * does not, so "off" is expressed by absence rather than by a branch inside * the transport. */ +/** + * Resolves true when the request in flight carries a Turnstile token that + * Cloudflare accepts. Built per request (see `diMiddleware`) and memoised, so a + * request that sends several messages pays for one verification, and a request + * that never reaches a gated send pays for none. + */ +export type HumanVerifier = () => Promise; + export interface OutboundCoolingPort { check(classId: string | undefined): Promise; } @@ -162,6 +175,7 @@ export function buildOutboundCoolingWindow( db: D1Database, tenantId: string, now: () => number = Date.now, + humanVerified?: HumanVerifier, ): OutboundCoolingPort { return { async check(classId) { @@ -190,6 +204,12 @@ export function buildOutboundCoolingWindow( const unlock = unlockAtMs(createdAt.getTime()); if (now() >= unlock) return; + // Inside the window a person may still send, by proving they are one. + // A request with no token (an API or MCP client, or a browser that + // has not been through the challenge) falls through to the refusal, + // whose code is what tells the web app to ask for the challenge. + if (humanVerified && await humanVerified()) return; + throw Errors.OutboundCoolingWindow({ unlockAtMs: unlock, windowHours: COOLING_WINDOW_HOURS }); }, }; diff --git a/server/lib/email/tenant-email-resolver.ts b/server/lib/email/tenant-email-resolver.ts index 69f875dda..24d3abbcd 100644 --- a/server/lib/email/tenant-email-resolver.ts +++ b/server/lib/email/tenant-email-resolver.ts @@ -2,6 +2,7 @@ import type { EmailService } from '../../services/email.service'; import { readTenantPlan, type PlanQuotaGuard } from '../../features/plan-quota/guard'; import { memoOnce } from '../request-scope'; import { assembleTenantEmailService, loadTenantEmailConfig, type EmailServiceEnv } from './build-email-service'; +import type { HumanVerifier } from './outbound-cooling-window'; /** * Explicit per-tenant email resolution for one request. @@ -53,6 +54,8 @@ export interface TenantEmailResolverDeps { /** Whether this deployment meters tenants against a usage allowance. */ hasUsageQuota: boolean; buildPlanQuota: () => PlanQuotaGuard | undefined; + /** Present when the request carries a Turnstile token; lets a new company send inside its cooling window. */ + humanVerified?: HumanVerifier; } export function createTenantEmailResolver(deps: TenantEmailResolverDeps): TenantEmailResolver { @@ -72,7 +75,7 @@ export function createTenantEmailResolver(deps: TenantEmailResolverDeps): Tenant const plan = deps.hasUsageQuota ? await memoOnce(deps.env, `plan:${tenantId}`, () => readTenantPlan(deps.env.DB, tenantId)).catch(() => null) : null; - return assembleTenantEmailService(deps.env, cfg, tenantId, deps.buildPlanQuota(), plan?.tier, { recipientInitiated }); + return assembleTenantEmailService(deps.env, cfg, tenantId, deps.buildPlanQuota(), plan?.tier, { recipientInitiated, ...(deps.humanVerified ? { humanVerified: deps.humanVerified } : {}) }); }; return { diff --git a/server/lib/errors.ts b/server/lib/errors.ts index e06ef574c..970e5e791 100644 --- a/server/lib/errors.ts +++ b/server/lib/errors.ts @@ -137,7 +137,7 @@ export const Errors = { new AppError( 403, ErrorCode.OUTBOUND_COOLING_WINDOW, - `New companies cannot send client email on the shared sender for the first ${details.windowHours} hours. Everything else works, and connecting your own email provider removes the wait entirely.`, + `New companies must confirm they are a person before sending client email on the shared sender during the first ${details.windowHours} hours. Do it in the web app, or connect your own email provider to remove the check entirely.`, details, ), /** diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts index 733412c02..fc9fe5790 100644 --- a/server/lib/middleware/di.ts +++ b/server/lib/middleware/di.ts @@ -16,6 +16,7 @@ import { LegalVersionService } from '../../services/legal-version.service'; import { drizzle } from 'drizzle-orm/d1'; import { assembleTenantEmailService, loadTenantEmailConfig, type LoadedEmailConfig } from '../email/build-email-service'; import { createTenantEmailResolver } from '../email/tenant-email-resolver'; +import { buildHumanVerifier } from '../email/human-verifier'; import { InspectionService } from '../../services/inspection.service'; import type { ImagesBinding } from '../media/strip-exif'; import { PortalService } from '../../services/portal.service'; @@ -114,7 +115,9 @@ export async function diMiddleware(c: Context, next: Next) { // One place decides own-vs-platform Resend + branded renderer, shared with // non-request contexts (workflows/scheduled) via assembleTenantEmailService. - const buildEmailService = () => assembleTenantEmailService(c.env, emailCfg, c.get('tenantId'), buildPlanQuota(), tenantTierForQuota); + const humanVerified = buildHumanVerifier(c); + const emailOptions = humanVerified ? { humanVerified } : {}; + const buildEmailService = () => assembleTenantEmailService(c.env, emailCfg, c.get('tenantId'), buildPlanQuota(), tenantTierForQuota, emailOptions); // Build the core->portal outbox sink, gated on the SYNC_QUEUE producer // binding — the transport itself. No queue → no sink → append() no-ops: @@ -238,6 +241,7 @@ export async function diMiddleware(c: Context, next: Next) { Object.assign(target, createTenantEmailResolver({ env: c.env, loadedTenantId: emailCfgFor, loadedService: () => (target.email ??= buildEmailService()), fixedTenantId: c.var.profile.fixedTenantId, hasUsageQuota: c.var.profile.hasUsageQuota, buildPlanQuota, + ...(humanVerified ? { humanVerified } : {}), })); break; case 'inspection': diff --git a/tests/unit/email/human-token-relay.spec.ts b/tests/unit/email/human-token-relay.spec.ts new file mode 100644 index 000000000..d8a8f3879 --- /dev/null +++ b/tests/unit/email/human-token-relay.spec.ts @@ -0,0 +1,41 @@ +/** + * The worker entry copies the browser's one-time Turnstile cookie onto the + * in-process API call as a header. Only a mutation carries it, and only a + * well-formed value does. + */ +import { describe, it, expect } from 'vitest'; +import { humanTokenFromCookie, withHumanToken, HUMAN_TOKEN_HEADER } from '../../../workers/human-token'; + +const TOKEN = '0.AbCdEfGhIjKlMnOpQrStUvWx-_yz'; +const cookie = `a=1; oi-human-token=${TOKEN}; b=2`; +const post = () => new Request('https://x.test/api/inspections/1/send', { method: 'POST', body: '{}' }); + +describe('humanTokenFromCookie', () => { + it('reads the token out of a cookie header', () => { + expect(humanTokenFromCookie(cookie)).toBe(TOKEN); + }); + it('returns null when absent, empty or malformed', () => { + expect(humanTokenFromCookie(undefined)).toBeNull(); + expect(humanTokenFromCookie('a=1')).toBeNull(); + expect(humanTokenFromCookie('oi-human-token=short')).toBeNull(); + expect(humanTokenFromCookie('oi-human-token=has space and