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:
2026-05-09 19:12:44 +02:00
parent 51a60c1b9e
commit 5bed62dc72
2 changed files with 73 additions and 0 deletions

View File

@@ -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;