feat(documents): document_folders schema + folder_id on documents
Adds a per-port folder tree (self-FK on parent_id, unlimited depth) plus a nullable folder_id on documents (null = root). Sibling-name uniqueness enforced via a unique index on (port_id, COALESCE(parent_id, '__root__'), LOWER(name)) so two folders can't share a name inside the same parent. ON DELETE SET NULL on documents.folder_id and ON DELETE NO ACTION on the parent self-FK so a botched delete never silently destroys data — the service layer implements soft-rescue (bubble children up to parent) instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
src/lib/db/migrations/0050_document_folders.sql
Normal file
33
src/lib/db/migrations/0050_document_folders.sql
Normal file
@@ -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");
|
||||||
@@ -63,6 +63,7 @@ export const documents = pgTable(
|
|||||||
reservationId: text('reservation_id').references(() => berthReservations.id, {
|
reservationId: text('reservation_id').references(() => berthReservations.id, {
|
||||||
onDelete: 'set null',
|
onDelete: 'set null',
|
||||||
}),
|
}),
|
||||||
|
folderId: text('folder_id'),
|
||||||
documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other
|
documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
|
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.
|
// the documents table fully.
|
||||||
index('idx_docs_file_id').on(table.fileId),
|
index('idx_docs_file_id').on(table.fileId),
|
||||||
index('idx_docs_signed_file_id').on(table.signedFileId),
|
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 File = typeof files.$inferSelect;
|
||||||
export type NewFile = typeof files.$inferInsert;
|
export type NewFile = typeof files.$inferInsert;
|
||||||
export type Document = typeof documents.$inferSelect;
|
export type Document = typeof documents.$inferSelect;
|
||||||
|
|||||||
Reference in New Issue
Block a user