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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user