diff --git a/src/lib/db/migrations/0052_audit_critical_fixes.sql b/src/lib/db/migrations/0052_audit_critical_fixes.sql new file mode 100644 index 00000000..4d9cd5e8 --- /dev/null +++ b/src/lib/db/migrations/0052_audit_critical_fixes.sql @@ -0,0 +1,94 @@ +-- Prod-readiness audit 2026-05-11 follow-ups (A5 + Audit-17 G-I4 + perf). +-- Fully idempotent — safe to re-run. +-- +-- IMPORTANT: This migration creates indexes CONCURRENTLY, which Postgres +-- forbids inside a transaction block. When applying via `psql`, do NOT +-- wrap the file in `BEGIN/COMMIT`. The DO blocks (for constraint adds) +-- each open their own implicit transaction, which is fine. +-- +-- ─── A5: tighten chk_system_folder_shape (NULL escape) ──────────────────── +-- The constraint installed by 0051 evaluates to NULL when entity_type IS +-- NULL AND system_managed = true. Postgres treats NULL as "not false" so +-- the constraint passes. Tighten the predicate so system_managed=true +-- always implies a non-null entity_type. +-- Drop the old constraint (if present from 0051) then re-create with the +-- tightened predicate. Wrapped in DO blocks for idempotency. + +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_system_folder_shape' + ) THEN + ALTER TABLE "document_folders" + DROP CONSTRAINT "chk_system_folder_shape"; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_system_folder_shape' + ) THEN + ALTER TABLE "document_folders" + ADD CONSTRAINT "chk_system_folder_shape" CHECK ( + NOT system_managed + OR ( + entity_type IS NOT NULL + AND ( + entity_type = 'root' + OR ( + entity_type = ANY(ARRAY['client','company','yacht']) + AND entity_id IS NOT NULL + ) + ) + ) + ); + END IF; +END $$; + +-- ─── Audit-17 G-I4: berth_recommendations.interest_id missing FK ────────── +-- The column has carried an `// references interests.id` comment since +-- introduction but no actual constraint. If an interest is hard-deleted, +-- stale berth_recommendations rows persist and skew the recommender tier +-- aggregates. Cascade delete keeps the dependent rows in sync with the +-- parent interest. + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'berth_recommendations_interest_id_fkey' + ) THEN + ALTER TABLE "berth_recommendations" + ADD CONSTRAINT "berth_recommendations_interest_id_fkey" + FOREIGN KEY ("interest_id") REFERENCES "interests" ("id") + ON DELETE CASCADE NOT VALID; + ALTER TABLE "berth_recommendations" + VALIDATE CONSTRAINT "berth_recommendations_interest_id_fkey"; + END IF; +END $$; + +-- ─── Performance: composite indexes for the aggregated projection ───────── +-- listFilesAggregatedByEntity / listInflightWorkflowsAggregatedByEntity +-- always filter by port_id (defense-in-depth at every join) and then by +-- one of client_id / company_id / yacht_id. The existing single-column +-- indexes (idx_files_client etc.) don't include port_id so Postgres +-- either re-checks per row or falls back to a sequential scan once the +-- per-port row counts grow. Add the composites BEFORE the prod backfill +-- so the rebuild rides the new indexes. +-- +-- CONCURRENTLY avoids the ShareLock that blocks writes during a normal +-- CREATE INDEX. It can fail mid-build — IF NOT EXISTS skips on re-run, +-- but a failed/invalid index from a prior attempt needs to be dropped +-- before this migration succeeds (check `pg_indexes` + `pg_index.indisvalid`). + +CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_files_port_client" + ON "files" ("port_id", "client_id"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_files_port_company" + ON "files" ("port_id", "company_id"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_files_port_yacht" + ON "files" ("port_id", "yacht_id"); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_docs_port_client" + ON "documents" ("port_id", "client_id"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_docs_port_company" + ON "documents" ("port_id", "company_id"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_docs_port_yacht" + ON "documents" ("port_id", "yacht_id"); diff --git a/src/lib/db/schema/berths.ts b/src/lib/db/schema/berths.ts index 7e0ba6a7..86ed9999 100644 --- a/src/lib/db/schema/berths.ts +++ b/src/lib/db/schema/berths.ts @@ -14,6 +14,7 @@ import { import { ports } from './ports'; import { clients } from './clients'; import { yachts } from './yachts'; +import { interests } from './interests'; export const berths = pgTable( 'berths', @@ -131,7 +132,9 @@ export const berthRecommendations = pgTable( id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), - interestId: text('interest_id').notNull(), // references interests.id + interestId: text('interest_id') + .notNull() + .references(() => interests.id, { onDelete: 'cascade' }), berthId: text('berth_id') .notNull() .references(() => berths.id, { onDelete: 'cascade' }), diff --git a/src/lib/db/schema/documents.ts b/src/lib/db/schema/documents.ts index a8dd46d1..e79a8360 100644 --- a/src/lib/db/schema/documents.ts +++ b/src/lib/db/schema/documents.ts @@ -50,6 +50,12 @@ export const files = pgTable( index('idx_files_company').on(table.companyId), index('idx_files_folder').on(table.folderId), index('idx_files_port_folder').on(table.portId, table.folderId), + // Composite indexes for the aggregated-projection queries + // (`listFilesAggregatedByEntity`) — every join carries a defense-in- + // depth `port_id` filter so the leading column matters at scale. + index('idx_files_port_client').on(table.portId, table.clientId), + index('idx_files_port_company').on(table.portId, table.companyId), + index('idx_files_port_yacht').on(table.portId, table.yachtId), ], ); @@ -109,6 +115,12 @@ export const documents = pgTable( index('idx_docs_file_id').on(table.fileId), index('idx_docs_signed_file_id').on(table.signedFileId), index('idx_docs_folder').on(table.folderId), + // Composite indexes for the aggregated-projection queries + // (`listInflightWorkflowsAggregatedByEntity`) — every join carries a + // defense-in-depth `port_id` filter so the leading column matters at scale. + index('idx_docs_port_client').on(table.portId, table.clientId), + index('idx_docs_port_company').on(table.portId, table.companyId), + index('idx_docs_port_yacht').on(table.portId, table.yachtId), ], );