From e4243730e5466e9d56779639c3d170b0cab8a6e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:52:24 +0000 Subject: [PATCH 1/5] types(db): generated schema becomes the source of truth for Database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of wiring the generic. No behaviour change, no client wiring yet — this removes the thing that was blocking it. lib/supabase/server.ts omits , so `Schema` defaults to `any` and NOT ONE .from() or .rpc() call in this app is type-checked. That is a real gap, not a style choice: reviews.internal_notes was selected by the RepuGuard cron for months and failed every run for every org, because nothing compared the select string against the schema. There was nothing to compare it to. The blocker was that types/database.ts is hand-written and its interfaces do not satisfy postgrest-js's GenericSchema constraint (no index signatures, no Relationships). Binding them collapses every row type to `never`: hand-written types + 2267 errors (2163 of them that collapse) generated types + 138 errors across 44 files types/database.generated.ts is now committed, generated from the live schema, and types/database.ts re-exports Json and Database from it instead of declaring its own. Nothing imported Database before this, so the swap is inert — which is the point: it is a prerequisite, landing green and reviewable on its own. The 145 hand-written named interfaces STAY hand-written and stay the app's import surface. They were diffed field-by-field against the live schema first, and they are accurate: the only differences across 91 mapped tables were two PostgREST embed aliases (crew_members on TurnoverAssignment, turnover_assignments on Turnover — relations, not columns) and the deliberately-omitted deprecated work_orders.assigned_crew_id. Worth stating plainly, because it changes what this work is: the types were never the problem, and internal_notes was not type drift. Only the absence of checking was. What remains is the 138, which are a long tail rather than one mechanical fix — insert/update payloads carrying keys the table does not have, string vs string | null, Json shapes — each needing its own judgement. The generic gets wired in the commit that resolves them, not before: a half-wired client is worse than an unwired one because it looks checked. The exact next step is written into lib/supabase/server.ts where whoever picks it up will be reading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0176sNbUryq9sVASSkyqpeue --- CLAUDE.md | 18 + lib/supabase/server.ts | 28 +- types/database.generated.ts | 1004 +++++++++++++++++++++++++++++++---- types/database.ts | 498 +---------------- 4 files changed, 962 insertions(+), 586 deletions(-) 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/lib/supabase/server.ts b/lib/supabase/server.ts index 0575974f..476cd431 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: 138 errors, across 44 files — 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/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..fdcfd87f 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,19 @@ 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. + */ +export type { Database } from './database.generated' + From 78fd656084bf351e1d5cf97601aafbbe125f23fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 14:11:00 +0000 Subject: [PATCH 2/5] fix(email): drop the per-turnover "assets still need discovery" email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It fired 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 its checklistSection from the SAME predicate over the SAME columns — missingAssetTypesFromDiscoveredSet() over the is_na/make/model/photo_url filter — per property, once a day. Verified before removing rather than taken on faith: the two computations are line-for-line equivalent. So it was the same number delivered twice, and the duplicate arrived on a trigger the PM cannot act on differently (asset discovery is not a turnover task) at a rate set by turnover volume. That is the shape that trains people to filter a sender, which costs more than the email was ever worth. Deleted rather than made conditional or throttled: there is no threshold at which a duplicate of the wrap-up's own content earns its own send. The in-app turnover-complete notification is untouched — completion still notifies, it just no longer emails. Also drops the four imports the step was the last user of (assetTypeDisplayName, missingAssetTypesFromDiscoveredSet, AssetType, getPmEmails) and adds a test for handleTurnoverCompleted, which had none. The test asserts the handler sends NO email, using a permissive chain double on purpose: it asserts an absence, so the doubles must not be the reason a send is missing — every step runs for real against a client that answers everything. Verified by reintroducing a send and watching it fail. Two ratchets caught the change and are updated with it: the n-plus-one EXCEPTIONS entry for the milestone-upsert loop (209 -> 180, same code, moved by the deletion) and supabase-error-handling's per-file baseline for this file (9 -> 7, since the removed step held two unhandled destructures). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0176sNbUryq9sVASSkyqpeue --- lib/inngest/functions/turnover-events.ts | 61 +++++-------------- unit/guardrails/n-plus-one-loops.test.ts | 2 +- .../supabase-error-handling.test.ts | 2 +- unit/inngest/turnover-events.test.ts | 37 ++++++++++- 4 files changed, 54 insertions(+), 48 deletions(-) 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/unit/guardrails/n-plus-one-loops.test.ts b/unit/guardrails/n-plus-one-loops.test.ts index 1e06ee12..f7a8587b 100644 --- a/unit/guardrails/n-plus-one-loops.test.ts +++ b/unit/guardrails/n-plus-one-loops.test.ts @@ -139,7 +139,7 @@ const EXCEPTIONS: Record = { '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/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/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() + }) +}) From 666a735f1e7e9f416a47e8fbcf3ef17ecc83e5fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:34:05 +0000 Subject: [PATCH 3/5] fix(inventory): narrow template category/unit before copying into inventory_items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First fixes from the TS2345 class surfaced by wiring the generated Database types. The wiring itself stays out until the class is clear — these two stand on their own and land green. The real defect. applyTemplateToProperties copies a template item's category and unit straight into inventory_items, but the two sides disagree: inventory_template_items.category text, NULLABLE inventory_template_items.unit text, NULLABLE inventory_items.category inventory_category enum, NOT NULL inventory_items.unit text, NOT NULL So a NULL category, a NULL unit, or any off-enum string in a template row reaches the insert and Postgres rejects it — and this is a BULK insert of every item for every selected property in one statement, so a single bad template row fails the entire application for all of them, not just its own. Latent today, not live: 228 template items in production, 0 null and 0 off-enum. The schema permits it; the data does not currently contain it. Nothing stops a template item being created without a category, which is why the boundary is the right place to fix it rather than the data. The fallbacks are the column defaults declared in the schema itself ('other' / 'units'), not invented values, so a defaulted row is indistinguishable from one the database would have defaulted. The valid enum labels come from Constants — generated from the live schema — rather than a hand-written list, because a second copy of an enum is a copy that drifts. Also replaces the payload's hand-written shape with TablesInsert. That annotation declared `category: string`, which WIDENED the enum the column accepts; once widened nothing checks the value again, which is precisely how the mismatch above stayed invisible. Deriving payload types from the schema is the general fix for this class, and Tables/TablesInsert/TablesUpdate/Enums and Constants are now re-exported from types/database.ts so other call sites can do the same. Progress on the 138: 2 resolved, 136 remain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0176sNbUryq9sVASSkyqpeue --- app/(dashboard)/inventory/actions.ts | 46 +++++++++++++++--------- lib/supabase/server.ts | 2 +- types/database.ts | 20 +++++++++++ unit/inventory/inventory-actions.test.ts | 23 ++++++++++++ 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/app/(dashboard)/inventory/actions.ts b/app/(dashboard)/inventory/actions.ts index f81d829c..faf54f2e 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 } 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, diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index 476cd431..0783cae3 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -18,7 +18,7 @@ import { cookies } from 'next/headers' // live schema and Database re-exports it, so wiring the generic here is: // // import type { Database } from '@/types/database' -// return createServerClient( +// return createServerClient( // // Measured on that basis: 138 errors, across 44 files — a long tail of // insert/update payload mismatches, nullability, and Json shapes, each needing diff --git a/types/database.ts b/types/database.ts index fdcfd87f..f950e62c 100644 --- a/types/database.ts +++ b/types/database.ts @@ -1779,3 +1779,23 @@ export interface PromoHospitableLaunchCounter { */ 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/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') + }) +}) From 89fdc6dfdc9dd728ac9892879110ae3d58fe028b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:57:01 +0000 Subject: [PATCH 4/5] types(db): derive update payloads from the schema; 138 -> 123 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the TS2345 class. The wiring stays out until the class is clear; every change here stands on its own and lands green. TWO PATTERNS, both of which made a query unverifiable rather than wrong: 1. `const patch: Record` for an incrementally-built update. A Record index signature accepts any key with any value, so the payload was checked against nothing. Ten sites now use TablesUpdate<'table'>: purchase_orders, work_orders, org_inventory_catalog, maintenance_schedule_template_items, turnovers, organizations (x2), guidebook_property_configs, property_assets, properties. 2. `LOCAL_SOURCE: Record` in hospitable-owner.ts. Typed as `string`, the table name widened to "any table in the schema", so postgrest-js intersected the columns of all 94 of them and resolved every argument to `never`. Narrowed to the literal union 'bookings' | 'properties' | 'reviews'. All three genuinely carry org_id / external_id / external_source — checked against the live schema — which is what makes the shared query legitimate; the union is what lets the type system confirm it rather than give up. Also types account/delete's `column` field as a literal union. It indexes the organizations update payload, and a `string` index would make that payload implicitly `any` — quietly surrendering the checking the write just gained. A NOTE ON THE NUMBER, because it briefly looked far better than it is. After the batch edit tsc reported 6 errors, down from 132. That was wrong: the import insertion had broken ownerrez/initial-sync.ts's syntax, and a file that will not parse suppresses errors in everything downstream of it. Fixing the syntax put the real figure at 123. Worth recording — a sharp drop in an error count is a reason to check the run, not to celebrate it. Two n-plus-one EXCEPTIONS entries are re-pinned (initial-sync 171 -> 172, guidebook/sync 135 -> 136); both moved by exactly the one import line added above them, same code. Progress: 138 -> 123 overall. TS2345 44 -> 32. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0176sNbUryq9sVASSkyqpeue --- app/(dashboard)/inventory/actions.ts | 4 ++-- app/(dashboard)/maintenance/actions.ts | 4 ++-- app/(dashboard)/templates/inventory/actions.ts | 4 ++-- app/(dashboard)/templates/maintenance/actions.ts | 4 ++-- app/(dashboard)/turnovers/actions.ts | 3 ++- app/api/account/delete/route.ts | 10 ++++++++-- lib/checklists/seed-default-room-templates.ts | 3 ++- lib/guidebook/sync.ts | 3 ++- lib/inngest/functions/asset-scan.ts | 3 ++- lib/inngest/functions/ownerrez/initial-sync.ts | 3 ++- lib/integrations/providers/hospitable-owner.ts | 9 ++++++++- lib/supabase/server.ts | 2 +- unit/guardrails/n-plus-one-loops.test.ts | 6 +++--- 13 files changed, 38 insertions(+), 20 deletions(-) diff --git a/app/(dashboard)/inventory/actions.ts b/app/(dashboard)/inventory/actions.ts index faf54f2e..d1beb6c3 100644 --- a/app/(dashboard)/inventory/actions.ts +++ b/app/(dashboard)/inventory/actions.ts @@ -7,7 +7,7 @@ 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, TablesInsert } from '@/types/database' +import type { InventoryCategory, TablesInsert, TablesUpdate } from '@/types/database' import { Constants } from '@/types/database' /** @@ -697,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/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 0783cae3..a910e9bb 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -20,7 +20,7 @@ import { cookies } from 'next/headers' // import type { Database } from '@/types/database' // return createServerClient( // -// Measured on that basis: 138 errors, across 44 files — a long tail of +// 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 diff --git a/unit/guardrails/n-plus-one-loops.test.ts b/unit/guardrails/n-plus-one-loops.test.ts index f7a8587b..d3cd91fd 100644 --- a/unit/guardrails/n-plus-one-loops.test.ts +++ b/unit/guardrails/n-plus-one-loops.test.ts @@ -133,10 +133,10 @@ 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:180': From ed91aebdb6e94f4f2dc94ef95ea6635c94cb8328 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 16:10:12 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(ci):=20restore=20the=20drift=20gate's?= =?UTF-8?q?=20table=20map=20=E2=80=94=2092=20findings=20were=20a=20parse?= =?UTF-8?q?=20miss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MY REGRESSION, from e424373. db-invariants failed with 92 findings that looked like catastrophic schema drift and were nothing of the kind. scripts/check-type-drift.mjs learns which hand-written interface models which live table by regex-parsing types/database.ts for `Tables: { table: { Row: Interface; ...` ... `Views:`. That block was doing two unrelated jobs: it was the postgrest schema type AND it was this mapping. When Database moved to types/database.generated.ts, the block went with it, the regex matched nothing, the map came back empty — and an empty map means every live table looks unmodelled. 92 tables, 92 findings, none real. Fixed by giving the mapping its own declaration, HandWrittenRowMap, whose only purpose is this. It cannot be carried off by an unrelated refactor again because there is nothing else it is for. All 90 entries recovered from the pre-change file; verified against the E2E project's own db_type_shape_report(): 93 live tables = 90 mapped + 3 allowlisted, zero unmodelled, zero stale. The gate deliberately still reads 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 — pointing the gate there would have turned 92 false failures into a check that passes forever and means nothing, which is worse. Two things so this class fails honestly next time: - The script now exits 1 with one clear message when the map parses empty, instead of emitting a finding per table. A parse miss and real drift should not look the same to whoever reads the log. - unit/guardrails/type-drift-map-parses.test.ts asserts the map still parses, that every interface it names exists, and that the script has not drifted back to the old block. This runs in `checks`, which always runs — the db-invariants job self-disarms without Supabase secrets, so on a fork PR or a local run the breakage would otherwise be invisible until merge. Verified by deleting the map and watching it fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0176sNbUryq9sVASSkyqpeue --- scripts/check-type-drift.mjs | 42 ++++++- types/database.ts | 119 ++++++++++++++++++ unit/guardrails/type-drift-map-parses.test.ts | 68 ++++++++++ 3 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 unit/guardrails/type-drift-map-parses.test.ts 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.ts b/types/database.ts index f950e62c..8ef1781f 100644 --- a/types/database.ts +++ b/types/database.ts @@ -1777,6 +1777,125 @@ export interface PromoHospitableLaunchCounter { * 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' /** 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) + }) +})