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

@@ -139,6 +139,9 @@ import {
moveFolder,
renameFolder,
syncEntityFolderName,
applyEntityArchivedSuffix,
applyEntityRestoredSuffix,
demoteSystemFolderOnEntityDelete,
} from '@/lib/services/document-folders.service';
describe('document-folders service · system folder protection', () => {
@@ -252,3 +255,78 @@ describe('document-folders service · syncEntityFolderName', () => {
expect(folder?.name).toBe(`${sharedName} (2)`);
});
});
describe('document-folders service · archive lifecycle', () => {
let portId: string;
let clientId: string;
let clientName: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
await ensureSystemRoots(portId, TEST_USER_ID);
clientName = `John Smith ${crypto.randomUUID().slice(0, 6)}`;
const [client] = await db
.insert(clients)
.values({ portId, fullName: clientName })
.returning();
clientId = client!.id;
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
});
it('appends (archived) suffix and stamps archived_at on archive', async () => {
await applyEntityArchivedSuffix(portId, 'client', clientId);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe(`${clientName} (archived)`);
expect(folder?.archivedAt).toBeInstanceOf(Date);
expect(folder?.systemManaged).toBe(true);
});
it('is idempotent on archive — second call does not double-append', async () => {
await applyEntityArchivedSuffix(portId, 'client', clientId);
await applyEntityArchivedSuffix(portId, 'client', clientId);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe(`${clientName} (archived)`);
});
it('removes (archived) suffix and clears archived_at on restore', async () => {
await applyEntityArchivedSuffix(portId, 'client', clientId);
await applyEntityRestoredSuffix(portId, 'client', clientId);
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
});
expect(folder?.name).toBe(clientName);
expect(folder?.archivedAt).toBeNull();
});
it('appends (deleted) and flips system_managed=false on hard-delete', async () => {
await demoteSystemFolderOnEntityDelete(portId, 'client', clientId);
// After demotion, entityType + entityId are cleared, so we look up by name.
const folder = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.name, `${clientName} (deleted)`),
),
});
expect(folder?.systemManaged).toBe(false);
expect(folder?.entityType).toBeNull();
expect(folder?.entityId).toBeNull();
});
it('is a no-op when the folder does not exist', async () => {
const otherPort = await makePort();
await ensureSystemRoots(otherPort.id, TEST_USER_ID);
const [other] = await db
.insert(clients)
.values({ portId: otherPort.id, fullName: `Lone Wolf ${crypto.randomUUID().slice(0, 6)}` })
.returning();
await expect(
applyEntityArchivedSuffix(otherPort.id, 'client', other!.id),
).resolves.toBeUndefined();
});
});