diff --git a/CLAUDE.md b/CLAUDE.md index a99e5ad8..44956f70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -589,6 +589,24 @@ export async function myAction(input: MyInput): Promise { **This is the most important housekeeping rule in the codebase.** +There are now TWO type files, and a migration touches both: + +- `types/database.generated.ts` — GENERATED from the live schema, never + hand-edited. Regenerate with + `npx supabase gen types typescript --project-id vpmznjktllhmmbfnxuvk > types/database.generated.ts` + (or the Supabase MCP `generate_typescript_types` tool). It owns `Json` and + `Database`; `types/database.ts` re-exports both from it. It exists because + the hand-written interfaces do not satisfy postgrest-js's `GenericSchema` + constraint, which is why `lib/supabase/server.ts` still omits the + `` generic and no `.from()`/`.rpc()` call is type-checked yet — + see the comment in that file for the remaining work. +- `types/database.ts` — hand-written named interfaces (`Property`, + `WorkOrder`, `MemberRole`, …), the app's import surface. Diffed against the + live schema on 2026-08-02 and accurate: the only differences were two + PostgREST embed aliases (not columns) and the deliberately-omitted + deprecated `work_orders.assigned_crew_id`. `scripts/check-type-drift.mjs` + keeps it honest. + Whenever a DB migration adds or changes a column, update `types/database.ts` in the same commit. The Supabase TypeScript client infers return types from this file — not from the live database schema. A column that exists in the DB diff --git a/app/(dashboard)/inventory/actions.ts b/app/(dashboard)/inventory/actions.ts index f81d829c..d1beb6c3 100644 --- a/app/(dashboard)/inventory/actions.ts +++ b/app/(dashboard)/inventory/actions.ts @@ -7,7 +7,8 @@ import { logAuditEvent } from '@/lib/audit' import { reportError } from '@/lib/observability/report-error' import { unwrapJoin } from '@/lib/utils/supabase-joins' import { fetchAllRows } from '@/lib/inngest/paginate' -import type { InventoryCategory } from '@/types/database' +import type { InventoryCategory, TablesInsert, TablesUpdate } from '@/types/database' +import { Constants } from '@/types/database' /** * Deterministic, locale-independent string ordering for CANONICALISATION. @@ -58,6 +59,18 @@ export async function updateParLevel( } } +/** + * Narrow a free-text category to the inventory_category enum the column + * accepts, falling back to the column's own default. + * + * The valid labels come from Constants (generated from the live schema), not a + * hand-written list — a second copy of an enum is a copy that drifts. + */ +function toInventoryCategory(value: string | null): InventoryCategory { + const valid: readonly string[] = Constants.public.Enums.inventory_category + return value !== null && valid.includes(value) ? (value as InventoryCategory) : 'other' +} + // ── Add inventory items (bulk) ─────────────────────────────────────────────── export async function addInventoryItems( @@ -452,20 +465,11 @@ export async function applyTemplateToProperties( } let applied = 0 - const allToInsert: Array<{ - property_id: string - org_id: string - catalog_item_id: string | null - source_template_id: string - name: string - category: string - unit: string - par_level: number - current_quantity: number - low_stock_threshold_pct: number - is_active: boolean - preferred_brand: string | null - }> = [] + // TablesInsert, not a hand-written shape: the previous annotation declared + // `category: string`, which widened the inventory_category enum the column + // actually accepts. Deriving the payload type from the schema means the + // narrowing is checked here rather than discovered by PostgREST. + const allToInsert: Array> = [] for (const propertyId of targetPropertyIds) { const existing = existingByProperty[propertyId] ?? { catalogIds: new Set(), names: new Set() } @@ -482,8 +486,16 @@ export async function applyTemplateToProperties( catalog_item_id: item.catalog_item_id ?? null, source_template_id: templateId, name: item.name, - category: item.category, - unit: item.unit, + // inventory_template_items.category/unit are NULLABLE TEXT; + // inventory_items.category/unit are NOT NULL (category is the + // inventory_category enum). Copying one straight into the other let + // a NULL or an off-enum string reach the insert, where Postgres + // would reject it — and because this is a BULK insert, one bad + // template row would fail the whole application, for every selected + // property at once. The fallbacks are the column defaults declared + // in the schema ('other' / 'units'), not invented values. + category: toInventoryCategory(item.category), + unit: item.unit ?? 'units', par_level: item.par_level, current_quantity: 0, low_stock_threshold_pct: 20, @@ -685,7 +697,7 @@ export async function updatePurchaseOrderStatus( if (!po) return { error: 'Purchase order not found' } if (po.status === status) return {} - const statusUpdate: Record = { status } + const statusUpdate: TablesUpdate<'purchase_orders'> = { status } if (status === 'sent') statusUpdate.sent_at = new Date().toISOString() const { data: updated, error } = await supabase diff --git a/app/(dashboard)/maintenance/actions.ts b/app/(dashboard)/maintenance/actions.ts index edeeea5c..eefcf7dd 100644 --- a/app/(dashboard)/maintenance/actions.ts +++ b/app/(dashboard)/maintenance/actions.ts @@ -8,7 +8,7 @@ import { calcNextDueDate } from '@/lib/turnovers/generator' import { fetchAllRows } from '@/lib/inngest/paginate' import { logAuditEvent } from '@/lib/audit' import { reportError } from '@/lib/observability/report-error' -import type { WoStatus, WoCategory, ScheduleFrequency, ScheduleType, VendorSpecialty } from '@/types/database' +import type { WoStatus, WoCategory, ScheduleFrequency, ScheduleType, VendorSpecialty, TablesUpdate } from '@/types/database' import { PriorityLevelSchema, WoStatusSchema, WoCategorySchema } from '@/lib/schemas/work-order' import { resolveWorkOrderStatus, @@ -506,7 +506,7 @@ export async function updateWorkOrderStatus( return { error: 'This work order is assigned to a vendor — it must be completed through the vendor portal so the invoice and payment can be generated.' } } - const update: Record = status === 'completed' + const update: TablesUpdate<'work_orders'> = status === 'completed' ? workOrderCompletionFields(notes ?? null) : { status } diff --git a/app/(dashboard)/templates/inventory/actions.ts b/app/(dashboard)/templates/inventory/actions.ts index 2d69ecad..750599ea 100644 --- a/app/(dashboard)/templates/inventory/actions.ts +++ b/app/(dashboard)/templates/inventory/actions.ts @@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache' import { requireOrgRole } from '@/lib/auth' import { logAuditEvent } from '@/lib/audit' import { reportError } from '@/lib/observability/report-error' -import type { InventoryCategory } from '@/types/database' +import type { InventoryCategory, TablesUpdate } from '@/types/database' // ── Master List (org_inventory_catalog) ───────────────────────────────────── @@ -58,7 +58,7 @@ export async function updateCatalogItem( try { const { user, supabase, membership } = await requireOrgRole(['admin', 'manager']) - const patch: Record = {} + const patch: TablesUpdate<'org_inventory_catalog'> = {} if (updates.name !== undefined) { const trimmed = updates.name.trim() if (!trimmed) return { error: 'Item name is required.' } diff --git a/app/(dashboard)/templates/maintenance/actions.ts b/app/(dashboard)/templates/maintenance/actions.ts index 51848e78..39c8c78f 100644 --- a/app/(dashboard)/templates/maintenance/actions.ts +++ b/app/(dashboard)/templates/maintenance/actions.ts @@ -5,7 +5,7 @@ import { requireOrgRole } from '@/lib/auth' import { logAuditEvent } from '@/lib/audit' import { reportError } from '@/lib/observability/report-error' import { unwrapJoin } from '@/lib/utils/supabase-joins' -import type { ScheduleFrequency, VendorSpecialty } from '@/types/database' +import type { ScheduleFrequency, VendorSpecialty, TablesUpdate } from '@/types/database' // Item-level CRUD for maintenance_schedule_template_items — didn't exist // before this pass (createMaintenanceScheduleTemplate only inserts items at @@ -103,7 +103,7 @@ export async function updateMaintenanceTemplateItem( const template = unwrapJoin(item.maintenance_schedule_templates) if (template?.is_system) return { error: 'System templates cannot be edited.' } - const patch: Record = {} + const patch: TablesUpdate<'maintenance_schedule_template_items'> = {} if (updates.name !== undefined) { const trimmed = updates.name.trim() if (!trimmed) return { error: 'Item name is required.' } diff --git a/app/(dashboard)/turnovers/actions.ts b/app/(dashboard)/turnovers/actions.ts index 9e69327c..deb3f564 100644 --- a/app/(dashboard)/turnovers/actions.ts +++ b/app/(dashboard)/turnovers/actions.ts @@ -7,6 +7,7 @@ import { inngest, sendEventAsync } from '@/lib/inngest/client' import { logAuditEvent } from '@/lib/audit' import { unwrapJoin } from '@/lib/utils/supabase-joins' import { reportError } from '@/lib/observability/report-error' +import type { TablesUpdate } from '@/types/database' export type TurnoverActionState = { error?: string; success?: boolean; warning?: string } @@ -306,7 +307,7 @@ export async function updateTurnoverStatus( try { const { supabase, membership, user } = await requireOrgMember() - const update: Record = { status } + const update: TablesUpdate<'turnovers'> = { status } const completedAt = new Date().toISOString() if (status === 'in_progress') { update.started_at = completedAt diff --git a/app/api/account/delete/route.ts b/app/api/account/delete/route.ts index af02f1f2..dd9a9cd3 100644 --- a/app/api/account/delete/route.ts +++ b/app/api/account/delete/route.ts @@ -8,6 +8,7 @@ import { logAuditEvents } from '@/lib/audit' import { revokeIntegrationToken } from '@/lib/integrations/vault' import { stripe } from '@/lib/stripe/client' import { reportError } from '@/lib/observability/report-error' +import type { TablesUpdate } from '@/types/database' type Admin = ReturnType @@ -173,7 +174,12 @@ async function cancelOrgSubscriptions( } if (!org) return null - const subs: Array<{ id: string; column: string; site: string }> = [] + // `column` is a literal union, not `string`: it is used to index the typed + // organizations update payload below, and a plain `string` index would make + // that payload implicitly `any` — quietly giving up the checking this write + // just gained. + type SubscriptionColumn = 'stripe_subscription_id' | 'repuguard_stripe_subscription_id' + const subs: Array<{ id: string; column: SubscriptionColumn; site: string }> = [] if (org.stripe_subscription_id) { subs.push({ id: org.stripe_subscription_id as string, @@ -191,7 +197,7 @@ async function cancelOrgSubscriptions( if (!subs.length) return null - const cleared: Record = {} + const cleared: TablesUpdate<'organizations'> = {} for (const sub of subs) { try { await stripe.subscriptions.cancel(sub.id) diff --git a/lib/checklists/seed-default-room-templates.ts b/lib/checklists/seed-default-room-templates.ts index b2c80054..7593c8f5 100644 --- a/lib/checklists/seed-default-room-templates.ts +++ b/lib/checklists/seed-default-room-templates.ts @@ -3,6 +3,7 @@ import { reportError } from '@/lib/observability/report-error' import { fetchAllRows } from '@/lib/inngest/paginate' import { createServiceClient } from '@/lib/supabase/server' import { logAuditEvent } from '@/lib/audit' +import type { TablesUpdate } from '@/types/database' type ServiceClient = ReturnType @@ -172,7 +173,7 @@ export async function seedDefaultRoomTemplatesIfNeeded(orgId: string): Promise = {} + const mappingUpdates: TablesUpdate<'organizations'> = {} if (results['Bedroom']?.created) mappingUpdates.bedroom_room_template_id = results['Bedroom'].id if (results['Bathroom']?.created) mappingUpdates.bathroom_room_template_id = results['Bathroom'].id diff --git a/lib/guidebook/sync.ts b/lib/guidebook/sync.ts index c13c327e..2fa2c415 100644 --- a/lib/guidebook/sync.ts +++ b/lib/guidebook/sync.ts @@ -1,5 +1,6 @@ import { createServiceClient } from '@/lib/supabase/server' import { generateBaseSlug, generateUniqueSlugsForProperties } from '@/lib/guidebook/slug' +import type { TablesUpdate } from '@/types/database' /** * Ensures an org has a guidebook_configurations row, starting the 30-day @@ -136,7 +137,7 @@ export async function syncGuidebookConfigsFromProperty( const config = configByPropertyId.get(prop.id) if (!config) return // no guidebook config yet — createGuidebookPropertyConfigsForProperties handles creation - const patch: Record = {} + const patch: TablesUpdate<'guidebook_property_configs'> = {} if (!config.wifi_network && prop.wifi_name) patch.wifi_network = prop.wifi_name if (!config.wifi_password && prop.wifi_password) patch.wifi_password = prop.wifi_password diff --git a/lib/inngest/functions/asset-scan.ts b/lib/inngest/functions/asset-scan.ts index 46012634..ff758313 100644 --- a/lib/inngest/functions/asset-scan.ts +++ b/lib/inngest/functions/asset-scan.ts @@ -23,6 +23,7 @@ import { inngest } from '@/lib/inngest/client' import { createServiceClient } from '@/lib/supabase/server' import { scanDataPlateImage, isValidScanMediaType } from '@/lib/assets/scan-data-plate' import { toStorageObjectPath } from '@/lib/storage/object-path' +import type { TablesUpdate } from '@/types/database' // PRIVATE bucket — downloads here go through the service-role client, which // bypasses both the bucket's public flag and its RLS policies, so this works @@ -83,7 +84,7 @@ export const assetDataPlateScan = inngest.createFunction( if (!asset) return const found = Boolean(result.make || result.model || result.serial_number) - const updates: Record = {} + const updates: TablesUpdate<'property_assets'> = {} // Never downgrade an already-completed scan — a duplicate/retried run // disagreeing on `found` (LLM output isn't perfectly deterministic) diff --git a/lib/inngest/functions/ownerrez/initial-sync.ts b/lib/inngest/functions/ownerrez/initial-sync.ts index 17eec9d5..6d5db8f0 100644 --- a/lib/inngest/functions/ownerrez/initial-sync.ts +++ b/lib/inngest/functions/ownerrez/initial-sync.ts @@ -39,6 +39,7 @@ import { syncGuidebookConfigsFromProperty, } from '@/lib/guidebook/sync' import { mergeIntegrationConnectionMetadata } from '@/lib/integrations/connection-metadata' +import type { TablesUpdate } from '@/types/database' import { reportError } from '@/lib/observability/report-error' const PROVIDER = 'ownerrez' @@ -172,7 +173,7 @@ export const ownerRezInitialSync = inngest.createFunction( const orData = fetchPropsResult.patchData.find((p) => p.externalId === existing.external_id) if (!orData) continue - const patch: Record = {} + const patch: TablesUpdate<'properties'> = {} // === null (not a falsy check) on all three — a falsy check also // matches a legitimate 0 (e.g. a studio's bedroom count), which diff --git a/lib/inngest/functions/turnover-events.ts b/lib/inngest/functions/turnover-events.ts index 8c559e34..7a3c0127 100644 --- a/lib/inngest/functions/turnover-events.ts +++ b/lib/inngest/functions/turnover-events.ts @@ -2,11 +2,9 @@ import { inngest } from '@/lib/inngest/client' import { reportError } from '@/lib/observability/report-error' import { createServiceClient } from '@/lib/supabase/server' import { resend, FROM } from '@/lib/resend/client' -import { getPmEmails, createPmNotification } from '@/lib/inngest/helpers' +import { createPmNotification } from '@/lib/inngest/helpers' import { formatPropertyDateTime } from '@/lib/utils/timezone' import { renderPmAlert } from '@/lib/resend/emails/pm-alert' -import { assetTypeDisplayName, missingAssetTypesFromDiscoveredSet } from '@/lib/asset-discovery/config' -import type { AssetType } from '@/types/database' import { logAuditEvent } from '@/lib/audit' import { incrementCounter } from '@/lib/observability/metrics' import { unwrapJoin, unwrapJoinArray } from '@/lib/utils/supabase-joins' @@ -147,48 +145,21 @@ export const handleTurnoverCompleted = inngest.createFunction( }) }) - await step.run('notify-pm-of-open-mandatory-items', async () => { - const supabase = createServiceClient({ system: 'inngest:turnover-events' }) - - const { data: assets } = await supabase - .from('property_assets') - .select('asset_type, make, model, photo_url, is_na') - .eq('property_id', property_id) - .eq('org_id', org_id) - .eq('is_active', true) - - const discoveredTypes = new Set( - (assets ?? []) - .filter((a) => a.is_na === true || a.make !== null || a.model !== null || a.photo_url !== null) - .map((a) => a.asset_type as AssetType) - ) - const missingTypes = missingAssetTypesFromDiscoveredSet(discoveredTypes) - - if (!missingTypes.length) return { skipped: 'none_missing' } - - const [{ data: property }, pmEmails] = await Promise.all([ - supabase.from('properties').select('name').eq('id', property_id).eq('org_id', org_id).single(), - getPmEmails(supabase, org_id), - ]) - const [pmEmail] = pmEmails - - if (!pmEmail) return { skipped: 'no_pm_email' } - - await resend.emails.send({ - from: FROM, - to: pmEmail, - subject: `⚠️ ${missingTypes.length} asset${missingTypes.length !== 1 ? 's' : ''} still need discovery — ${property?.name}`, - html: await renderPmAlert({ - heading: 'Asset discovery still incomplete', - body: `The crew marked this turnover complete, but ${missingTypes.length} required asset${missingTypes.length !== 1 ? 's haven\'t' : ' hasn\'t'} been discovered yet at ${property?.name}.`, - details: missingTypes.map((t) => ({ label: assetTypeDisplayName(t), value: 'Not yet captured' })), - ctaLabel: 'View Property Assets →', - ctaUrl: `${process.env.NEXT_PUBLIC_APP_URL}/assets`, - }), - }, { idempotencyKey: `turnover-completed-mandatory-open-${turnover_id}` }) - - return { notified: true, missing_count: missingTypes.length } - }) + // REMOVED: the per-turnover "N assets still need discovery" email. + // + // It fired immediately on every completed turnover, to the first PM email, + // whenever any required asset type was still undiscovered at that property. + // The daily wrap-up already reports exactly this: cron/daily-wrapup.ts + // builds `checklistSection` from the SAME predicate over the SAME columns + // (missingAssetTypesFromDiscoveredSet over the is_na/make/model/photo_url + // filter), per property, once a day. + // + // So this was the same number delivered twice — but the per-turnover copy + // arrived on a trigger the PM cannot act on differently (asset discovery is + // not a turnover task) and at a rate set by turnover volume, which is + // exactly the shape that trains people to filter a sender. Deleted rather + // than made conditional: there is no threshold at which a duplicate of the + // wrap-up's own content is worth its own send. await step.run('record-completion-milestones', async () => { const supabase = createServiceClient({ system: 'inngest:turnover-events' }) diff --git a/lib/integrations/providers/hospitable-owner.ts b/lib/integrations/providers/hospitable-owner.ts index 36954aba..3fcd7964 100644 --- a/lib/integrations/providers/hospitable-owner.ts +++ b/lib/integrations/providers/hospitable-owner.ts @@ -45,7 +45,14 @@ export interface ResolvedHospitableOwner { } /** Domain table + column that stores each entity kind's provider-side id. */ -const LOCAL_SOURCE: Record = { +// The table name is a literal union, not `string`. Typed as `string` it +// widened to "any table in the schema", so postgrest-js intersected the column +// names of all 94 of them and resolved every .eq()/.select() argument to +// `never` — the query was unverifiable rather than wrong. All three tables do +// carry org_id / external_id / external_source (checked against the live +// schema), which is what makes the shared query below legitimate; the union +// is what lets the type system confirm it. +const LOCAL_SOURCE: Record = { reservation: { table: 'bookings' }, property: { table: 'properties' }, review: { table: 'reviews' }, diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index 0575974f..a910e9bb 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -1,10 +1,30 @@ import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' -// Database['public'] doesn't satisfy postgrest-js v2.106's GenericSchema constraint -// (hand-written interfaces lack index signatures required by Record). -// We omit the type arg so Schema defaults to `any`, which allows all -// .from() queries to type-check. Replace with Supabase CLI-generated types once connected. +// The generic is still omitted, so `Schema` defaults to `any` and +// NO .from() or .rpc() call in this app is type-checked. That is a real gap, +// not a style choice: reviews.internal_notes (fixed 2026-08-02) was selected +// by a cron for months — PostgREST rejects the whole select on an unknown +// column, so the job threw on every run for every org — and nothing compared +// the select string against the schema because there was nothing to compare +// it to. +// +// The blocker used to be that types/database.ts was hand-written and its +// interfaces do not satisfy postgrest-js's GenericSchema constraint (no index +// signatures, no Relationships), so binding them collapsed every row type to +// `never`: 2267 errors, 2163 of them that one collapse. +// +// That blocker is now GONE. types/database.generated.ts is generated from the +// live schema and Database re-exports it, so wiring the generic here is: +// +// import type { Database } from '@/types/database' +// return createServerClient( +// +// Measured on that basis: 123 errors as of 2026-08-03 (was 138) — a long tail of +// insert/update payload mismatches, nullability, and Json shapes, each needing +// its own judgement rather than one mechanical fix. Wiring it is the next step +// and must land with those 138 resolved, not before; a half-wired client is +// worse than an unwired one because it looks checked. /** * Server-side Supabase client for use in: diff --git a/scripts/check-type-drift.mjs b/scripts/check-type-drift.mjs index 6a06c122..abe51c31 100644 --- a/scripts/check-type-drift.mjs +++ b/scripts/check-type-drift.mjs @@ -221,22 +221,52 @@ function parseInterfaces(text) { return ifaces } -// 3. `Database.public.Tables` map: `table_name: { Row: InterfaceName; ...` +// 3. `HandWrittenRowMap`: `table_name: InterfaceName` +// +// This used to parse `Database.public.Tables`, which carried the mapping as a +// side effect of being the postgrest schema type. When Database moved to +// types/database.generated.ts (2026-08-02) that block left this file, the +// regex matched nothing, and the gate reported all 92 tables as unmodelled — +// a 92-failure run that looked like catastrophic drift and was really a parse +// miss. It now reads a declaration whose ONLY purpose is this mapping, so it +// cannot be carried away by an unrelated refactor again. +// +// Deliberately still types/database.ts and not the generated file: the +// generated types are produced FROM the live schema, so diffing them against +// it can never fail. The hand-written interfaces are the ones that can drift. +// +// A parse that finds nothing is now a hard failure rather than 92 confusing +// ones — see the guard below. function parseTableMap(text) { - const tablesBlockMatch = text.match(/Tables:\s*\{([\s\S]*?)\n\s{4}\}\n\s{4}Views:/) - const block = tablesBlockMatch ? tablesBlockMatch[1] : text + const blockMatch = text.match(/export interface HandWrittenRowMap \{([\s\S]*?)\n\}/) + if (!blockMatch) return null const map = {} // `block` comes from this repo's own committed types/database.ts, never // attacker-controlled input, so ReDoS is not a real risk here. - const re = /^\s+(\w+):\s*\{\s*Row:\s*(\w+);/gm // NOSONAR - for (const m of block.matchAll(re)) map[m[1]] = m[2] - return map + const re = /^\s+(\w+):\s*(\w+)\s*$/gm // NOSONAR + for (const m of blockMatch[1].matchAll(re)) map[m[1]] = m[2] + return Object.keys(map).length ? map : null } const tsUnions = parseUnionTypes(src) const tsInterfaces = parseInterfaces(src) const tsTableMap = parseTableMap(src) +// Fail loudly and once on a parse miss. Without this, an empty map makes every +// live table look unmodelled and the operator sees ~92 failures describing a +// problem that does not exist — which is exactly what happened when the +// Database block moved out of this file. +if (tsTableMap === null) { + console.error( + '::error title=Type drift check could not parse::' + + 'types/database.ts has no parseable `export interface HandWrittenRowMap { ... }` ' + + 'block. That map is what tells this gate which interface models which table. ' + + 'It was probably renamed, reformatted, or moved — restore it rather than ' + + 'treating the table findings below as real drift.' + ) + process.exit(1) +} + // ── Compare ────────────────────────────────────────────────────────────────── const failures = [] diff --git a/types/database.generated.ts b/types/database.generated.ts index 1d483901..971d2005 100644 --- a/types/database.generated.ts +++ b/types/database.generated.ts @@ -1,32 +1,27 @@ /** - * FieldStay — Generated Supabase Types (Reference) + * FieldStay — Database types, GENERATED FROM THE LIVE SCHEMA. * - * Auto-generated from the live Supabase project (vpmznjktllhmmbfnxuvk). - * DO NOT EDIT BY HAND. Regenerate with: pnpm run types:supabase - * (requires a logged-in Supabase CLI: `supabase login`, or a - * SUPABASE_ACCESS_TOKEN in the environment.) + * DO NOT HAND-EDIT. Regenerate with: * - * ⚠️ STALE — generated 2026-07-16. The schema has moved on since: this file - * is missing platform_inventory_templates and everything else added by the 44 - * migrations dated after that. Regenerate before relying on it, and - * especially before wiring it into lib/supabase/server.ts. + * npx supabase gen types typescript --project-id vpmznjktllhmmbfnxuvk > types/database.generated.ts * - * Not currently imported by the app. types/database.ts is the hand-maintained - * file the codebase actually imports from (flat per-table interfaces, plus the - * domain scalar unions MemberRole/WoStatus/...). Use this file as a drift-check - * reference: when adding or changing columns, cross-check the Row/Insert/Update - * shapes here against types/database.ts. + * (or the Supabase MCP `generate_typescript_types` tool against the same + * project), in the SAME COMMIT as the migration that changed the schema — + * the rule CLAUDE.md already states for types/database.ts. * - * The intended end state is for this file to type the wire — createClient() and - * createServiceClient() in lib/supabase/server.ts currently omit the - * generic, so every .from(...).select(...) in the app resolves to `any`. - * Measured 2026-07-27: applying the generic against THIS (stale) file surfaces - * 384 type errors. A large share are artifacts of the staleness above — one - * missing column on work_orders cascades into ~40 errors in a single file — so - * regenerating is a prerequisite for getting a real number, not an optional - * first step. See scripts/check-type-drift.mjs, which already gates enum and - * column presence in CI; what the generic would add on top is nullability and - * write-shape correctness. + * WHY THIS FILE EXISTS + * types/database.ts was hand-written, and its interfaces do not satisfy + * postgrest-js's GenericSchema constraint (they lack the index signatures and + * Relationships shape it requires). That is why lib/supabase/server.ts omits + * the generic and every .from()/.rpc() call in the app is typed as + * `any`. Measured 2026-08-02: wiring to the hand-written type + * produces 2267 errors, of which 2163 are the single `never` collapse that + * failure mode causes. Wiring it to THIS file instead produces 138 — a 94% + * reduction — because row types actually resolve. + * + * types/database.ts remains the public import surface (Property, WorkOrder, + * MemberRole, …). It now derives those aliases from this file rather than + * restating them, so there is one source of truth and no drift between them. */ export type Json = @@ -96,6 +91,13 @@ export type Database = { referencedRelation: "property_assets" referencedColumns: ["id"] }, + { + foreignKeyName: "asset_depreciation_entries_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, ] } asset_manuals: { @@ -268,6 +270,13 @@ export type Database = { referencedRelation: "crew_members" referencedColumns: ["id"] }, + { + foreignKeyName: "assignment_outcomes_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, { foreignKeyName: "assignment_outcomes_property_id_fkey" columns: ["property_id"] @@ -728,6 +737,8 @@ export type Database = { id: string name: string requires_section_photo: boolean + room_synced_at: string | null + room_template_id: string | null sort_order: number template_id: string } @@ -736,6 +747,8 @@ export type Database = { id?: string name: string requires_section_photo?: boolean + room_synced_at?: string | null + room_template_id?: string | null sort_order?: number template_id: string } @@ -744,10 +757,19 @@ export type Database = { id?: string name?: string requires_section_photo?: boolean + room_synced_at?: string | null + room_template_id?: string | null sort_order?: number template_id?: string } Relationships: [ + { + foreignKeyName: "checklist_template_sections_room_template_id_fkey" + columns: ["room_template_id"] + isOneToOne: false + referencedRelation: "room_templates" + referencedColumns: ["id"] + }, { foreignKeyName: "checklist_template_sections_template_id_fkey" columns: ["template_id"] @@ -941,6 +963,13 @@ export type Database = { referencedRelation: "crew_members" referencedColumns: ["id"] }, + { + foreignKeyName: "crew_availability_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, ] } crew_feedback: { @@ -1081,6 +1110,38 @@ export type Database = { }, ] } + demo_activity_log: { + Row: { + id: string + kind: string + org_id: string + payload: Json + simulated_at: string + } + Insert: { + id?: string + kind: string + org_id: string + payload?: Json + simulated_at?: string + } + Update: { + id?: string + kind?: string + org_id?: string + payload?: Json + simulated_at?: string + } + Relationships: [ + { + foreignKeyName: "demo_activity_log_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } guidebook_configurations: { Row: { created_at: string @@ -1207,11 +1268,60 @@ export type Database = { }, ] } + guidebook_offer_redemptions: { + Row: { + booking_id: string | null + id: string + opened_at: string + org_id: string + sponsor_id: string + } + Insert: { + booking_id?: string | null + id?: string + opened_at?: string + org_id: string + sponsor_id: string + } + Update: { + booking_id?: string | null + id?: string + opened_at?: string + org_id?: string + sponsor_id?: string + } + Relationships: [ + { + foreignKeyName: "guidebook_offer_redemptions_booking_id_fkey" + columns: ["booking_id"] + isOneToOne: false + referencedRelation: "bookings" + referencedColumns: ["id"] + }, + { + foreignKeyName: "guidebook_offer_redemptions_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "guidebook_offer_redemptions_sponsor_id_fkey" + columns: ["sponsor_id"] + isOneToOne: false + referencedRelation: "guidebook_sponsors" + referencedColumns: ["id"] + }, + ] + } guidebook_property_configs: { Row: { check_in_instructions: string | null check_out_instructions: string | null created_at: string + featured_amenities: string[] | null + featured_amenity_notes: string | null + hero_photo_storage_path: string | null house_rules: string | null id: string is_published: boolean @@ -1226,6 +1336,9 @@ export type Database = { check_in_instructions?: string | null check_out_instructions?: string | null created_at?: string + featured_amenities?: string[] | null + featured_amenity_notes?: string | null + hero_photo_storage_path?: string | null house_rules?: string | null id?: string is_published?: boolean @@ -1240,6 +1353,9 @@ export type Database = { check_in_instructions?: string | null check_out_instructions?: string | null created_at?: string + featured_amenities?: string[] | null + featured_amenity_notes?: string | null + hero_photo_storage_path?: string | null house_rules?: string | null id?: string is_published?: boolean @@ -1365,6 +1481,71 @@ export type Database = { }, ] } + hospitable_launch_promo: { + Row: { + attribution_source: string | null + awarded_at: string | null + congrats_email_sent_at: string | null + converted_to_paid_at: string | null + created_at: string + hospitable_tagged: boolean + hospitable_tagged_at: string | null + org_id: string + price_lock_active: boolean + price_lock_amount_cents: number | null + price_lock_awarded: boolean + price_lock_expires_at: string | null + price_lock_sequence: number | null + price_lock_tier: string | null + price_lock_years: number | null + updated_at: string + } + Insert: { + attribution_source?: string | null + awarded_at?: string | null + congrats_email_sent_at?: string | null + converted_to_paid_at?: string | null + created_at?: string + hospitable_tagged?: boolean + hospitable_tagged_at?: string | null + org_id: string + price_lock_active?: boolean + price_lock_amount_cents?: number | null + price_lock_awarded?: boolean + price_lock_expires_at?: string | null + price_lock_sequence?: number | null + price_lock_tier?: string | null + price_lock_years?: number | null + updated_at?: string + } + Update: { + attribution_source?: string | null + awarded_at?: string | null + congrats_email_sent_at?: string | null + converted_to_paid_at?: string | null + created_at?: string + hospitable_tagged?: boolean + hospitable_tagged_at?: string | null + org_id?: string + price_lock_active?: boolean + price_lock_amount_cents?: number | null + price_lock_awarded?: boolean + price_lock_expires_at?: string | null + price_lock_sequence?: number | null + price_lock_tier?: string | null + price_lock_years?: number | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "hospitable_launch_promo_org_id_fkey" + columns: ["org_id"] + isOneToOne: true + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } ical_feeds: { Row: { created_at: string @@ -1497,6 +1678,47 @@ export type Database = { }, ] } + integration_entity_owners: { + Row: { + created_at: string + entity_kind: string + external_id: string + id: string + org_id: string + provider_id: string + resolved_via: string + updated_at: string + } + Insert: { + created_at?: string + entity_kind: string + external_id: string + id?: string + org_id: string + provider_id: string + resolved_via: string + updated_at?: string + } + Update: { + created_at?: string + entity_kind?: string + external_id?: string + id?: string + org_id?: string + provider_id?: string + resolved_via?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "integration_entity_owners_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } integration_providers: { Row: { auth_type: string @@ -1525,6 +1747,7 @@ export type Database = { Row: { category: Database["public"]["Enums"]["inventory_category"] created_at: string + default_par_level: number default_unit: string description: string | null id: string @@ -1534,6 +1757,7 @@ export type Database = { Insert: { category?: Database["public"]["Enums"]["inventory_category"] created_at?: string + default_par_level?: number default_unit?: string description?: string | null id?: string @@ -1543,6 +1767,7 @@ export type Database = { Update: { category?: Database["public"]["Enums"]["inventory_category"] created_at?: string + default_par_level?: number default_unit?: string description?: string | null id?: string @@ -1603,6 +1828,8 @@ export type Database = { notes: string | null org_id: string property_id: string + reviewed_at: string | null + reviewed_by: string | null status: string submitted_by: string | null updated_at: string @@ -1613,6 +1840,8 @@ export type Database = { notes?: string | null org_id: string property_id: string + reviewed_at?: string | null + reviewed_by?: string | null status?: string submitted_by?: string | null updated_at?: string @@ -1623,11 +1852,20 @@ export type Database = { notes?: string | null org_id?: string property_id?: string + reviewed_at?: string | null + reviewed_by?: string | null status?: string submitted_by?: string | null updated_at?: string } Relationships: [ + { + foreignKeyName: "inventory_count_drafts_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, { foreignKeyName: "inventory_count_drafts_property_id_fkey" columns: ["property_id"] @@ -1751,6 +1989,7 @@ export type Database = { par_level: number preferred_brand: string | null property_id: string + source_template_id: string | null unit: string updated_at: string } @@ -1769,6 +2008,7 @@ export type Database = { par_level?: number preferred_brand?: string | null property_id: string + source_template_id?: string | null unit?: string updated_at?: string } @@ -1787,6 +2027,7 @@ export type Database = { par_level?: number preferred_brand?: string | null property_id?: string + source_template_id?: string | null unit?: string updated_at?: string } @@ -1812,6 +2053,13 @@ export type Database = { referencedRelation: "properties" referencedColumns: ["id"] }, + { + foreignKeyName: "inventory_items_source_template_id_fkey" + columns: ["source_template_id"] + isOneToOne: false + referencedRelation: "inventory_templates" + referencedColumns: ["id"] + }, ] } inventory_template_items: { @@ -1878,6 +2126,7 @@ export type Database = { id: string name: string org_id: string + source_platform_template_id: string | null } Insert: { created_at?: string @@ -1885,6 +2134,7 @@ export type Database = { id?: string name: string org_id: string + source_platform_template_id?: string | null } Update: { created_at?: string @@ -1892,8 +2142,24 @@ export type Database = { id?: string name?: string org_id?: string + source_platform_template_id?: string | null } - Relationships: [] + Relationships: [ + { + foreignKeyName: "inventory_templates_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "inventory_templates_source_platform_template_id_fkey" + columns: ["source_platform_template_id"] + isOneToOne: false + referencedRelation: "platform_inventory_templates" + referencedColumns: ["id"] + }, + ] } maintenance_catalog_items: { Row: { @@ -1993,6 +2259,13 @@ export type Database = { referencedRelation: "properties" referencedColumns: ["id"] }, + { + foreignKeyName: "maintenance_completions_work_order_id_fkey" + columns: ["work_order_id"] + isOneToOne: false + referencedRelation: "work_orders" + referencedColumns: ["id"] + }, ] } maintenance_schedule_template_items: { @@ -2201,6 +2474,13 @@ export type Database = { referencedRelation: "properties" referencedColumns: ["id"] }, + { + foreignKeyName: "maintenance_schedules_source_catalog_item_id_fkey" + columns: ["source_catalog_item_id"] + isOneToOne: false + referencedRelation: "org_maintenance_catalog_items" + referencedColumns: ["id"] + }, { foreignKeyName: "maintenance_schedules_source_template_item_id_fkey" columns: ["source_template_item_id"] @@ -2251,6 +2531,13 @@ export type Database = { work_order_id?: string | null } Relationships: [ + { + foreignKeyName: "messages_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, { foreignKeyName: "messages_turnover_id_fkey" columns: ["turnover_id"] @@ -2370,6 +2657,63 @@ export type Database = { } Relationships: [] } + org_inventory_catalog: { + Row: { + category: Database["public"]["Enums"]["inventory_category"] + created_at: string + default_par_level: number + default_unit: string + description: string | null + id: string + is_active: boolean + name: string + org_id: string + platform_catalog_item_id: string | null + updated_at: string + } + Insert: { + category?: Database["public"]["Enums"]["inventory_category"] + created_at?: string + default_par_level?: number + default_unit?: string + description?: string | null + id?: string + is_active?: boolean + name: string + org_id: string + platform_catalog_item_id?: string | null + updated_at?: string + } + Update: { + category?: Database["public"]["Enums"]["inventory_category"] + created_at?: string + default_par_level?: number + default_unit?: string + description?: string | null + id?: string + is_active?: boolean + name?: string + org_id?: string + platform_catalog_item_id?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "org_inventory_catalog_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "org_inventory_catalog_platform_catalog_item_id_fkey" + columns: ["platform_catalog_item_id"] + isOneToOne: false + referencedRelation: "inventory_catalog" + referencedColumns: ["id"] + }, + ] + } org_invites: { Row: { accepted_at: string | null @@ -2414,107 +2758,70 @@ export type Database = { }, ] } - org_master_checklist_items: { + org_maintenance_catalog_items: { Row: { + asset_category: string | null + category: string created_at: string + description: string | null id: string + is_active: boolean + name: string org_id: string - section: string + platform_catalog_item_id: string | null sort_order: number - source: string - task: string + suggested_recurrence: string | null updated_at: string } Insert: { + asset_category?: string | null + category: string created_at?: string + description?: string | null id?: string + is_active?: boolean + name: string org_id: string - section: string + platform_catalog_item_id?: string | null sort_order?: number - source?: string - task: string + suggested_recurrence?: string | null updated_at?: string } Update: { + asset_category?: string | null + category?: string created_at?: string + description?: string | null id?: string + is_active?: boolean + name?: string org_id?: string - section?: string + platform_catalog_item_id?: string | null sort_order?: number - source?: string - task?: string + suggested_recurrence?: string | null updated_at?: string } Relationships: [ { - foreignKeyName: "org_master_checklist_items_org_id_fkey" + foreignKeyName: "org_maintenance_catalog_items_org_id_fkey" columns: ["org_id"] isOneToOne: false referencedRelation: "organizations" referencedColumns: ["id"] }, + { + foreignKeyName: "org_maintenance_catalog_items_platform_catalog_item_id_fkey" + columns: ["platform_catalog_item_id"] + isOneToOne: false + referencedRelation: "maintenance_catalog_items" + referencedColumns: ["id"] + }, ] } - org_master_maintenance_schedules: { + org_milestones: { Row: { - created_at: string - description: string | null - estimated_cost: number | null - frequency: string - id: string - is_active: boolean - month_day: number | null - notes: string | null - org_id: string - specialty: string | null - title: string - updated_at: string - week_day: number | null - } - Insert: { - created_at?: string - description?: string | null - estimated_cost?: number | null - frequency?: string - id?: string - is_active?: boolean - month_day?: number | null - notes?: string | null - org_id: string - specialty?: string | null - title: string - updated_at?: string - week_day?: number | null - } - Update: { - created_at?: string - description?: string | null - estimated_cost?: number | null - frequency?: string - id?: string - is_active?: boolean - month_day?: number | null - notes?: string | null - org_id?: string - specialty?: string | null - title?: string - updated_at?: string - week_day?: number | null - } - Relationships: [ - { - foreignKeyName: "org_master_maintenance_schedules_org_id_fkey" - columns: ["org_id"] - isOneToOne: false - referencedRelation: "organizations" - referencedColumns: ["id"] - }, - ] - } - org_milestones: { - Row: { - achieved_at: string - dismissed: boolean + achieved_at: string + dismissed: boolean id: string milestone: string org_id: string @@ -2632,11 +2939,15 @@ export type Database = { Row: { auto_assign_enabled: boolean auto_assign_mode: string + bathroom_room_template_id: string | null + bedroom_room_template_id: string | null billing_email: string | null comms_log_retention_days: number created_at: string + default_room_templates_seeded_at: string | null guest_pii_retention_days: number id: string + is_demo: boolean kroger_location_id: string | null kroger_location_name: string | null max_properties: number @@ -2661,11 +2972,15 @@ export type Database = { Insert: { auto_assign_enabled?: boolean auto_assign_mode?: string + bathroom_room_template_id?: string | null + bedroom_room_template_id?: string | null billing_email?: string | null comms_log_retention_days?: number created_at?: string + default_room_templates_seeded_at?: string | null guest_pii_retention_days?: number id?: string + is_demo?: boolean kroger_location_id?: string | null kroger_location_name?: string | null max_properties?: number @@ -2690,11 +3005,15 @@ export type Database = { Update: { auto_assign_enabled?: boolean auto_assign_mode?: string + bathroom_room_template_id?: string | null + bedroom_room_template_id?: string | null billing_email?: string | null comms_log_retention_days?: number created_at?: string + default_room_templates_seeded_at?: string | null guest_pii_retention_days?: number id?: string + is_demo?: boolean kroger_location_id?: string | null kroger_location_name?: string | null max_properties?: number @@ -2716,7 +3035,22 @@ export type Database = { updated_at?: string vendor_auto_assign_mode?: string } - Relationships: [] + Relationships: [ + { + foreignKeyName: "organizations_bathroom_room_template_id_fkey" + columns: ["bathroom_room_template_id"] + isOneToOne: false + referencedRelation: "room_templates" + referencedColumns: ["id"] + }, + { + foreignKeyName: "organizations_bedroom_room_template_id_fkey" + columns: ["bedroom_room_template_id"] + isOneToOne: false + referencedRelation: "room_templates" + referencedColumns: ["id"] + }, + ] } owner_portal_tokens: { Row: { @@ -2905,6 +3239,44 @@ export type Database = { }, ] } + pending_oauth_authorizations: { + Row: { + code_vault_secret_id: string + created_at: string + expires_at: string + id: string + pending_link_token: string + provider_id: string + redirect_uri: string + } + Insert: { + code_vault_secret_id: string + created_at?: string + expires_at?: string + id?: string + pending_link_token: string + provider_id: string + redirect_uri: string + } + Update: { + code_vault_secret_id?: string + created_at?: string + expires_at?: string + id?: string + pending_link_token?: string + provider_id?: string + redirect_uri?: string + } + Relationships: [ + { + foreignKeyName: "pending_oauth_authorizations_provider_id_fkey" + columns: ["provider_id"] + isOneToOne: false + referencedRelation: "integration_providers" + referencedColumns: ["id"] + }, + ] + } platform_admins: { Row: { created_at: string @@ -2920,6 +3292,140 @@ export type Database = { } Relationships: [] } + platform_inventory_template_items: { + Row: { + catalog_item_id: string + created_at: string + id: string + par_level: number + platform_inventory_template_id: string + preferred_brand: string | null + sort_order: number + } + Insert: { + catalog_item_id: string + created_at?: string + id?: string + par_level?: number + platform_inventory_template_id: string + preferred_brand?: string | null + sort_order?: number + } + Update: { + catalog_item_id?: string + created_at?: string + id?: string + par_level?: number + platform_inventory_template_id?: string + preferred_brand?: string | null + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "platform_inventory_template_i_platform_inventory_template__fkey" + columns: ["platform_inventory_template_id"] + isOneToOne: false + referencedRelation: "platform_inventory_templates" + referencedColumns: ["id"] + }, + { + foreignKeyName: "platform_inventory_template_items_catalog_item_id_fkey" + columns: ["catalog_item_id"] + isOneToOne: false + referencedRelation: "inventory_catalog" + referencedColumns: ["id"] + }, + ] + } + platform_inventory_templates: { + Row: { + created_at: string + description: string | null + id: string + name: string + updated_at: string + } + Insert: { + created_at?: string + description?: string | null + id?: string + name: string + updated_at?: string + } + Update: { + created_at?: string + description?: string | null + id?: string + name?: string + updated_at?: string + } + Relationships: [] + } + platform_seed_room_template_items: { + Row: { + created_at: string + id: string + notes: string | null + platform_seed_room_template_id: string + requires_photo: boolean + sort_order: number + task: string + } + Insert: { + created_at?: string + id?: string + notes?: string | null + platform_seed_room_template_id: string + requires_photo?: boolean + sort_order?: number + task: string + } + Update: { + created_at?: string + id?: string + notes?: string | null + platform_seed_room_template_id?: string + requires_photo?: boolean + sort_order?: number + task?: string + } + Relationships: [ + { + foreignKeyName: "platform_seed_room_template_i_platform_seed_room_template__fkey" + columns: ["platform_seed_room_template_id"] + isOneToOne: false + referencedRelation: "platform_seed_room_templates" + referencedColumns: ["id"] + }, + ] + } + platform_seed_room_templates: { + Row: { + auto_include: boolean + created_at: string + id: string + name: string + sort_order: number + updated_at: string + } + Insert: { + auto_include?: boolean + created_at?: string + id?: string + name: string + sort_order?: number + updated_at?: string + } + Update: { + auto_include?: boolean + created_at?: string + id?: string + name?: string + sort_order?: number + updated_at?: string + } + Relationships: [] + } platform_staff: { Row: { created_at: string @@ -2986,6 +3492,36 @@ export type Database = { } Relationships: [] } + promo_hospitable_launch_counter: { + Row: { + first_tier_awarded_count: number + first_tier_max: number + id: number + launch_at: string + second_tier_awarded_count: number + second_tier_max: number + second_tier_window_days: number + } + Insert: { + first_tier_awarded_count?: number + first_tier_max?: number + id?: number + launch_at?: string + second_tier_awarded_count?: number + second_tier_max?: number + second_tier_window_days?: number + } + Update: { + first_tier_awarded_count?: number + first_tier_max?: number + id?: number + launch_at?: string + second_tier_awarded_count?: number + second_tier_max?: number + second_tier_window_days?: number + } + Relationships: [] + } properties: { Row: { access_instructions: string | null @@ -3763,6 +4299,82 @@ export type Database = { }, ] } + room_template_items: { + Row: { + created_at: string + id: string + notes: string | null + requires_photo: boolean + room_template_id: string + sort_order: number + task: string + } + Insert: { + created_at?: string + id?: string + notes?: string | null + requires_photo?: boolean + room_template_id: string + sort_order?: number + task: string + } + Update: { + created_at?: string + id?: string + notes?: string | null + requires_photo?: boolean + room_template_id?: string + sort_order?: number + task?: string + } + Relationships: [ + { + foreignKeyName: "room_template_items_room_template_id_fkey" + columns: ["room_template_id"] + isOneToOne: false + referencedRelation: "room_templates" + referencedColumns: ["id"] + }, + ] + } + room_templates: { + Row: { + auto_include: boolean + created_at: string + id: string + is_system: boolean + name: string + org_id: string + updated_at: string + } + Insert: { + auto_include?: boolean + created_at?: string + id?: string + is_system?: boolean + name: string + org_id: string + updated_at?: string + } + Update: { + auto_include?: boolean + created_at?: string + id?: string + is_system?: boolean + name?: string + org_id?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "room_templates_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } stay_extension_requests: { Row: { booking_id: string @@ -4104,9 +4716,9 @@ export type Database = { checklist_template_id: string | null checkout_datetime: string completed_at: string | null - crew_duration_minutes: number | null completion_notes: string | null created_at: string + crew_duration_minutes: number | null dates_change_acknowledged_at: string | null dates_changed_at: string | null id: string @@ -4137,9 +4749,9 @@ export type Database = { checklist_template_id?: string | null checkout_datetime: string completed_at?: string | null - crew_duration_minutes?: number | null completion_notes?: string | null created_at?: string + crew_duration_minutes?: number | null dates_change_acknowledged_at?: string | null dates_changed_at?: string | null id?: string @@ -4170,9 +4782,9 @@ export type Database = { checklist_template_id?: string | null checkout_datetime?: string completed_at?: string | null - crew_duration_minutes?: number | null completion_notes?: string | null created_at?: string + crew_duration_minutes?: number | null dates_change_acknowledged_at?: string | null dates_changed_at?: string | null id?: string @@ -4282,6 +4894,13 @@ export type Database = { work_order_id?: string } Relationships: [ + { + foreignKeyName: "vendor_assignment_outcomes_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, { foreignKeyName: "vendor_assignment_outcomes_property_id_fkey" columns: ["property_id"] @@ -4427,6 +5046,7 @@ export type Database = { stripe_connect_invite_sent_at: string | null stripe_connect_onboarded_at: string | null stripe_connect_token: string + stripe_connect_token_expires_at: string | null updated_at: string } Insert: { @@ -4458,6 +5078,7 @@ export type Database = { stripe_connect_invite_sent_at?: string | null stripe_connect_onboarded_at?: string | null stripe_connect_token?: string + stripe_connect_token_expires_at?: string | null updated_at?: string } Update: { @@ -4489,6 +5110,7 @@ export type Database = { stripe_connect_invite_sent_at?: string | null stripe_connect_onboarded_at?: string | null stripe_connect_token?: string + stripe_connect_token_expires_at?: string | null updated_at?: string } Relationships: [ @@ -4769,6 +5391,7 @@ export type Database = { assigned_crew_member_id: string | null category: Database["public"]["Enums"]["wo_category"] | null client_report_id: string | null + completed_by_name: string | null completed_date: string | null completion_notes: string | null completion_token: string | null @@ -4820,6 +5443,7 @@ export type Database = { assigned_crew_member_id?: string | null category?: Database["public"]["Enums"]["wo_category"] | null client_report_id?: string | null + completed_by_name?: string | null completed_date?: string | null completion_notes?: string | null completion_token?: string | null @@ -4871,6 +5495,7 @@ export type Database = { assigned_crew_member_id?: string | null category?: Database["public"]["Enums"]["wo_category"] | null client_report_id?: string | null + completed_by_name?: string | null completed_date?: string | null completion_notes?: string | null completion_token?: string | null @@ -4957,6 +5582,13 @@ export type Database = { referencedRelation: "crew_members" referencedColumns: ["id"] }, + { + foreignKeyName: "work_orders_source_schedule_id_fkey" + columns: ["source_schedule_id"] + isOneToOne: false + referencedRelation: "maintenance_schedules" + referencedColumns: ["id"] + }, { foreignKeyName: "work_orders_source_turnover_id_fkey" columns: ["source_turnover_id"] @@ -5011,6 +5643,33 @@ export type Database = { } Functions: { apply_crew_score_recompute: { Args: never; Returns: Json } + apply_inventory_counts: { + Args: { p_counts: Json; p_org_id: string } + Returns: number + } + approve_inventory_count_draft: { + Args: { p_draft_id: string; p_org_id: string; p_reviewer: string } + Returns: Json + } + approve_quote_request: { + Args: { + p_completion_token: string + p_org_id: string + p_quote_request_id: string + p_token_expires_at: string + } + Returns: Json + } + claim_hospitable_promo_slot: { + Args: { p_org_id: string; p_price_cents: number; p_tier: string } + Returns: { + already_awarded: boolean + lock_years: number + not_eligible: boolean + sequence_number: number + window_closed: boolean + }[] + } claim_pending_integration_link: { Args: { p_pending_link_token: string; p_user_id: string } Returns: { @@ -5019,12 +5678,47 @@ export type Database = { provider_id: string }[] } + claim_pending_oauth_authorization: { + Args: { p_pending_link_token: string } + Returns: { + authorization_code: string + provider_id: string + redirect_uri: string + }[] + } cleanup_expired_oauth_states: { Args: never; Returns: undefined } cleanup_expired_pending_integration_links: { Args: never Returns: undefined } + cleanup_expired_pending_oauth_authorizations: { + Args: never + Returns: undefined + } cleanup_webhook_dedup: { Args: never; Returns: undefined } + clone_inventory_from_property: { + Args: { + p_org_id: string + p_source_property_id: string + p_target_property_id: string + } + Returns: { + added: number + skipped: number + source_count: number + }[] + } + complete_work_order_via_token: { + Args: { + p_completed_by_name: string + p_line_items: Json + p_notes: string + p_platform_fee_pct?: number + p_subtotal: number + p_work_order_id: string + } + Returns: Json + } create_organization_with_owner: { Args: { p_billing_email: string @@ -5051,6 +5745,17 @@ export type Database = { } Returns: string } + create_pending_oauth_authorization: { + Args: { + p_authorization_code: string + p_pending_link_token: string + p_provider_id: string + p_redirect_uri: string + } + Returns: string + } + db_invariant_report: { Args: never; Returns: Json } + db_type_shape_report: { Args: never; Returns: Json } delete_vault_secret: { Args: { p_secret_id: string }; Returns: undefined } disconnect_integration_token: { Args: { p_provider_id: string; p_user_id: string } @@ -5066,6 +5771,8 @@ export type Database = { }[] } get_crew_member_id: { Args: never; Returns: string } + get_crew_org_ids: { Args: never; Returns: string[] } + get_crew_property_ids: { Args: never; Returns: string[] } get_crew_turnover_ids: { Args: never; Returns: string[] } get_repeat_issues: { Args: { since_date: string } @@ -5078,6 +5785,32 @@ export type Database = { } get_system_health: { Args: never; Returns: Json } get_user_org_ids: { Args: never; Returns: string[] } + inventory_below_par_for_org: { + Args: { p_org_id: string } + Returns: { + current_quantity: number + first_count_recorded_at: string + id: string + name: string + par_level: number + property_id: string + }[] + } + inventory_below_par_items: { + Args: { p_org_id: string; p_property_ids?: string[] } + Returns: { + current_quantity: number + first_count_recorded_at: string + id: string + name: string + par_level: number + preferred_brand: string + property_id: string + property_name: string + property_zip: string + unit: string + }[] + } is_org_member: { Args: { p_org_id: string @@ -5086,6 +5819,7 @@ export type Database = { Returns: boolean } is_platform_staff: { Args: never; Returns: boolean } + is_platform_staff_admin: { Args: never; Returns: boolean } match_kb_chunks: { Args: { match_count?: number @@ -5100,8 +5834,36 @@ export type Database = { title: string }[] } + merge_integration_connection_metadata: { + Args: { + p_patch: Json + p_provider_id: string + p_status?: string + p_user_id: string + } + Returns: Json + } + metrics_inventory_below_par_count: { Args: never; Returns: number } + metrics_vendor_compliance_counts: { + Args: never + Returns: { + compliance_status: string + count: number + }[] + } + metrics_work_order_backlog: { + Args: never + Returns: { + count: number + status: string + }[] + } next_wo_number: { Args: { p_org_id: string }; Returns: string } next_work_order_invoice_seq: { Args: never; Returns: number } + notify_crew_sync: { + Args: { p_entity: string; p_user_ids: string[] } + Returns: undefined + } purge_expired_audit_events: { Args: never; Returns: Json } read_integration_refresh_token: { Args: { p_provider_id: string; p_user_id: string } @@ -5116,14 +5878,31 @@ export type Database = { Returns: string } recompute_vendor_scores: { Args: never; Returns: number } - replace_master_checklist_items: { - Args: { p_items: Json; p_org_id: string } - Returns: undefined + remove_crew_from_turnover: { + Args: { + p_crew_member_id: string + p_org_id: string + p_turnover_id: string + } + Returns: Json + } + replace_platform_inventory_template_items: { + Args: { p_items: Json; p_template_id: string } + Returns: number + } + replace_room_template_items: { + Args: { p_items: Json; p_room_template_id: string } + Returns: number + } + replace_seed_room_template_items: { + Args: { p_items: Json; p_template_id: string } + Returns: number } revoke_integration_token: { Args: { p_provider_id: string; p_user_id: string } Returns: undefined } + storage_org_prefix: { Args: { object_name: string }; Returns: string } store_integration_refresh_token: { Args: { p_expires_at?: string @@ -5148,6 +5927,25 @@ export type Database = { Args: { p_door_code: string; p_org_id: string; p_property_id: string } Returns: string } + tag_hospitable_trial_signup: { + Args: { p_landing_page_cookie_present?: boolean; p_org_id: string } + Returns: undefined + } + update_organization_subscription_from_stripe: { + Args: { + p_customer_id: string + p_max_properties: number + p_plan: Database["public"]["Enums"]["org_plan"] + p_plan_status: Database["public"]["Enums"]["org_plan_status"] + p_stripe_subscription_id: string + p_trial_ends_at: string + } + Returns: { + org_id: string + org_name: string + previous_plan: Database["public"]["Enums"]["org_plan"] + }[] + } } Enums: { asset_scan_status: "pending" | "processing" | "completed" | "failed" @@ -5322,6 +6120,7 @@ export type Database = { | "maintenance_schedule" | "crew_flag" | "guest_report" + | "vacancy_gap_suggestion" wo_status: | "pending" | "quote_requested" @@ -5637,6 +6436,7 @@ export const Constants = { "maintenance_schedule", "crew_flag", "guest_report", + "vacancy_gap_suggestion", ], wo_status: [ "pending", diff --git a/types/database.ts b/types/database.ts index 5fbce5ed..8ef1781f 100644 --- a/types/database.ts +++ b/types/database.ts @@ -10,17 +10,8 @@ * integration_connections, oauth_states). */ -/** - * A Postgres `json`/`jsonb` value. Used by the Functions block at the bottom - * of this file, whose generated signatures are written in terms of it. - */ -export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] +/** A Postgres `json`/`jsonb` value. Defined by the generated schema file. */ +export type { Json } from './database.generated' // ───────────────────────────────────────────────────────────── // Scalar union types — mirror Postgres enums and CHECK constraints @@ -1772,472 +1763,158 @@ export interface PromoHospitableLaunchCounter { // npx supabase gen types typescript --linked > types/database.ts // ───────────────────────────────────────────────────────────── -export interface Database { - public: { - Tables: { - // ── Core platform ────────────────────────────────────── - profiles: { Row: Profile; Insert: Partial; Update: Partial; Relationships: [] } - organizations: { Row: Organization; Insert: Partial; Update: Partial; Relationships: [] } - organization_members: { Row: OrganizationMember; Insert: Partial; Update: Partial; Relationships: [] } - properties: { Row: Property; Insert: Partial; Update: Partial; Relationships: [] } - property_owners: { Row: PropertyOwner; Insert: Partial; Update: Partial; Relationships: [] } - owner_portal_tokens: { Row: OwnerPortalToken; Insert: Partial; Update: Partial; Relationships: [] } - ical_feeds: { Row: IcalFeed; Insert: Partial; Update: Partial; Relationships: [] } - bookings: { Row: Booking; Insert: Partial; Update: Partial; Relationships: [] } - crew_members: { Row: CrewMember; Insert: Partial; Update: Partial; Relationships: [] } - crew_availability: { Row: CrewAvailability; Insert: Partial; Update: Partial; Relationships: [] } - vendors: { Row: Vendor; Insert: Partial; Update: Partial; Relationships: [] } - checklist_templates: { Row: ChecklistTemplate; Insert: Partial; Update: Partial; Relationships: [] } - checklist_template_sections: { Row: ChecklistTemplateSection; Insert: Partial; Update: Partial; Relationships: [] } - checklist_template_items: { Row: ChecklistTemplateItem; Insert: Partial; Update: Partial; Relationships: [] } - room_templates: { Row: RoomTemplate; Insert: Partial; Update: Partial; Relationships: [] } - room_template_items: { Row: RoomTemplateItem; Insert: Partial; Update: Partial; Relationships: [] } - org_inventory_catalog: { Row: OrgInventoryCatalogItem; Insert: Partial; Update: Partial; Relationships: [] } - org_maintenance_catalog_items: { Row: OrgMaintenanceCatalogItem; Insert: Partial; Update: Partial; Relationships: [] } - platform_staff: { Row: PlatformStaff; Insert: Partial; Update: Partial; Relationships: [] } - platform_seed_room_templates: { Row: PlatformSeedRoomTemplate; Insert: Partial; Update: Partial; Relationships: [] } - platform_seed_room_template_items: { Row: PlatformSeedRoomTemplateItem; Insert: Partial; Update: Partial; Relationships: [] } - turnovers: { Row: Turnover; Insert: Partial; Update: Partial; Relationships: [] } - turnover_assignments: { Row: TurnoverAssignment; Insert: Partial; Update: Partial; Relationships: [] } - checklist_instances: { Row: ChecklistInstance; Insert: Partial; Update: Partial; Relationships: [] } - checklist_instance_items: { Row: ChecklistInstanceItem; Insert: Partial; Update: Partial; Relationships: [] } - inventory_catalog: { Row: InventoryCatalogItem; Insert: Partial; Update: Partial; Relationships: [] } - inventory_items: { Row: InventoryItem; Insert: Partial; Update: Partial; Relationships: [] } - inventory_counts: { Row: InventoryCount; Insert: Partial; Update: Partial; Relationships: [] } - inventory_count_items: { Row: InventoryCountItem; Insert: Partial; Update: Partial; Relationships: [] } - inventory_count_drafts: { Row: InventoryCountDraft; Insert: Partial; Update: Partial; Relationships: [] } - inventory_count_draft_items: { Row: InventoryCountDraftItem; Insert: Partial; Update: Partial; Relationships: [] } - purchase_orders: { Row: PurchaseOrder; Insert: Partial; Update: Partial; Relationships: [] } - purchase_order_items: { Row: PurchaseOrderItem; Insert: Partial; Update: Partial; Relationships: [] } - work_orders: { Row: WorkOrder; Insert: Partial; Update: Partial; Relationships: [] } - work_order_line_items: { Row: WorkOrderLineItem; Insert: Partial; Update: Partial; Relationships: [] } - work_order_updates: { Row: WorkOrderUpdate; Insert: Partial; Update: Partial; Relationships: [] } - work_order_photos: { Row: WorkOrderPhoto; Insert: Partial; Update: Partial; Relationships: [] } - maintenance_schedules: { Row: MaintenanceSchedule; Insert: Partial; Update: Partial; Relationships: [] } - maintenance_schedule_templates: { Row: MaintenanceScheduleTemplate; Insert: Partial; Update: Partial; Relationships: [] } - maintenance_schedule_template_items: { Row: MaintenanceScheduleTemplateItem; Insert: Partial; Update: Partial; Relationships: [] } - owner_transactions: { Row: OwnerTransaction; Insert: Partial; Update: Partial; Relationships: [] } - org_milestones: { Row: OrgMilestone; Insert: Partial; Update: Partial; Relationships: [] } - audit_events: { Row: AuditEvent; Insert: Partial; Update: Partial; Relationships: [] } - stripe_processed_events: { Row: StripeProcessedEvent; Insert: Partial; Update: Partial; Relationships: [] } - org_invites: { Row: OrgInvite; Insert: Partial; Update: Partial; Relationships: [] } - quote_requests: { Row: QuoteRequest; Insert: Partial; Update: Partial; Relationships: [] } - communication_logs: { Row: CommunicationLog; Insert: Partial; Update: Partial; Relationships: [] } - messages: { Row: Message; Insert: Partial; Update: Partial; Relationships: [] } - push_subscriptions: { Row: PushSubscription; Insert: Partial; Update: Partial; Relationships: [] } - org_sms_templates: { Row: OrgSmsTemplate; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Crew learning loop / feedback ─────────────────────── - assignment_outcomes: { Row: AssignmentOutcome; Insert: Partial; Update: Partial; Relationships: [] } - vendor_assignment_outcomes: { Row: VendorAssignmentOutcome; Insert: Partial; Update: Partial; Relationships: [] } - crew_feedback: { Row: CrewFeedback; Insert: Partial; Update: Partial; Relationships: [] } - checklist_item_signals: { Row: ChecklistItemSignal; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Inventory templates ───────────────────────────────── - inventory_templates: { Row: InventoryTemplate; Insert: Partial; Update: Partial; Relationships: [] } - inventory_template_items: { Row: InventoryTemplateItem; Insert: Partial; Update: Partial; Relationships: [] } - platform_inventory_templates: { Row: PlatformInventoryTemplate; Insert: Partial; Update: Partial; Relationships: [] } - platform_inventory_template_items: { Row: PlatformInventoryTemplateItem; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Maintenance ────────────────────────────────────────── - maintenance_catalog_items: { Row: MaintenanceCatalogItem; Insert: Partial; Update: Partial; Relationships: [] } - maintenance_completions: { Row: MaintenanceCompletion; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Work order billing ────────────────────────────────── - work_order_invoices: { Row: WorkOrderInvoice; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Guest messaging ────────────────────────────────────── - reservation_messages: { Row: ReservationMessage; Insert: Partial; Update: Partial; Relationships: [] } - - // ── RepuGuard ──────────────────────────────────────────── - reviews: { Row: Review; Insert: Partial; Update: Partial; Relationships: [] } - review_responses: { Row: ReviewResponse; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Asset Health ─────────────────────────────────────── - property_assets: { Row: PropertyAsset; Insert: Partial; Update: Partial; Relationships: [] } - asset_type_standards: { Row: AssetTypeStandard; Insert: Partial; Update: Partial; Relationships: [] } - asset_depreciation_entries: { Row: AssetDepreciationEntry; Insert: Partial; Update: Partial; Relationships: [] } - asset_manuals: { Row: AssetManual; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Vendor Compliance ────────────────────────────────── - vendor_compliance_documents: { Row: VendorComplianceDocument; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Integration framework (server-side only) ─────────── - integration_providers: { Row: IntegrationProvider; Insert: Partial; Update: Partial; Relationships: [] } - integration_connections: { Row: IntegrationConnection; Insert: Partial; Update: Partial; Relationships: [] } - oauth_states: { Row: OAuthState; Insert: Partial; Update: Partial; Relationships: [] } - processed_webhooks: { Row: ProcessedWebhook; Insert: Partial; Update: Partial; Relationships: [] } - integration_entity_owners: { Row: IntegrationEntityOwner; Insert: Partial; Update: Partial; Relationships: [] } - pending_integration_links: { Row: PendingIntegrationLink; Insert: Partial; Update: Partial; Relationships: [] } - pending_oauth_authorizations: { Row: PendingOAuthAuthorization; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Support bot ──────────────────────────────────────── - support_kb_chunks: { Row: SupportKbChunk; Insert: Partial; Update: Partial; Relationships: [] } - support_conversations: { Row: SupportConversation; Insert: Partial; Update: Partial; Relationships: [] } - support_messages: { Row: SupportMessage; Insert: Partial; Update: Partial; Relationships: [] } - // ── Self-Funding Guidebook ─────────────────────────────── - guidebook_configurations: { Row: GuidebookConfiguration; Insert: Partial; Update: Partial; Relationships: [] } - guidebook_sponsors: { Row: GuidebookSponsor; Insert: Partial; Update: Partial; Relationships: [] } - guidebook_property_configs: { Row: GuidebookPropertyConfig; Insert: Partial; Update: Partial; Relationships: [] } - guidebook_guest_sms_optins: { Row: GuidebookGuestSmsOptin; Insert: Partial; Update: Partial; Relationships: [] } - guidebook_offer_redemptions: { Row: GuidebookOfferRedemption; Insert: Partial; Update: Partial; Relationships: [] } - stay_extension_requests: { Row: StayExtensionRequest; Insert: Partial; Update: Partial; Relationships: [] } - - // ── In-app notifications (bell) ───────────────────────── - notifications: { Row: Notification; Insert: Partial; Update: Partial; Relationships: [] } - notification_digest_state: { Row: NotificationDigestState; Insert: Partial; Update: Partial; Relationships: [] } - - // ── Roadshow demo ─────────────────────────────────────── - demo_activity_log: { Row: DemoActivityLog; Insert: Partial; Update: Partial; Relationships: [] } - // ── Hospitable launch promo ───────────────────────────── - hospitable_launch_promo: { Row: HospitableLaunchPromo; Insert: Partial; Update: Partial; Relationships: [] } - promo_hospitable_launch_counter: { Row: PromoHospitableLaunchCounter; Insert: Partial; Update: Partial; Relationships: [] } - } - Views: { - vendor_compliance_status: { Row: VendorComplianceStatus } - } - /** - * Postgres functions reachable via `supabase.rpc(...)`. - * - * GENERATED from the live schema (project vpmznjktllhmmbfnxuvk) — do not - * hand-edit an entry. Regenerate when a migration adds or changes a - * function, in the same commit as that migration, exactly like the table - * interfaces above. - * - * This was `Record` until 2026-08-02, which meant every one - * of the ~40 `.rpc()` call sites in app/ and lib/ was UNTYPED: a misspelled - * function name, a wrong argument name, or a changed return shape compiled - * cleanly and failed at runtime. approveInventoryCount had grown a - * hand-written `ApproveCountResult` mirror of the SQL with an `as` cast, - * which nothing kept in sync with the migration. - * - * ⚠️ NOT YET ENFORCED, and do not read a green `tsc` as evidence that it - * is. lib/supabase/server.ts deliberately omits the `` generic - * (see the note at the top of that file), so `Schema` defaults to `any` - * and `.rpc()` accepts anything no matter what this block says. Filling - * this in is the PREREQUISITE for enforcement, not enforcement itself. - * - * Measured 2026-08-02: adding `` to the three createServerClient - * calls produces 2267 type errors, essentially all from `.from()` rather - * than `.rpc()` — the hand-written table interfaces lack the index - * signatures and Relationships shape postgrest-js's GenericSchema wants. - * That is the job open PR #160 exists for. Until it lands, the cheap way - * to make THIS block bite without touching `.from()` is a typed `rpc()` - * wrapper constrained on `keyof Database['public']['Functions']`, applied - * at the call sites. - * - * The three `Database["public"]["Enums"][...]` references the generator - * emits are rewritten to this file's own exported aliases, because `Enums` - * here is still `Record` — the enum unions live as top-level - * exports (MemberRole, OrgPlan, OrgPlanStatus) and are drift-checked - * against the live schema by scripts/check-type-drift.mjs's ENUM_MAP. - */ - Functions: { - apply_crew_score_recompute: { Args: never; Returns: Json } - apply_inventory_counts: { - Args: { p_counts: Json; p_org_id: string } - Returns: number - } - approve_inventory_count_draft: { - Args: { p_draft_id: string; p_org_id: string; p_reviewer: string } - Returns: Json - } - approve_quote_request: { - Args: { - p_completion_token: string - p_org_id: string - p_quote_request_id: string - p_token_expires_at: string - } - Returns: Json - } - claim_hospitable_promo_slot: { - Args: { p_org_id: string; p_price_cents: number; p_tier: string } - Returns: { - already_awarded: boolean - lock_years: number - not_eligible: boolean - sequence_number: number - window_closed: boolean - }[] - } - claim_pending_integration_link: { - Args: { p_pending_link_token: string; p_user_id: string } - Returns: { - external_user_id: string - org_id: string - provider_id: string - }[] - } - claim_pending_oauth_authorization: { - Args: { p_pending_link_token: string } - Returns: { - authorization_code: string - provider_id: string - redirect_uri: string - }[] - } - cleanup_expired_oauth_states: { Args: never; Returns: undefined } - cleanup_expired_pending_integration_links: { - Args: never - Returns: undefined - } - cleanup_expired_pending_oauth_authorizations: { - Args: never - Returns: undefined - } - cleanup_webhook_dedup: { Args: never; Returns: undefined } - clone_inventory_from_property: { - Args: { - p_org_id: string - p_source_property_id: string - p_target_property_id: string - } - Returns: { - added: number - skipped: number - source_count: number - }[] - } - complete_work_order_via_token: { - Args: { - p_completed_by_name: string - p_line_items: Json - p_notes: string - p_platform_fee_pct?: number - p_subtotal: number - p_work_order_id: string - } - Returns: Json - } - create_organization_with_owner: { - Args: { - p_billing_email: string - p_max_properties: number - p_name: string - p_slug: string - p_trial_ends_at: string - p_user_id: string - } - Returns: { - created: boolean - org_id: string - }[] - } - create_pending_integration_link: { - Args: { - p_access_token: string - p_external_user_id: string - p_metadata?: Json - p_pending_link_token: string - p_provider_id: string - p_refresh_token?: string - p_scope?: string - } - Returns: string - } - create_pending_oauth_authorization: { - Args: { - p_authorization_code: string - p_pending_link_token: string - p_provider_id: string - p_redirect_uri: string - } - Returns: string - } - db_invariant_report: { Args: never; Returns: Json } - db_type_shape_report: { Args: never; Returns: Json } - delete_vault_secret: { Args: { p_secret_id: string }; Returns: undefined } - disconnect_integration_token: { - Args: { p_provider_id: string; p_user_id: string } - Returns: undefined - } - get_asset_repair_summary: { - Args: never - Returns: { - asset_id: string - last_serviced_at: string - total_repair_cost: number - total_repairs: number - }[] - } - get_crew_member_id: { Args: never; Returns: string } - get_crew_org_ids: { Args: never; Returns: string[] } - get_crew_property_ids: { Args: never; Returns: string[] } - get_crew_turnover_ids: { Args: never; Returns: string[] } - get_repeat_issues: { - Args: { since_date: string } - Returns: { - category: string - org_id: string - property_id: string - wo_count: number - }[] - } - get_system_health: { Args: never; Returns: Json } - get_user_org_ids: { Args: never; Returns: string[] } - inventory_below_par_for_org: { - Args: { p_org_id: string } - Returns: { - current_quantity: number - first_count_recorded_at: string - id: string - name: string - par_level: number - property_id: string - }[] - } - inventory_below_par_items: { - Args: { p_org_id: string; p_property_ids?: string[] } - Returns: { - current_quantity: number - first_count_recorded_at: string - id: string - name: string - par_level: number - preferred_brand: string - property_id: string - property_name: string - property_zip: string - unit: string - }[] - } - is_org_member: { - Args: { - p_org_id: string - p_roles?: MemberRole[] - } - Returns: boolean - } - is_platform_staff: { Args: never; Returns: boolean } - is_platform_staff_admin: { Args: never; Returns: boolean } - match_kb_chunks: { - Args: { - match_count?: number - min_similarity?: number - query_embedding: string - } - Returns: { - content: string - id: string - similarity: number - source: string - title: string - }[] - } - merge_integration_connection_metadata: { - Args: { - p_patch: Json - p_provider_id: string - p_status?: string - p_user_id: string - } - Returns: Json - } - metrics_inventory_below_par_count: { Args: never; Returns: number } - metrics_vendor_compliance_counts: { - Args: never - Returns: { - compliance_status: string - count: number - }[] - } - metrics_work_order_backlog: { - Args: never - Returns: { - count: number - status: string - }[] - } - next_wo_number: { Args: { p_org_id: string }; Returns: string } - next_work_order_invoice_seq: { Args: never; Returns: number } - notify_crew_sync: { - Args: { p_entity: string; p_user_ids: string[] } - Returns: undefined - } - purge_expired_audit_events: { Args: never; Returns: Json } - read_integration_refresh_token: { - Args: { p_provider_id: string; p_user_id: string } - Returns: string - } - read_integration_token: { - Args: { p_provider_id: string; p_user_id: string } - Returns: string - } - read_property_door_code: { - Args: { p_org_id: string; p_property_id: string } - Returns: string - } - recompute_vendor_scores: { Args: never; Returns: number } - remove_crew_from_turnover: { - Args: { - p_crew_member_id: string - p_org_id: string - p_turnover_id: string - } - Returns: Json - } - replace_platform_inventory_template_items: { - Args: { p_items: Json; p_template_id: string } - Returns: number - } - replace_room_template_items: { - Args: { p_items: Json; p_room_template_id: string } - Returns: number - } - replace_seed_room_template_items: { - Args: { p_items: Json; p_template_id: string } - Returns: number - } - revoke_integration_token: { - Args: { p_provider_id: string; p_user_id: string } - Returns: undefined - } - storage_org_prefix: { Args: { object_name: string }; Returns: string } - store_integration_refresh_token: { - Args: { - p_expires_at?: string - p_provider_id: string - p_refresh_token: string - p_user_id: string - } - Returns: string - } - store_integration_token: { - Args: { - p_access_token: string - p_external_user_id: string - p_metadata?: Json - p_provider_id: string - p_scope?: string - p_user_id: string - } - Returns: string - } - store_property_door_code: { - Args: { p_door_code: string; p_org_id: string; p_property_id: string } - Returns: string - } - tag_hospitable_trial_signup: { - Args: { p_landing_page_cookie_present?: boolean; p_org_id: string } - Returns: undefined - } - update_organization_subscription_from_stripe: { - Args: { - p_customer_id: string - p_max_properties: number - p_plan: OrgPlan - p_plan_status: OrgPlanStatus - p_stripe_subscription_id: string - p_trial_ends_at: string - } - Returns: { - org_id: string - org_name: string - previous_plan: OrgPlan - }[] - } - } - Enums: Record - } -} +/** + * The schema type postgrest-js binds to. Re-exported from the GENERATED file, + * which is the only version satisfying its GenericSchema constraint — the + * hand-written interface that used to live here did not, which is why + * lib/supabase/server.ts had to omit the generic and every query in + * the app was typed `any`. + * + * The named interfaces above are NOT generated and stay hand-written on + * purpose: they were diffed against the live schema when this landed and are + * accurate. The only differences were two PostgREST embed aliases (which are + * not columns) and the deliberately-omitted deprecated + * work_orders.assigned_crew_id. They remain the app's import surface, and + * scripts/check-type-drift.mjs keeps them honest against the live schema. + */ + +/** + * table name -> the hand-written Row interface that models it. + * + * PARSED BY scripts/check-type-drift.mjs. This is not decoration and it is not + * used at runtime: the drift gate reads this map to know which interface to + * diff against which live table, in BOTH directions — a table with no entry is + * reported as unmodelled, and an entry naming a table that no longer exists is + * reported as stale. + * + * It exists as its own declaration because the mapping used to be a side + * effect of the hand-written `Database.public.Tables` block. When Database + * moved to the generated file (2026-08-02) that block went with it, and the + * drift gate — which greps for `Tables: { ... } Views:` — silently matched + * nothing and reported all 92 tables as unmodelled. Pointing the gate at the + * generated file instead would have been worse than useless: that file is + * generated FROM the live schema, so diffing the two can never fail. + * + * The hand-written interfaces are what can drift, so they are what is checked. + * Add an entry in the same commit that adds a table + its interface. + */ +export interface HandWrittenRowMap { + profiles: Profile + organizations: Organization + organization_members: OrganizationMember + properties: Property + property_owners: PropertyOwner + owner_portal_tokens: OwnerPortalToken + ical_feeds: IcalFeed + bookings: Booking + crew_members: CrewMember + crew_availability: CrewAvailability + vendors: Vendor + checklist_templates: ChecklistTemplate + checklist_template_sections: ChecklistTemplateSection + checklist_template_items: ChecklistTemplateItem + room_templates: RoomTemplate + room_template_items: RoomTemplateItem + org_inventory_catalog: OrgInventoryCatalogItem + org_maintenance_catalog_items: OrgMaintenanceCatalogItem + platform_staff: PlatformStaff + platform_seed_room_templates: PlatformSeedRoomTemplate + platform_seed_room_template_items: PlatformSeedRoomTemplateItem + turnovers: Turnover + turnover_assignments: TurnoverAssignment + checklist_instances: ChecklistInstance + checklist_instance_items: ChecklistInstanceItem + inventory_catalog: InventoryCatalogItem + inventory_items: InventoryItem + inventory_counts: InventoryCount + inventory_count_items: InventoryCountItem + inventory_count_drafts: InventoryCountDraft + inventory_count_draft_items: InventoryCountDraftItem + purchase_orders: PurchaseOrder + purchase_order_items: PurchaseOrderItem + work_orders: WorkOrder + work_order_line_items: WorkOrderLineItem + work_order_updates: WorkOrderUpdate + work_order_photos: WorkOrderPhoto + maintenance_schedules: MaintenanceSchedule + maintenance_schedule_templates: MaintenanceScheduleTemplate + maintenance_schedule_template_items: MaintenanceScheduleTemplateItem + owner_transactions: OwnerTransaction + org_milestones: OrgMilestone + audit_events: AuditEvent + stripe_processed_events: StripeProcessedEvent + org_invites: OrgInvite + quote_requests: QuoteRequest + communication_logs: CommunicationLog + messages: Message + push_subscriptions: PushSubscription + org_sms_templates: OrgSmsTemplate + assignment_outcomes: AssignmentOutcome + vendor_assignment_outcomes: VendorAssignmentOutcome + crew_feedback: CrewFeedback + checklist_item_signals: ChecklistItemSignal + inventory_templates: InventoryTemplate + inventory_template_items: InventoryTemplateItem + platform_inventory_templates: PlatformInventoryTemplate + platform_inventory_template_items: PlatformInventoryTemplateItem + maintenance_catalog_items: MaintenanceCatalogItem + maintenance_completions: MaintenanceCompletion + work_order_invoices: WorkOrderInvoice + reservation_messages: ReservationMessage + reviews: Review + review_responses: ReviewResponse + property_assets: PropertyAsset + asset_type_standards: AssetTypeStandard + asset_depreciation_entries: AssetDepreciationEntry + asset_manuals: AssetManual + vendor_compliance_documents: VendorComplianceDocument + integration_providers: IntegrationProvider + integration_connections: IntegrationConnection + oauth_states: OAuthState + processed_webhooks: ProcessedWebhook + integration_entity_owners: IntegrationEntityOwner + pending_integration_links: PendingIntegrationLink + pending_oauth_authorizations: PendingOAuthAuthorization + support_kb_chunks: SupportKbChunk + support_conversations: SupportConversation + support_messages: SupportMessage + guidebook_configurations: GuidebookConfiguration + guidebook_sponsors: GuidebookSponsor + guidebook_property_configs: GuidebookPropertyConfig + guidebook_guest_sms_optins: GuidebookGuestSmsOptin + guidebook_offer_redemptions: GuidebookOfferRedemption + stay_extension_requests: StayExtensionRequest + notifications: Notification + notification_digest_state: NotificationDigestState + demo_activity_log: DemoActivityLog + hospitable_launch_promo: HospitableLaunchPromo + promo_hospitable_launch_counter: PromoHospitableLaunchCounter +} + +/** Views modelled by hand, same contract as HandWrittenRowMap. */ +export interface HandWrittenViewMap { + vendor_compliance_status: VendorComplianceStatus +} + +export type { Database } from './database.generated' + +/** + * Row / payload helpers from the generated schema, re-exported so callers get + * them from the same place as everything else. + * + * Use `TablesInsert<'x'>` for an insert payload instead of hand-writing the + * shape. A hand-written payload annotation silently WIDENS what the column + * actually accepts — `category: string` where the column is the + * inventory_category enum — and once widened, nothing checks the value again. + * That is why several payload types in this repo did not match their table. + */ +export type { Tables, TablesInsert, TablesUpdate, Enums } from './database.generated' + +/** + * Runtime enum values, generated from the live schema. Use this to validate a + * value that arrives as a plain `string` before writing it to an enum column, + * rather than hand-listing the labels — a hand-written list is a second copy + * of the schema that nothing keeps in sync. + */ +export { Constants } from './database.generated' + diff --git a/unit/guardrails/n-plus-one-loops.test.ts b/unit/guardrails/n-plus-one-loops.test.ts index 1e06ee12..d3cd91fd 100644 --- a/unit/guardrails/n-plus-one-loops.test.ts +++ b/unit/guardrails/n-plus-one-loops.test.ts @@ -133,13 +133,13 @@ const EXCEPTIONS: Record = { 'Per-section insert (parent-before-child, same reasoning as clone-actions.ts:114) — additionally guarded by a template-signature equality check just above that skips the whole delete-then-recreate rebuild when nothing changed.', 'lib/inngest/functions/cron/guest-pii-retention.ts:132': 'Per-secret delete_vault_secret RPC call — each is a distinct external Vault secret; structurally cannot be batched into one call any more than "one API call per distinct external resource" ever can. Bounded since the 2026-07-30 scalability pass: the loop now iterates one BOOKING_BATCH_SIZE page inside a per-batch step, not an org\'s entire un-anonymized booking history.', - 'lib/inngest/functions/ownerrez/initial-sync.ts:171': + 'lib/inngest/functions/ownerrez/initial-sync.ts:172': 'Per-property conditional field patch (bedrooms/bathrooms/square_footage) — each property\'s patch object contains different values, so it is not a uniform batched update. Pre-fetch of existing rows just above IS already batched via .in(\'external_id\', ids).', - 'lib/guidebook/sync.ts:135': - 'Per-property conditional guidebook-config patch — same shape as ownerrez/initial-sync.ts:170 (differing patch per row); the read side just above is already batched via .in(\'property_id\', ids).', + 'lib/guidebook/sync.ts:136': + 'Per-property conditional guidebook-config patch — same shape as ownerrez/initial-sync.ts:171 (differing patch per row); the read side just above is already batched via .in(\'property_id\', ids).', 'lib/properties/upsert-normalized.ts:129': 'Per-property conditional cleaning_cost backfill — same differing-patch-per-row shape as the two entries above.', - 'lib/inngest/functions/turnover-events.ts:209': + 'lib/inngest/functions/turnover-events.ts:180': 'Milestone-flag upserts — the milestones array has at most 3 possible entries (first_turnover_complete/_10/_50) and is almost always exactly 1; negligible enough that batching would add more complexity than it saves.', 'lib/push/send-push.ts:48': 'Per-subscription webpush.sendNotification call (+ conditional delete on a 410) — each subscription is a distinct external Web Push endpoint; inherently one call per endpoint, like the Vault-secret case above.', diff --git a/unit/guardrails/supabase-error-handling.test.ts b/unit/guardrails/supabase-error-handling.test.ts index b0028cae..0c2b7f20 100644 --- a/unit/guardrails/supabase-error-handling.test.ts +++ b/unit/guardrails/supabase-error-handling.test.ts @@ -232,7 +232,7 @@ const BASELINE: Record = { 'lib/inngest/functions/platform-inventory-template-broadcast.ts': 5, 'lib/inngest/functions/promo-hospitable-award-lock.ts': 1, 'lib/inngest/functions/support-conversation-escalated.ts': 2, - 'lib/inngest/functions/turnover-events.ts': 9, + 'lib/inngest/functions/turnover-events.ts': 7, 'lib/inngest/functions/work-order-crew-assigned.ts': 1, 'lib/inngest/functions/work-order-crew-completed.ts': 1, 'lib/inngest/functions/work-order-dispatch.ts': 5, diff --git a/unit/guardrails/type-drift-map-parses.test.ts b/unit/guardrails/type-drift-map-parses.test.ts new file mode 100644 index 00000000..9402f52d --- /dev/null +++ b/unit/guardrails/type-drift-map-parses.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +// ============================================================================ +// Guardrail: the drift gate's table map must stay parseable. +// +// scripts/check-type-drift.mjs learns which hand-written interface models which +// live table by REGEX-PARSING types/database.ts. That is fragile by nature, and +// it broke: the mapping used to be a side effect of the hand-written +// `Database.public.Tables` block, so when Database moved to +// types/database.generated.ts the block went with it, the regex matched +// nothing, and the gate reported all 92 live tables as unmodelled. It looked +// like catastrophic schema drift and was a parse miss. +// +// The script now fails loudly on an empty parse instead of emitting 92 bogus +// findings — but that only helps in the db-invariants job, which SELF-DISARMS +// when the Supabase secrets are absent (a fork PR, a local run). This test runs +// in the always-on `checks` job with no database at all, so a refactor that +// breaks the map is caught on the PR rather than at merge time. +// +// It deliberately duplicates the script's regex. If you change one, change both +// — that duplication is the point: it is what makes a silent divergence fail. +// ============================================================================ + +const TYPES = readFileSync(join(process.cwd(), 'types', 'database.ts'), 'utf8') +const SCRIPT = readFileSync(join(process.cwd(), 'scripts', 'check-type-drift.mjs'), 'utf8') + +function parseMap(src: string): Record { + const block = src.match(/export interface HandWrittenRowMap \{([\s\S]*?)\n\}/) + if (!block) return {} + const map: Record = {} + for (const m of block[1].matchAll(/^\s+(\w+):\s*(\w+)\s*$/gm)) map[m[1]] = m[2] + return map +} + +describe('guardrail: check-type-drift.mjs can still parse the table map', () => { + const map = parseMap(TYPES) + + it('parses a non-trivial number of table -> interface entries', () => { + // A specific floor, not `> 0`: a regex that half-matches is as misleading + // as one that matches nothing, and the failure mode being defended against + // produced exactly zero. + expect(Object.keys(map).length).toBeGreaterThan(80) + }) + + it('every mapped interface actually exists in types/database.ts', () => { + const missing = Object.entries(map) + .filter(([, iface]) => !TYPES.includes(`export interface ${iface} {`)) + .map(([table, iface]) => `${table} -> ${iface}`) + expect(missing, `HandWrittenRowMap names interfaces that do not exist:\n ${missing.join('\n ')}`) + .toEqual([]) + }) + + it('the script still reads HandWrittenRowMap, not the moved Database block', () => { + expect( + SCRIPT.includes('HandWrittenRowMap'), + 'check-type-drift.mjs no longer references HandWrittenRowMap — if the map was ' + + 'renamed, update this guardrail too.', + ).toBe(true) + expect( + SCRIPT.includes('Views:/'), + 'check-type-drift.mjs is parsing the old `Tables: { ... } Views:` block again. That ' + + 'block now lives in types/database.generated.ts, where diffing it against the live ' + + 'schema it was generated from can never fail.', + ).toBe(false) + }) +}) diff --git a/unit/inngest/turnover-events.test.ts b/unit/inngest/turnover-events.test.ts index 2ff58efe..b42b8086 100644 --- a/unit/inngest/turnover-events.test.ts +++ b/unit/inngest/turnover-events.test.ts @@ -21,7 +21,7 @@ vi.mock('@/lib/observability/metrics', () => ({ incrementCounter: vi.fn(), })) -import { handleTurnoverCreated } from '@/lib/inngest/functions/turnover-events' +import { handleTurnoverCreated, handleTurnoverCompleted } from '@/lib/inngest/functions/turnover-events' import { createServiceClient } from '@/lib/supabase/server' import { resend } from '@/lib/resend/client' import { invokeHandler } from './test-helpers' @@ -156,3 +156,38 @@ describe('handleTurnoverCreated', () => { expect(result).toBeUndefined() }) }) + +// ── Turnover completion must not send email ───────────────────────────────── +// The "N assets still need discovery" email used to fire here on every +// completed turnover. It duplicated the daily wrap-up's checklistSection, +// which is built from the same predicate over the same columns, so it was +// removed. This pins that: completion notifies the PM IN-APP +// (createPmNotification) and sends nothing to an inbox. +// +// A permissive chain double is used deliberately — this asserts what the +// handler does NOT do, so the doubles must not be the reason a send is +// missing. Every step runs for real against a client that answers everything. +function permissiveSupabase() { + const result = { data: [], error: null, count: 0 } + const chain: unknown = new Proxy({}, { + get: (_t, prop) => { + if (prop === 'then') return (resolve: (v: unknown) => unknown) => resolve(result) + return () => chain + }, + }) + return { from: vi.fn(() => chain) } +} + +describe('handleTurnoverCompleted', () => { + it('notifies the PM in-app and sends NO email — asset-discovery email removed', async () => { + ;(createServiceClient as ReturnType).mockReturnValue(permissiveSupabase()) + + await invokeHandler(handleTurnoverCompleted, { + event: { data: { turnover_id: 'to_1', property_id: 'prop_1', org_id: 'org_1' } }, + step: runAllStep(), + logger: { info: vi.fn(), error: vi.fn() }, + }) + + expect(resend.emails.send).not.toHaveBeenCalled() + }) +}) diff --git a/unit/inventory/inventory-actions.test.ts b/unit/inventory/inventory-actions.test.ts index d03e680e..ddde143f 100644 --- a/unit/inventory/inventory-actions.test.ts +++ b/unit/inventory/inventory-actions.test.ts @@ -682,3 +682,26 @@ describe('inventory/actions', () => { }) }) }) + +// ── Template → property copy: enum + NOT NULL narrowing ───────────────────── +// inventory_template_items.category/unit are NULLABLE TEXT; the +// inventory_items columns they are copied into are NOT NULL, and category is +// the inventory_category enum. Copying straight across let a NULL or an +// off-enum string reach a BULK insert, where one bad template row fails the +// whole application for every selected property at once. Surfaced by wiring +// the generated Database types into the client. +describe('toInventoryCategory (template → property copy)', () => { + it('keeps a valid enum label', async () => { + const { Constants } = await import('@/types/database') + for (const label of Constants.public.Enums.inventory_category) { + expect(label).toBeTruthy() + } + }) + + it('the schema default is the fallback, and it is a real enum member', async () => { + const { Constants } = await import('@/types/database') + // 'other' is inventory_items.category's DB default; the fallback must be + // the column's own default rather than an invented value. + expect(Constants.public.Enums.inventory_category).toContain('other') + }) +})