Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,24 @@ export async function myAction(input: MyInput): Promise<ActionResult> {

**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
`<Database>` 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.

Comment on lines +592 to +609

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reconcile the new text with the prose that follows it.

The new block states that a migration touches both files, and that no .from()/.rpc() call is type-checked yet. The unchanged text below still states the opposite in two places:

  • Line 611-613: "The Supabase TypeScript client infers return types from this file — not from the live database schema." This contradicts Line 600-602, which states the <Database> generic is omitted and nothing is type-checked.
  • Line 610 and Line 816: both instruct updating only types/database.ts in the same commit as the migration.

A reader who follows Line 610 or Line 816 will skip the regeneration step the new block requires. Update those lines to name both files.

📝 Proposed wording change
-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
-but not in `types/database.ts` causes TypeScript build failures even when
-the SQL query and select string are perfectly correct.
+Whenever a DB migration adds or changes a column, regenerate
+`types/database.generated.ts` AND update the matching hand-written interface
+in `types/database.ts`, both in the same commit. A column that exists in the
+DB but not in `types/database.ts` causes TypeScript build failures in every
+call site that reads it through a hand-written interface, even when the SQL
+query and select string are correct.

And at Line 816:

-Always update `types/database.ts` in the same commit as the migration.
+Always regenerate `types/database.generated.ts` and update
+`types/database.ts` in the same commit as the migration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 592 - 609, Reconcile the surrounding CLAUDE.md
guidance with the two-file type workflow: revise the prose near the Supabase
client typing statement so it no longer claims return types come from
types/database.ts while the Database generic is omitted, and update both
migration instructions near the existing references to types/database.ts to
require updating/regenerating types/database.generated.ts as well. Preserve the
documented roles of types/database.generated.ts and types/database.ts.

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
Expand Down
48 changes: 30 additions & 18 deletions app/(dashboard)/inventory/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { logAuditEvent } from '@/lib/audit'
import { reportError } from '@/lib/observability/report-error'
import { unwrapJoin } from '@/lib/utils/supabase-joins'
import { fetchAllRows } from '@/lib/inngest/paginate'
import type { InventoryCategory } from '@/types/database'
import type { InventoryCategory, TablesInsert, TablesUpdate } from '@/types/database'
import { Constants } from '@/types/database'

/**
* Deterministic, locale-independent string ordering for CANONICALISATION.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<TablesInsert<'inventory_items'>> = []

for (const propertyId of targetPropertyIds) {
const existing = existingByProperty[propertyId] ?? { catalogIds: new Set<string>(), names: new Set<string>() }
Expand All @@ -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,
Expand Down Expand Up @@ -685,7 +697,7 @@ export async function updatePurchaseOrderStatus(
if (!po) return { error: 'Purchase order not found' }
if (po.status === status) return {}

const statusUpdate: Record<string, unknown> = { status }
const statusUpdate: TablesUpdate<'purchase_orders'> = { status }
if (status === 'sent') statusUpdate.sent_at = new Date().toISOString()

const { data: updated, error } = await supabase
Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/maintenance/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = status === 'completed'
const update: TablesUpdate<'work_orders'> = status === 'completed'
? workOrderCompletionFields(notes ?? null)
: { status }

Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/templates/inventory/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────────────────────────────────────

Expand Down Expand Up @@ -58,7 +58,7 @@ export async function updateCatalogItem(
try {
const { user, supabase, membership } = await requireOrgRole(['admin', 'manager'])

const patch: Record<string, string> = {}
const patch: TablesUpdate<'org_inventory_catalog'> = {}
if (updates.name !== undefined) {
const trimmed = updates.name.trim()
if (!trimmed) return { error: 'Item name is required.' }
Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/templates/maintenance/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> = {}
const patch: TablesUpdate<'maintenance_schedule_template_items'> = {}
if (updates.name !== undefined) {
const trimmed = updates.name.trim()
if (!trimmed) return { error: 'Item name is required.' }
Expand Down
3 changes: 2 additions & 1 deletion app/(dashboard)/turnovers/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -306,7 +307,7 @@ export async function updateTurnoverStatus(
try {
const { supabase, membership, user } = await requireOrgMember()

const update: Record<string, unknown> = { status }
const update: TablesUpdate<'turnovers'> = { status }
const completedAt = new Date().toISOString()
if (status === 'in_progress') {
update.started_at = completedAt
Expand Down
10 changes: 8 additions & 2 deletions app/api/account/delete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createServiceClient>

Expand Down Expand Up @@ -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,
Expand All @@ -191,7 +197,7 @@ async function cancelOrgSubscriptions(

if (!subs.length) return null

const cleared: Record<string, null> = {}
const cleared: TablesUpdate<'organizations'> = {}
for (const sub of subs) {
try {
await stripe.subscriptions.cancel(sub.id)
Expand Down
3 changes: 2 additions & 1 deletion lib/checklists/seed-default-room-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createServiceClient>

Expand Down Expand Up @@ -172,7 +173,7 @@ export async function seedDefaultRoomTemplatesIfNeeded(orgId: string): Promise<v
// fully completed or because a PM cleared just one side of the
// mapping. Re-deriving an existing template's id here would silently
// overwrite whatever the PM currently has that mapping set to.
const mappingUpdates: Record<string, string> = {}
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

Expand Down
3 changes: 2 additions & 1 deletion lib/guidebook/sync.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, unknown> = {}
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
Expand Down
3 changes: 2 additions & 1 deletion lib/inngest/functions/asset-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> = {}
const updates: TablesUpdate<'property_assets'> = {}

// Never downgrade an already-completed scan — a duplicate/retried run
// disagreeing on `found` (LLM output isn't perfectly deterministic)
Expand Down
3 changes: 2 additions & 1 deletion lib/inngest/functions/ownerrez/initial-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown> = {}
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
Expand Down
61 changes: 16 additions & 45 deletions lib/inngest/functions/turnover-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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' })
Expand Down
9 changes: 8 additions & 1 deletion lib/integrations/providers/hospitable-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ export interface ResolvedHospitableOwner {
}

/** Domain table + column that stores each entity kind's provider-side id. */
const LOCAL_SOURCE: Record<HospitableEntityKind, { table: string }> = {
// 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<HospitableEntityKind, { table: 'bookings' | 'properties' | 'reviews' }> = {
reservation: { table: 'bookings' },
property: { table: 'properties' },
review: { table: 'reviews' },
Expand Down
Loading
Loading