applyEntityArchivedSuffix stamps " (archived)" + archived_at on the entity subfolder so the UI mutes it and auto-deposit halts. Restore is the inverse. demoteSystemFolderOnEntityDelete flips system_managed=false, appends " (deleted)", and clears the entity FK so the partial unique index releases the slot — orphaned files retain their entity FK snapshots and surface in the rep's clean-up view. All three helpers are best-effort from the entity-side hooks; folder errors are logged at warn level but do not fail the entity-update operation. UPDATE WHERE clauses include port_id (defense-in-depth). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
678 lines
23 KiB
TypeScript
678 lines
23 KiB
TypeScript
import { and, asc, eq } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents';
|
|
import { clients } from '@/lib/db/schema/clients';
|
|
import { companies } from '@/lib/db/schema/companies';
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
import { createAuditLog } from '@/lib/audit';
|
|
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
|
|
|
/**
|
|
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
|
|
* raised specifically on the sibling-name unique index. Narrowing to this
|
|
* constraint prevents swallowing unrelated unique violations.
|
|
*/
|
|
function isSiblingNameConflict(err: unknown): boolean {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const e = err as {
|
|
code?: unknown;
|
|
constraint_name?: unknown;
|
|
constraint?: unknown;
|
|
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
|
|
};
|
|
const code = e.code ?? e.cause?.code;
|
|
if (code !== '23505') return false;
|
|
const constraint =
|
|
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
|
|
return constraint === 'uniq_document_folders_sibling_name';
|
|
}
|
|
|
|
/**
|
|
* Throws ConflictError if the folder is system-managed. Centralises the
|
|
* rejection so rename/move/delete all surface identical error shapes.
|
|
*/
|
|
async function assertNotSystemManaged(
|
|
portId: string,
|
|
folderId: string,
|
|
action: 'rename' | 'move' | 'delete',
|
|
): Promise<DocumentFolder> {
|
|
const folder = await db.query.documentFolders.findFirst({
|
|
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
|
|
});
|
|
if (!folder) throw new NotFoundError('Folder');
|
|
if (folder.systemManaged) {
|
|
const verb = action === 'rename' ? 'renamed' : action === 'move' ? 'moved' : 'deleted';
|
|
throw new ConflictError(`System folders can't be ${verb}`);
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
export interface FolderNode extends DocumentFolder {
|
|
children: FolderNode[];
|
|
}
|
|
|
|
/**
|
|
* Returns the entire folder tree for a port, nested under their
|
|
* parents. Roots come back at the top level. Order is alphabetical
|
|
* (case-insensitive) within each parent — matches the sibling-uniqueness
|
|
* index ordering and gives reps a stable browsing experience.
|
|
*
|
|
* Uses a single SELECT + JS nesting rather than a recursive CTE; the
|
|
* folder tree is small (UI gates depth; thousands of folders would be
|
|
* a misuse) so the in-memory build is cheaper than a CTE round-trip.
|
|
*/
|
|
export async function listTree(portId: string): Promise<FolderNode[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(documentFolders)
|
|
.where(eq(documentFolders.portId, portId))
|
|
.orderBy(asc(documentFolders.name));
|
|
|
|
const byId = new Map<string, FolderNode>();
|
|
for (const row of rows) byId.set(row.id, { ...row, children: [] });
|
|
|
|
const roots: FolderNode[] = [];
|
|
for (const node of byId.values()) {
|
|
if (node.parentId === null) {
|
|
roots.push(node);
|
|
} else {
|
|
const parent = byId.get(node.parentId);
|
|
if (parent) parent.children.push(node);
|
|
// Orphan rows (parentId pointing nowhere) are dropped from the
|
|
// tree but stay in the DB. Surface via a separate maintenance
|
|
// query if needed; never silently re-parent.
|
|
}
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
interface CreateFolderInput {
|
|
name: string;
|
|
parentId: string | null;
|
|
}
|
|
|
|
/**
|
|
* Creates a folder under the given parent. Throws ConflictError when
|
|
* a sibling with the same case-insensitive name already exists (the
|
|
* DB unique index is the authoritative guard; this maps the Postgres
|
|
* 23505 to the typed error). Throws ValidationError when `parentId`
|
|
* doesn't belong to this port (cross-port leakage guard).
|
|
*/
|
|
export async function createFolder(
|
|
portId: string,
|
|
userId: string,
|
|
data: CreateFolderInput,
|
|
): Promise<DocumentFolder> {
|
|
const trimmed = data.name.trim();
|
|
if (!trimmed) throw new ValidationError('Folder name cannot be empty');
|
|
if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars');
|
|
|
|
if (data.parentId !== null) {
|
|
const parent = await db.query.documentFolders.findFirst({
|
|
where: and(eq(documentFolders.id, data.parentId), eq(documentFolders.portId, portId)),
|
|
});
|
|
if (!parent) throw new ValidationError('Invalid parent folder');
|
|
}
|
|
|
|
try {
|
|
const [row] = await db
|
|
.insert(documentFolders)
|
|
.values({
|
|
portId,
|
|
parentId: data.parentId,
|
|
name: trimmed,
|
|
createdBy: userId,
|
|
})
|
|
.returning();
|
|
if (!row) throw new NotFoundError('Folder');
|
|
return row;
|
|
} catch (err) {
|
|
if (isSiblingNameConflict(err)) {
|
|
throw new ConflictError(`A folder named "${trimmed}" already exists here`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renames a folder. Throws ConflictError if a sibling with the same
|
|
* case-insensitive name already exists. Throws NotFoundError if the
|
|
* folder doesn't belong to the given port (cross-port leakage guard).
|
|
*/
|
|
export async function renameFolder(
|
|
portId: string,
|
|
folderId: string,
|
|
newName: string,
|
|
userId: string,
|
|
): Promise<DocumentFolder> {
|
|
const trimmed = newName.trim();
|
|
if (!trimmed) throw new ValidationError('Folder name cannot be empty');
|
|
if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars');
|
|
|
|
const existing = await assertNotSystemManaged(portId, folderId, 'rename');
|
|
|
|
try {
|
|
const [updated] = await db
|
|
.update(documentFolders)
|
|
.set({ name: trimmed, updatedAt: new Date() })
|
|
.where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)))
|
|
.returning();
|
|
if (!updated) throw new NotFoundError('Folder');
|
|
|
|
void createAuditLog({
|
|
userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'document_folder',
|
|
entityId: folderId,
|
|
oldValue: { name: existing.name },
|
|
newValue: { name: trimmed },
|
|
});
|
|
|
|
return updated;
|
|
} catch (err) {
|
|
if (isSiblingNameConflict(err)) {
|
|
throw new ConflictError(`A folder named "${trimmed}" already exists here`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves a folder to a new parent (or to root with newParentId=null).
|
|
* Walks the destination's ancestor chain to detect cycles before
|
|
* writing. Throws ValidationError for cycle/invalid-parent, NotFoundError
|
|
* for cross-port access.
|
|
*/
|
|
export async function moveFolder(
|
|
portId: string,
|
|
folderId: string,
|
|
newParentId: string | null,
|
|
userId: string,
|
|
): Promise<DocumentFolder> {
|
|
if (newParentId === folderId) {
|
|
throw new ValidationError('Cannot move a folder under itself (cycle)');
|
|
}
|
|
|
|
const folder = await assertNotSystemManaged(portId, folderId, 'move');
|
|
|
|
if (newParentId !== null) {
|
|
const newParent = await db.query.documentFolders.findFirst({
|
|
where: and(eq(documentFolders.id, newParentId), eq(documentFolders.portId, portId)),
|
|
});
|
|
if (!newParent) throw new ValidationError('Invalid parent folder');
|
|
|
|
// Cycle check: walk the destination's ancestor chain. If we hit
|
|
// folderId, the destination is a descendant of the folder being
|
|
// moved — moving would create a cycle.
|
|
let cursor: string | null = newParent.parentId;
|
|
const seen = new Set<string>([newParent.id]);
|
|
while (cursor) {
|
|
if (cursor === folderId) {
|
|
throw new ValidationError('Cannot move a folder under one of its descendants (cycle)');
|
|
}
|
|
if (seen.has(cursor)) break; // defensive — pre-existing cycle, bail
|
|
seen.add(cursor);
|
|
const next = await db.query.documentFolders.findFirst({
|
|
where: and(eq(documentFolders.id, cursor), eq(documentFolders.portId, portId)),
|
|
columns: { parentId: true },
|
|
});
|
|
cursor = next?.parentId ?? null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const [updated] = await db
|
|
.update(documentFolders)
|
|
.set({ parentId: newParentId, updatedAt: new Date() })
|
|
.where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)))
|
|
.returning();
|
|
if (!updated) throw new NotFoundError('Folder');
|
|
|
|
void createAuditLog({
|
|
userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'document_folder',
|
|
entityId: folderId,
|
|
oldValue: { parentId: folder.parentId },
|
|
newValue: { parentId: newParentId },
|
|
metadata: { type: 'folder_move' },
|
|
});
|
|
|
|
return updated;
|
|
} catch (err) {
|
|
if (isSiblingNameConflict(err)) {
|
|
throw new ConflictError('A folder with that name already exists in the destination');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Soft-rescue delete: re-parent every child folder + every linked
|
|
* document to the deleted folder's parent (or to root if the deleted
|
|
* folder is at root). Audit-logged. Wrapped in a transaction so
|
|
* partial failures don't leave dangling rows.
|
|
*/
|
|
export async function deleteFolderSoftRescue(
|
|
portId: string,
|
|
folderId: string,
|
|
userId: string,
|
|
): Promise<void> {
|
|
const folder = await assertNotSystemManaged(portId, folderId, 'delete');
|
|
|
|
const newParent = folder.parentId; // null = re-parent to root
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(documentFolders)
|
|
.set({ parentId: newParent, updatedAt: new Date() })
|
|
.where(and(eq(documentFolders.parentId, folderId), eq(documentFolders.portId, portId)));
|
|
|
|
await tx
|
|
.update(documents)
|
|
.set({ folderId: newParent })
|
|
.where(and(eq(documents.folderId, folderId), eq(documents.portId, portId)));
|
|
|
|
await tx
|
|
.delete(documentFolders)
|
|
.where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)));
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId,
|
|
portId,
|
|
action: 'delete',
|
|
entityType: 'document_folder',
|
|
entityId: folderId,
|
|
oldValue: { name: folder.name, parentId: folder.parentId },
|
|
metadata: { rescuedTo: newParent },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Walk a folder tree and return the IDs of every descendant of `rootId`
|
|
* (NOT including rootId itself). Used by `listDocuments` when
|
|
* `includeDescendants=true` so a port-wide tree fetch is reused
|
|
* instead of a recursive CTE.
|
|
*/
|
|
export function collectDescendantIds(tree: FolderNode[], rootId: string): string[] {
|
|
const out: string[] = [];
|
|
function visit(nodes: FolderNode[], inside: boolean) {
|
|
for (const n of nodes) {
|
|
if (inside || n.id === rootId) {
|
|
if (n.id !== rootId) out.push(n.id);
|
|
visit(n.children, true);
|
|
} else {
|
|
visit(n.children, false);
|
|
}
|
|
}
|
|
}
|
|
visit(tree, false);
|
|
return out;
|
|
}
|
|
|
|
const SYSTEM_ROOT_NAMES = ['Clients', 'Companies', 'Yachts'] as const;
|
|
type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number];
|
|
|
|
/**
|
|
* Idempotently create the three system root folders for a port
|
|
* (`Clients/`, `Companies/`, `Yachts/`). Returns the rows in stable
|
|
* order. Safe to call on every port-init and on every backfill run.
|
|
*
|
|
* Uses INSERT … ON CONFLICT … DO NOTHING via the sibling-name unique
|
|
* index (`uniq_document_folders_sibling_name`) so a concurrent caller
|
|
* can't race two inserts of the same root. Re-SELECTs on conflict so
|
|
* the return shape is always populated.
|
|
*/
|
|
export async function ensureSystemRoots(portId: string, userId: string): Promise<DocumentFolder[]> {
|
|
const values = SYSTEM_ROOT_NAMES.map((name) => ({
|
|
portId,
|
|
parentId: null,
|
|
name,
|
|
systemManaged: true,
|
|
entityType: 'root' as const,
|
|
entityId: null,
|
|
createdBy: userId,
|
|
}));
|
|
|
|
// ON CONFLICT DO NOTHING with no target is safe here because root
|
|
// inserts can only collide on `uniq_document_folders_sibling_name`
|
|
// (entityId is null on roots, so the partial index
|
|
// `uniq_document_folders_entity` is excluded). Do not copy this
|
|
// pattern into helpers that insert per-entity subfolders — they
|
|
// need an explicit target to avoid masking real conflicts.
|
|
await db.insert(documentFolders).values(values).onConflictDoNothing();
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(documentFolders)
|
|
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
|
|
|
|
return SYSTEM_ROOT_NAMES.map((name: SystemRootName) => {
|
|
const row = rows.find((r) => r.name === name);
|
|
if (!row) throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
|
|
return row;
|
|
});
|
|
}
|
|
|
|
// ─── ensureEntityFolder ──────────────────────────────────────────────────────
|
|
|
|
export type EntityType = 'client' | 'company' | 'yacht';
|
|
const ENTITY_TYPES = new Set<EntityType>(['client', 'company', 'yacht']);
|
|
|
|
/**
|
|
* Returns the display name for an entity used to label its subfolder.
|
|
* Clients use `fullName` verbatim (matching rep-facing list views).
|
|
* Companies and yachts use their `name` column verbatim.
|
|
*/
|
|
async function resolveEntityDisplayName(
|
|
portId: string,
|
|
entityType: EntityType,
|
|
entityId: string,
|
|
): Promise<string> {
|
|
if (entityType === 'client') {
|
|
const c = await db.query.clients.findFirst({
|
|
where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
|
|
columns: { fullName: true },
|
|
});
|
|
if (!c) throw new NotFoundError('Client');
|
|
return c.fullName;
|
|
}
|
|
if (entityType === 'company') {
|
|
const co = await db.query.companies.findFirst({
|
|
where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
|
|
columns: { name: true },
|
|
});
|
|
if (!co) throw new NotFoundError('Company');
|
|
return co.name;
|
|
}
|
|
// yacht
|
|
const y = await db.query.yachts.findFirst({
|
|
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
|
|
columns: { name: true },
|
|
});
|
|
if (!y) throw new NotFoundError('Yacht');
|
|
return y.name;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
|
|
* on the per-entity unique index (`uniq_document_folders_entity`). Narrowing
|
|
* to this specific constraint lets callers distinguish a concurrent-winner
|
|
* race (same entity_id inserted twice) from a sibling-name collision.
|
|
*/
|
|
function isEntityFolderConflict(err: unknown): boolean {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const e = err as {
|
|
code?: unknown;
|
|
constraint_name?: unknown;
|
|
constraint?: unknown;
|
|
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
|
|
};
|
|
const code = e.code ?? e.cause?.code;
|
|
if (code !== '23505') return false;
|
|
const constraint =
|
|
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
|
|
return constraint === 'uniq_document_folders_entity';
|
|
}
|
|
|
|
/**
|
|
* Idempotently create the per-entity subfolder under the matching system
|
|
* root (`Clients/` / `Companies/` / `Yachts/`). Returns the folder row
|
|
* regardless of whether it was newly created or already existed.
|
|
*
|
|
* Concurrent callers race safely via the partial unique index
|
|
* `uniq_document_folders_entity` — the loser INSERT errors and the
|
|
* re-SELECT returns the winner's row.
|
|
*
|
|
* On sibling-name collision (two entities want the same display name),
|
|
* appends a numeric suffix `(2)`, `(3)`, …, until the insert succeeds.
|
|
* The `system_managed` flag stays true on the suffixed folder.
|
|
*
|
|
* Note: this helper deliberately does NOT use the no-args
|
|
* onConflictDoNothing() pattern from ensureSystemRoots. Entity inserts
|
|
* can conflict on EITHER uniq_document_folders_entity (race winner
|
|
* already inserted the same entity_id) OR uniq_document_folders_sibling_name
|
|
* (different entity, same display name). We need to discriminate so we
|
|
* can retry-with-suffix on the name collision and re-SELECT-and-return
|
|
* on the entity-id collision.
|
|
*/
|
|
export async function ensureEntityFolder(
|
|
portId: string,
|
|
entityType: EntityType,
|
|
entityId: string,
|
|
userId: string,
|
|
): Promise<DocumentFolder> {
|
|
if (!ENTITY_TYPES.has(entityType)) {
|
|
throw new ValidationError(`Unknown entity type: ${entityType}`);
|
|
}
|
|
|
|
// Fast path: row already exists.
|
|
const existing = await db.query.documentFolders.findFirst({
|
|
where: and(
|
|
eq(documentFolders.portId, portId),
|
|
eq(documentFolders.entityType, entityType),
|
|
eq(documentFolders.entityId, entityId),
|
|
),
|
|
});
|
|
if (existing) return existing;
|
|
|
|
// Locate the system root for this entity type.
|
|
const rootName: SystemRootName =
|
|
entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts';
|
|
const root = await db.query.documentFolders.findFirst({
|
|
where: and(
|
|
eq(documentFolders.portId, portId),
|
|
eq(documentFolders.entityType, 'root'),
|
|
eq(documentFolders.name, rootName),
|
|
),
|
|
});
|
|
if (!root) {
|
|
// Self-heal: the port-init hook may have been skipped (legacy port).
|
|
await ensureSystemRoots(portId, userId);
|
|
return ensureEntityFolder(portId, entityType, entityId, userId);
|
|
}
|
|
|
|
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
|
|
|
|
// Try the base name first; on sibling-name collision, append (2), (3)...
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
const candidate = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`;
|
|
try {
|
|
const [row] = await db
|
|
.insert(documentFolders)
|
|
.values({
|
|
portId,
|
|
parentId: root.id,
|
|
name: candidate,
|
|
systemManaged: true,
|
|
entityType,
|
|
entityId,
|
|
createdBy: userId,
|
|
})
|
|
.returning();
|
|
if (!row) throw new Error('ensureEntityFolder: insert returned no row');
|
|
return row;
|
|
} catch (err) {
|
|
// If another caller won the entity-id race, re-SELECT and return their row.
|
|
if (isEntityFolderConflict(err)) {
|
|
const winner = await db.query.documentFolders.findFirst({
|
|
where: and(
|
|
eq(documentFolders.portId, portId),
|
|
eq(documentFolders.entityType, entityType),
|
|
eq(documentFolders.entityId, entityId),
|
|
),
|
|
});
|
|
if (winner) return winner;
|
|
}
|
|
// Sibling-name collision (different entity, same name) → bump suffix and retry.
|
|
if (isSiblingNameConflict(err)) continue;
|
|
throw err;
|
|
}
|
|
}
|
|
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
|
}
|
|
|
|
/**
|
|
* Rename the per-entity subfolder to match the entity's current display
|
|
* name. Called from the entity rename services (`updateClient`,
|
|
* `updateCompany`, `updateYacht`). No-op when the folder does not exist
|
|
* (lazy creation — entities without a folder skip the sync entirely).
|
|
*
|
|
* Sibling-name collision is resolved by suffix bump (matches
|
|
* `ensureEntityFolder` semantics).
|
|
*
|
|
* Intentionally does NOT call `assertNotSystemManaged` — this helper
|
|
* is the legitimate path for renaming a system folder.
|
|
*/
|
|
export async function syncEntityFolderName(
|
|
portId: string,
|
|
entityType: EntityType,
|
|
entityId: string,
|
|
_userId: string,
|
|
): Promise<void> {
|
|
if (!ENTITY_TYPES.has(entityType)) return;
|
|
|
|
const folder = await db.query.documentFolders.findFirst({
|
|
where: and(
|
|
eq(documentFolders.portId, portId),
|
|
eq(documentFolders.entityType, entityType),
|
|
eq(documentFolders.entityId, entityId),
|
|
),
|
|
});
|
|
if (!folder) return; // Lazy creation — nothing to sync yet.
|
|
|
|
// Preserve archived suffix if present.
|
|
const isArchived = folder.name.endsWith(' (archived)');
|
|
const isDeleted = folder.name.endsWith(' (deleted)');
|
|
if (isDeleted) return; // Demoted; rep owns the name now.
|
|
|
|
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
|
|
const targetSuffix = isArchived ? ' (archived)' : '';
|
|
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
const candidate =
|
|
attempt === 0 ? `${baseName}${targetSuffix}` : `${baseName} (${attempt + 1})${targetSuffix}`;
|
|
if (candidate === folder.name) return; // No-op rename.
|
|
try {
|
|
const [updated] = await db
|
|
.update(documentFolders)
|
|
.set({ name: candidate, updatedAt: new Date() })
|
|
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)))
|
|
.returning();
|
|
if (updated) return;
|
|
} catch (err) {
|
|
if (isSiblingNameConflict(err)) continue;
|
|
throw err;
|
|
}
|
|
}
|
|
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
|
}
|
|
|
|
// ─── Archive / Restore / Demote helpers ──────────────────────────────────────
|
|
|
|
const ARCHIVED_SUFFIX = ' (archived)';
|
|
const DELETED_SUFFIX = ' (deleted)';
|
|
|
|
/**
|
|
* Stamp an entity's subfolder as archived: append " (archived)" to the
|
|
* name (idempotent — won't double-append) and set archived_at. No-op
|
|
* when the folder does not exist (lazy creation). Used by the entity
|
|
* archive paths in clients / companies / yachts services.
|
|
*/
|
|
export async function applyEntityArchivedSuffix(
|
|
portId: string,
|
|
entityType: EntityType,
|
|
entityId: string,
|
|
): Promise<void> {
|
|
if (!ENTITY_TYPES.has(entityType)) return;
|
|
const folder = await db.query.documentFolders.findFirst({
|
|
where: and(
|
|
eq(documentFolders.portId, portId),
|
|
eq(documentFolders.entityType, entityType),
|
|
eq(documentFolders.entityId, entityId),
|
|
),
|
|
});
|
|
if (!folder) return;
|
|
const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
|
|
? folder.name
|
|
: `${folder.name}${ARCHIVED_SUFFIX}`;
|
|
await db
|
|
.update(documentFolders)
|
|
.set({ name: newName, archivedAt: new Date(), updatedAt: new Date() })
|
|
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
|
|
}
|
|
|
|
/**
|
|
* Inverse of `applyEntityArchivedSuffix` — strip " (archived)" from
|
|
* the name and clear archived_at. No-op when the folder does not
|
|
* exist or wasn't archived.
|
|
*/
|
|
export async function applyEntityRestoredSuffix(
|
|
portId: string,
|
|
entityType: EntityType,
|
|
entityId: string,
|
|
): Promise<void> {
|
|
if (!ENTITY_TYPES.has(entityType)) return;
|
|
const folder = await db.query.documentFolders.findFirst({
|
|
where: and(
|
|
eq(documentFolders.portId, portId),
|
|
eq(documentFolders.entityType, entityType),
|
|
eq(documentFolders.entityId, entityId),
|
|
),
|
|
});
|
|
if (!folder) return;
|
|
const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
|
|
? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
|
|
: folder.name;
|
|
await db
|
|
.update(documentFolders)
|
|
.set({ name: newName, archivedAt: null, updatedAt: new Date() })
|
|
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
|
|
}
|
|
|
|
/**
|
|
* Entity has been hard-deleted: demote the folder to a regular user
|
|
* folder by clearing `system_managed`, appending " (deleted)" to the
|
|
* name, and dropping the entity FK so the partial unique index no
|
|
* longer constrains it. Files still inside the folder retain their
|
|
* snapshotted entity FKs (orphaned — they appear in the root-view
|
|
* Files section once the rep cleans up).
|
|
*
|
|
* Idempotent: re-demoting an already-demoted folder is a no-op because
|
|
* the entityType + entityId FK is cleared on first demotion.
|
|
*/
|
|
export async function demoteSystemFolderOnEntityDelete(
|
|
portId: string,
|
|
entityType: EntityType,
|
|
entityId: string,
|
|
): Promise<void> {
|
|
if (!ENTITY_TYPES.has(entityType)) return;
|
|
const folder = await db.query.documentFolders.findFirst({
|
|
where: and(
|
|
eq(documentFolders.portId, portId),
|
|
eq(documentFolders.entityType, entityType),
|
|
eq(documentFolders.entityId, entityId),
|
|
),
|
|
});
|
|
if (!folder) return;
|
|
const stripped = folder.name.endsWith(ARCHIVED_SUFFIX)
|
|
? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
|
|
: folder.name;
|
|
const newName = stripped.endsWith(DELETED_SUFFIX) ? stripped : `${stripped}${DELETED_SUFFIX}`;
|
|
await db
|
|
.update(documentFolders)
|
|
.set({
|
|
name: newName,
|
|
systemManaged: false,
|
|
entityType: null,
|
|
entityId: null,
|
|
archivedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
|
|
}
|