diff --git a/src/lib/db/migrations/0051_documents_hub_split.sql b/src/lib/db/migrations/0051_documents_hub_split.sql new file mode 100644 index 00000000..c1c41f31 --- /dev/null +++ b/src/lib/db/migrations/0051_documents_hub_split.sql @@ -0,0 +1,40 @@ +-- Wave 11.B+: documents hub split + auto-filed client folders. +-- Adds system-managed folder lifecycle columns to document_folders +-- (Clients/Companies/Yachts roots + per-entity subfolders), adds the +-- folder_id pointer to files, and backfills the structure for every +-- existing port + file. Idempotent — safe to re-run. + +-- ─── document_folders: lifecycle columns ────────────────────────────────── +ALTER TABLE "document_folders" + ADD COLUMN IF NOT EXISTS "system_managed" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "entity_type" text, + ADD COLUMN IF NOT EXISTS "entity_id" text, + ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone; + +-- Shape guard: system_managed=true implies a known shape. +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 = 'root' + OR (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL) + ); + END IF; +END $$; + +-- Partial unique index: one subfolder per (port, entity_type, entity_id). +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_document_folders_entity" + ON "document_folders" ("port_id", "entity_type", "entity_id") + WHERE "entity_id" IS NOT NULL; + +-- ─── files: folder pointer ──────────────────────────────────────────────── +ALTER TABLE "files" + ADD COLUMN IF NOT EXISTS "folder_id" text REFERENCES "document_folders" ("id") + ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS "idx_files_folder" ON "files" ("folder_id"); +CREATE INDEX IF NOT EXISTS "idx_files_port_folder" ON "files" ("port_id", "folder_id"); diff --git a/src/lib/db/schema/documents.ts b/src/lib/db/schema/documents.ts index acfb9f16..a8dd46d1 100644 --- a/src/lib/db/schema/documents.ts +++ b/src/lib/db/schema/documents.ts @@ -30,6 +30,9 @@ export const files = pgTable( clientId: text('client_id').references(() => clients.id), yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }), companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }), + folderId: text('folder_id').references((): AnyPgColumn => documentFolders.id, { + onDelete: 'set null', + }), filename: text('filename').notNull(), originalName: text('original_name').notNull(), mimeType: text('mime_type'), @@ -45,6 +48,8 @@ export const files = pgTable( index('idx_files_client').on(table.clientId), index('idx_files_yacht').on(table.yachtId), index('idx_files_company').on(table.companyId), + index('idx_files_folder').on(table.folderId), + index('idx_files_port_folder').on(table.portId, table.folderId), ], ); @@ -290,6 +295,20 @@ export const documentFolders = pgTable( // parent in a transaction instead of cascading. parentId: text('parent_id'), name: text('name').notNull(), + /** True = folder is managed by the system (one of the three roots + * Clients/Companies/Yachts, or an auto-created entity subfolder). + * System folders reject rename/move/delete at the API layer. Demoted + * to false when the owning entity is hard-deleted. */ + systemManaged: boolean('system_managed').notNull().default(false), + /** null | 'root' | 'client' | 'company' | 'yacht'. 'root' is the + * three system roots; the entity values mark per-entity subfolders. */ + entityType: text('entity_type'), + /** Null when entityType is null or 'root'; the entity's id otherwise. + * Combined with entityType to dedupe entity folders per port. */ + entityId: text('entity_id'), + /** Mirrors the entity's archive state. Non-null = folder muted in UI + * and auto-deposit halted. Cleared on entity restore. */ + archivedAt: timestamp('archived_at', { withTimezone: true }), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), @@ -302,6 +321,11 @@ export const documentFolders = pgTable( sql`COALESCE(${table.parentId}, '__root__')`, sql`LOWER(${table.name})`, ), + // One subfolder per entity per port. Excludes 'root' folders (the + // three system roots are deduped by sibling-name uniqueness). + uniqueIndex('uniq_document_folders_entity') + .on(table.portId, table.entityType, table.entityId) + .where(sql`${table.entityId} IS NOT NULL`), ], );