feat(documents): entity-folder archive / restore / demote helpers

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>
This commit is contained in:
2026-05-11 11:34:02 +02:00
parent 3b34b41989
commit 4c5dc7ec17
5 changed files with 211 additions and 3 deletions

View File

@@ -24,7 +24,11 @@ import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
import { logger } from '@/lib/logger';
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
import {
syncEntityFolderName,
applyEntityArchivedSuffix,
applyEntityRestoredSuffix,
} from '@/lib/services/document-folders.service';
import type {
CreateClientInput,
UpdateClientInput,
@@ -553,6 +557,10 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
await softDelete(clients, clients.id, id);
void applyEntityArchivedSuffix(portId, 'client', id).catch((err) => {
logger.warn({ err, clientId: id }, 'Failed to apply archived suffix to client folder');
});
void createAuditLog({
userId: meta.userId,
portId,
@@ -581,6 +589,10 @@ export async function restoreClient(id: string, portId: string, meta: AuditMeta)
await restore(clients, clients.id, id);
void applyEntityRestoredSuffix(portId, 'client', id).catch((err) => {
logger.warn({ err, clientId: id }, 'Failed to clear archived suffix on client folder');
});
void createAuditLog({
userId: meta.userId,
portId,

View File

@@ -13,7 +13,10 @@ import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ConflictError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
import {
syncEntityFolderName,
applyEntityArchivedSuffix,
} from '@/lib/services/document-folders.service';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { diffEntity } from '@/lib/entity-diff';
@@ -212,6 +215,10 @@ export async function archiveCompany(id: string, portId: string, meta: AuditMeta
.set({ archivedAt: new Date() })
.where(and(eq(companies.id, id), eq(companies.portId, portId)));
void applyEntityArchivedSuffix(portId, 'company', id).catch((err) => {
logger.warn({ err, companyId: id }, 'Failed to apply archived suffix to company folder');
});
void createAuditLog({
userId: meta.userId,
portId,

View File

@@ -571,3 +571,107 @@ export async function syncEntityFolderName(
}
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)));
}

View File

@@ -6,7 +6,10 @@ import { companies } from '@/lib/db/schema/companies';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
import {
syncEntityFolderName,
applyEntityArchivedSuffix,
} from '@/lib/services/document-folders.service';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { diffEntity } from '@/lib/entity-diff';
@@ -190,6 +193,10 @@ export async function archiveYacht(id: string, portId: string, meta: AuditMeta)
.set({ archivedAt: new Date() })
.where(and(eq(yachts.id, id), eq(yachts.portId, portId)));
void applyEntityArchivedSuffix(portId, 'yacht', id).catch((err) => {
logger.warn({ err, yachtId: id }, 'Failed to apply archived suffix to yacht folder');
});
void createAuditLog({
userId: meta.userId,
portId,