feat(documents): schema for hub split + entity-folder lifecycle
Adds system_managed / entity_type / entity_id / archived_at to document_folders for the three system roots (Clients/Companies/ Yachts) + per-entity auto-subfolders. Adds files.folder_id so a file's home is a first-class field (not derived from storagePath prefix). Partial unique index uniq_document_folders_entity dedupes entity subfolders per port; chk_system_folder_shape pins the shape of system rows. Migration is idempotent and ships without backfill — the backfill script runs as a separate deploy step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
40
src/lib/db/migrations/0051_documents_hub_split.sql
Normal file
40
src/lib/db/migrations/0051_documents_hub_split.sql
Normal file
@@ -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");
|
||||||
@@ -30,6 +30,9 @@ export const files = pgTable(
|
|||||||
clientId: text('client_id').references(() => clients.id),
|
clientId: text('client_id').references(() => clients.id),
|
||||||
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
|
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
|
||||||
companyId: text('company_id').references(() => companies.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(),
|
filename: text('filename').notNull(),
|
||||||
originalName: text('original_name').notNull(),
|
originalName: text('original_name').notNull(),
|
||||||
mimeType: text('mime_type'),
|
mimeType: text('mime_type'),
|
||||||
@@ -45,6 +48,8 @@ export const files = pgTable(
|
|||||||
index('idx_files_client').on(table.clientId),
|
index('idx_files_client').on(table.clientId),
|
||||||
index('idx_files_yacht').on(table.yachtId),
|
index('idx_files_yacht').on(table.yachtId),
|
||||||
index('idx_files_company').on(table.companyId),
|
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.
|
// parent in a transaction instead of cascading.
|
||||||
parentId: text('parent_id'),
|
parentId: text('parent_id'),
|
||||||
name: text('name').notNull(),
|
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(),
|
createdBy: text('created_by').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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`COALESCE(${table.parentId}, '__root__')`,
|
||||||
sql`LOWER(${table.name})`,
|
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`),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user