diff --git a/src/lib/db/migrations/0050_document_folders.sql b/src/lib/db/migrations/0050_document_folders.sql new file mode 100644 index 00000000..920f1c28 --- /dev/null +++ b/src/lib/db/migrations/0050_document_folders.sql @@ -0,0 +1,33 @@ +-- Document folders: per-port, unlimited-depth tree. parent_id references +-- another document_folders row; null = root. Sibling-name uniqueness is +-- enforced via a partial-uniqueness on (port_id, COALESCE(parent_id, +-- '__root__'), LOWER(name)) so two folders can't share a name inside +-- the same parent. The CRM checks parent_id chain for cycles in the +-- service layer; no DB-side cycle guard. +CREATE TABLE IF NOT EXISTS "document_folders" ( + "id" text PRIMARY KEY NOT NULL, + "port_id" text NOT NULL REFERENCES "ports" ("id"), + "parent_id" text, + "name" text NOT NULL, + "created_by" text NOT NULL, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); + +ALTER TABLE "document_folders" + ADD CONSTRAINT "document_folders_parent_fk" + FOREIGN KEY ("parent_id") REFERENCES "document_folders" ("id") + ON DELETE NO ACTION; + +CREATE INDEX IF NOT EXISTS "idx_document_folders_port" + ON "document_folders" ("port_id"); +CREATE INDEX IF NOT EXISTS "idx_document_folders_parent" + ON "document_folders" ("parent_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_document_folders_sibling_name" + ON "document_folders" ("port_id", COALESCE("parent_id", '__root__'), LOWER("name")); + +ALTER TABLE "documents" + ADD COLUMN IF NOT EXISTS "folder_id" text REFERENCES "document_folders" ("id") ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS "idx_docs_folder" + ON "documents" ("folder_id"); diff --git a/src/lib/db/schema/documents.ts b/src/lib/db/schema/documents.ts index 88e33b13..0242f2d3 100644 --- a/src/lib/db/schema/documents.ts +++ b/src/lib/db/schema/documents.ts @@ -63,6 +63,7 @@ export const documents = pgTable( reservationId: text('reservation_id').references(() => berthReservations.id, { onDelete: 'set null', }), + folderId: text('folder_id'), documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other title: text('title').notNull(), status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled @@ -99,6 +100,7 @@ export const documents = pgTable( // the documents table fully. index('idx_docs_file_id').on(table.fileId), index('idx_docs_signed_file_id').on(table.signedFileId), + index('idx_docs_folder').on(table.folderId), ], ); @@ -262,6 +264,44 @@ export const formSubmissions = pgTable( ], ); +/** + * Per-port folder tree for organising documents. Self-referencing + * via parent_id; null parent = root. Unlimited depth — the UI is the + * gate (collapsed sidebar tree + breadcrumb header). Cycle prevention + * happens in the service layer (parent_id chain walk on insert/move). + * + * On folder delete: children (both subfolders and documents) bubble + * up to the deleted folder's parent. Never CASCADE. + */ +export const documentFolders = pgTable( + 'document_folders', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + portId: text('port_id') + .notNull() + .references(() => ports.id), + parentId: text('parent_id'), + name: text('name').notNull(), + createdBy: text('created_by').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('idx_document_folders_port').on(table.portId), + index('idx_document_folders_parent').on(table.parentId), + uniqueIndex('uniq_document_folders_sibling_name').on( + table.portId, + sql`COALESCE(${table.parentId}, '__root__')`, + sql`LOWER(${table.name})`, + ), + ], +); + +export type DocumentFolder = typeof documentFolders.$inferSelect; +export type NewDocumentFolder = typeof documentFolders.$inferInsert; + export type File = typeof files.$inferSelect; export type NewFile = typeof files.$inferInsert; export type Document = typeof documents.$inferSelect;