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:
@@ -24,7 +24,11 @@ import { buildListQuery } from '@/lib/db/query-builder';
|
|||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
||||||
import { logger } from '@/lib/logger';
|
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 {
|
import type {
|
||||||
CreateClientInput,
|
CreateClientInput,
|
||||||
UpdateClientInput,
|
UpdateClientInput,
|
||||||
@@ -553,6 +557,10 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
|||||||
|
|
||||||
await softDelete(clients, clients.id, id);
|
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({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
portId,
|
portId,
|
||||||
@@ -581,6 +589,10 @@ export async function restoreClient(id: string, portId: string, meta: AuditMeta)
|
|||||||
|
|
||||||
await restore(clients, clients.id, id);
|
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({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
portId,
|
portId,
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import { buildListQuery } from '@/lib/db/query-builder';
|
|||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { NotFoundError, ConflictError } from '@/lib/errors';
|
import { NotFoundError, ConflictError } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
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 { emitToRoom } from '@/lib/socket/server';
|
||||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -212,6 +215,10 @@ export async function archiveCompany(id: string, portId: string, meta: AuditMeta
|
|||||||
.set({ archivedAt: new Date() })
|
.set({ archivedAt: new Date() })
|
||||||
.where(and(eq(companies.id, id), eq(companies.portId, portId)));
|
.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({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
portId,
|
portId,
|
||||||
|
|||||||
@@ -571,3 +571,107 @@ export async function syncEntityFolderName(
|
|||||||
}
|
}
|
||||||
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
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)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { companies } from '@/lib/db/schema/companies';
|
|||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
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 { emitToRoom } from '@/lib/socket/server';
|
||||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -190,6 +193,10 @@ export async function archiveYacht(id: string, portId: string, meta: AuditMeta)
|
|||||||
.set({ archivedAt: new Date() })
|
.set({ archivedAt: new Date() })
|
||||||
.where(and(eq(yachts.id, id), eq(yachts.portId, portId)));
|
.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({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
portId,
|
portId,
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ import {
|
|||||||
moveFolder,
|
moveFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
syncEntityFolderName,
|
syncEntityFolderName,
|
||||||
|
applyEntityArchivedSuffix,
|
||||||
|
applyEntityRestoredSuffix,
|
||||||
|
demoteSystemFolderOnEntityDelete,
|
||||||
} from '@/lib/services/document-folders.service';
|
} from '@/lib/services/document-folders.service';
|
||||||
|
|
||||||
describe('document-folders service · system folder protection', () => {
|
describe('document-folders service · system folder protection', () => {
|
||||||
@@ -252,3 +255,78 @@ describe('document-folders service · syncEntityFolderName', () => {
|
|||||||
expect(folder?.name).toBe(`${sharedName} (2)`);
|
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