diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index f99627a4..8686cd46 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -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, diff --git a/src/lib/services/companies.service.ts b/src/lib/services/companies.service.ts index 6bf69230..ab04ab1c 100644 --- a/src/lib/services/companies.service.ts +++ b/src/lib/services/companies.service.ts @@ -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, diff --git a/src/lib/services/document-folders.service.ts b/src/lib/services/document-folders.service.ts index 1988b882..f23917bb 100644 --- a/src/lib/services/document-folders.service.ts +++ b/src/lib/services/document-folders.service.ts @@ -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 { + 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 { + 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 { + 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))); +} diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index c6cd2f0b..3551eb24 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -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, diff --git a/tests/unit/document-folders-system-folders.test.ts b/tests/unit/document-folders-system-folders.test.ts index 22299ada..a146a880 100644 --- a/tests/unit/document-folders-system-folders.test.ts +++ b/tests/unit/document-folders-system-folders.test.ts @@ -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(); + }); +});