diff --git a/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md b/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md index ff65ace6..d529e343 100644 --- a/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md +++ b/docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md @@ -428,10 +428,7 @@ type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number]; * can't race two inserts of the same root. Re-SELECTs on conflict so * the return shape is always populated. */ -export async function ensureSystemRoots( - portId: string, - userId: string, -): Promise { +export async function ensureSystemRoots(portId: string, userId: string): Promise { // Try to insert all three; collect existing ids on conflict. const values = SYSTEM_ROOT_NAMES.map((name) => ({ portId, @@ -443,13 +440,16 @@ export async function ensureSystemRoots( createdBy: userId, })); - await db.insert(documentFolders).values(values).onConflictDoNothing({ - target: [ - documentFolders.portId, - sql`COALESCE(${documentFolders.parentId}, '__root__')`, - sql`LOWER(${documentFolders.name})`, - ], - }); + await db + .insert(documentFolders) + .values(values) + .onConflictDoNothing({ + target: [ + documentFolders.portId, + sql`COALESCE(${documentFolders.parentId}, '__root__')`, + sql`LOWER(${documentFolders.name})`, + ], + }); // Re-SELECT — the rows that already existed are not in `.returning()` // when ON CONFLICT DO NOTHING is used. SELECT is the authoritative @@ -457,9 +457,7 @@ export async function ensureSystemRoots( const rows = await db .select() .from(documentFolders) - .where( - and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')), - ); + .where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root'))); // Preserve SYSTEM_ROOT_NAMES order for callers (stable test assertions // and a stable UI render order). @@ -879,8 +877,7 @@ async function assertNotSystemManaged( }); if (!folder) throw new NotFoundError('Folder'); if (folder.systemManaged) { - const verb = - action === 'rename' ? 'renamed' : action === 'move' ? 'moved' : 'deleted'; + const verb = action === 'rename' ? 'renamed' : action === 'move' ? 'moved' : 'deleted'; throw new ConflictError(`System folders can't be ${verb}`); } return folder; @@ -1009,10 +1006,7 @@ describe('document-folders service · syncEntityFolderName', () => { }); it('renames the entity subfolder when the entity is renamed', async () => { - await db - .update(clients) - .set({ firstName: 'Jonathan' }) - .where(eq(clients.id, clientId)); + await db.update(clients).set({ firstName: 'Jonathan' }).where(eq(clients.id, clientId)); await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID); const folder = await db.query.documentFolders.findFirst({ where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), @@ -1052,10 +1046,7 @@ describe('document-folders service · syncEntityFolderName', () => { await ensureEntityFolder(portId, 'client', collider!.id, TEST_USER_ID); // Rename John → Jane (collision with the other Smith, Jane). - await db - .update(clients) - .set({ firstName: 'Jane' }) - .where(eq(clients.id, clientId)); + await db.update(clients).set({ firstName: 'Jane' }).where(eq(clients.id, clientId)); await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID); const folder = await db.query.documentFolders.findFirst({ @@ -1115,9 +1106,7 @@ export async function syncEntityFolderName( for (let attempt = 0; attempt < 50; attempt += 1) { const candidate = - attempt === 0 - ? `${baseName}${targetSuffix}` - : `${baseName} (${attempt + 1})${targetSuffix}`; + attempt === 0 ? `${baseName}${targetSuffix}` : `${baseName} (${attempt + 1})${targetSuffix}`; if (candidate === folder.name) return; // No-op rename. try { const [updated] = await db @@ -1692,9 +1681,12 @@ interface ResolvedOwner { * linked interest's primary entity. Returns null when no owner is * resolvable (signed PDF will land at root). */ -async function resolveDocumentOwner( - doc: { clientId: string | null; companyId: string | null; yachtId: string | null; interestId: string | null }, -): Promise { +async function resolveDocumentOwner(doc: { + clientId: string | null; + companyId: string | null; + yachtId: string | null; + interestId: string | null; +}): Promise { if (doc.clientId) return { entityType: 'client', entityId: doc.clientId }; if (doc.companyId) return { entityType: 'company', entityId: doc.companyId }; if (doc.yachtId) return { entityType: 'yacht', entityId: doc.yachtId }; @@ -1853,24 +1845,40 @@ describe('files service · listFilesAggregatedByEntity', () => { await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); await ensureSystemRoots(portId, TEST_USER_ID); - const [c] = await db.insert(clients).values({ - portId, firstName: 'John', lastName: 'Smith', - email: `john-${crypto.randomUUID()}@example.com`, - }).returning(); + const [c] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); clientId = c!.id; - const [co] = await db.insert(companies).values({ - portId, name: 'Smith Marine LLC', - }).returning(); + const [co] = await db + .insert(companies) + .values({ + portId, + name: 'Smith Marine LLC', + }) + .returning(); companyId = co!.id; - const [y] = await db.insert(yachts).values({ - portId, name: 'MV Serenity', currentOwnerType: 'client', currentOwnerId: clientId, - }).returning(); + const [y] = await db + .insert(yachts) + .values({ + portId, + name: 'MV Serenity', + currentOwnerType: 'client', + currentOwnerId: clientId, + }) + .returning(); yachtId = y!.id; await db.insert(companyMemberships).values({ - companyId, clientId, + companyId, + clientId, }); clientFolderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id; @@ -1885,19 +1893,22 @@ describe('files service · listFilesAggregatedByEntity', () => { folderId: string; filename: string; }) { - const [row] = await db.insert(files).values({ - portId, - clientId: opts.clientId ?? null, - companyId: opts.companyId ?? null, - yachtId: opts.yachtId ?? null, - folderId: opts.folderId, - filename: opts.filename, - originalName: opts.filename, - mimeType: 'application/pdf', - storagePath: `test/${crypto.randomUUID()}`, - storageBucket: 'test', - uploadedBy: TEST_USER_ID, - }).returning(); + const [row] = await db + .insert(files) + .values({ + portId, + clientId: opts.clientId ?? null, + companyId: opts.companyId ?? null, + yachtId: opts.yachtId ?? null, + folderId: opts.folderId, + filename: opts.filename, + originalName: opts.filename, + mimeType: 'application/pdf', + storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', + uploadedBy: TEST_USER_ID, + }) + .returning(); return row!; } @@ -1934,14 +1945,24 @@ describe('files service · listFilesAggregatedByEntity', () => { it('snapshots are file-FK-based — yacht transfer does not move historical files', async () => { const file = await insertFile({ - yachtId, clientId, folderId: yachtFolderId, filename: 'Historic.pdf', + yachtId, + clientId, + folderId: yachtFolderId, + filename: 'Historic.pdf', }); // Transfer the yacht to a different owner. - const [mary] = await db.insert(clients).values({ - portId, firstName: 'Mary', lastName: 'Brown', - email: `mary-${crypto.randomUUID()}@example.com`, - }).returning(); - await db.update(yachts).set({ currentOwnerType: 'client', currentOwnerId: mary!.id }) + const [mary] = await db + .insert(clients) + .values({ + portId, + firstName: 'Mary', + lastName: 'Brown', + email: `mary-${crypto.randomUUID()}@example.com`, + }) + .returning(); + await db + .update(yachts) + .set({ currentOwnerType: 'client', currentOwnerId: mary!.id }) .where(eq(yachts.id, yachtId)); // John's view still shows the file (via DIRECTLY ATTACHED or FROM YACHT). @@ -1957,10 +1978,15 @@ describe('files service · listFilesAggregatedByEntity', () => { it('rejects cross-port leakage with defense-in-depth port filter', async () => { const otherPort = await setupTestPort(); - const [otherClient] = await db.insert(clients).values({ - portId: otherPort, firstName: 'Other', lastName: 'Port', - email: `other-${crypto.randomUUID()}@example.com`, - }).returning(); + const [otherClient] = await db + .insert(clients) + .values({ + portId: otherPort, + firstName: 'Other', + lastName: 'Port', + email: `other-${crypto.randomUUID()}@example.com`, + }) + .returning(); // Try to query the other port's client from our port — should return empty. const result = await listFilesAggregatedByEntity(portId, 'client', otherClient!.id); expect(result.groups.flatMap((g) => g.files)).toHaveLength(0); @@ -2041,9 +2067,11 @@ export async function listFilesAggregatedByEntity( // DIRECTLY ATTACHED — files whose own FK matches the requested entity. const directColumn = - entityType === 'client' ? files.clientId - : entityType === 'company' ? files.companyId - : files.yachtId; + entityType === 'client' + ? files.clientId + : entityType === 'company' + ? files.companyId + : files.yachtId; const direct = await fetchGroupRows(portId, eq(directColumn, entityId), GROUP_LIMIT); if (direct.rows.length > 0) { groups.push({ @@ -2169,7 +2197,10 @@ async function collectRelatedEntities( and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, 'company'), - inArray(yachts.currentOwnerId, memberCompanies.map((c) => c.id)), + inArray( + yachts.currentOwnerId, + memberCompanies.map((c) => c.id), + ), ), ); } @@ -2228,10 +2259,7 @@ async function collectRelatedEntities( ? [ { id: owner.id, - name: `${owner.lastName ?? ''}, ${owner.firstName ?? ''}`.replace( - /^,\s*|,\s*$/, - '', - ), + name: `${owner.lastName ?? ''}, ${owner.firstName ?? ''}`.replace(/^,\s*|,\s*$/, ''), }, ] : [], @@ -2310,10 +2338,7 @@ Append to `src/lib/services/documents.service.ts` — reuses the same `collectRe Then append to `src/lib/services/documents.service.ts`: ```typescript -import { - collectRelatedEntities, - type AggregatedFileGroup, -} from '@/lib/services/files'; +import { collectRelatedEntities, type AggregatedFileGroup } from '@/lib/services/files'; export interface AggregatedWorkflowGroup { label: string; @@ -2339,9 +2364,11 @@ export async function listInflightWorkflowsAggregatedByEntity( const groups: AggregatedWorkflowGroup[] = []; const directColumn = - entityType === 'client' ? documents.clientId - : entityType === 'company' ? documents.companyId - : documents.yachtId; + entityType === 'client' + ? documents.clientId + : entityType === 'company' + ? documents.companyId + : documents.yachtId; const direct = await fetchWorkflowGroupRows(portId, eq(directColumn, entityId)); if (direct.rows.length > 0) { @@ -2432,22 +2459,35 @@ describe('documents service · listInflightWorkflowsAggregatedByEntity', () => { portId = await setupTestPort(); await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); await ensureSystemRoots(portId, TEST_USER_ID); - const [c] = await db.insert(clients).values({ - portId, firstName: 'John', lastName: 'Smith', - email: `john-${crypto.randomUUID()}@example.com`, - }).returning(); + const [c] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); clientId = c!.id; }); it('returns in-flight workflows in DIRECTLY ATTACHED group', async () => { const { documents: documentsTable } = await import('@/lib/db/schema/documents'); await db.insert(documentsTable).values({ - portId, clientId, title: 'EOI', documentType: 'eoi', - status: 'sent', createdBy: TEST_USER_ID, + portId, + clientId, + title: 'EOI', + documentType: 'eoi', + status: 'sent', + createdBy: TEST_USER_ID, }); await db.insert(documentsTable).values({ - portId, clientId, title: 'Old signed EOI', documentType: 'eoi', - status: 'completed', createdBy: TEST_USER_ID, + portId, + clientId, + title: 'Old signed EOI', + documentType: 'eoi', + status: 'completed', + createdBy: TEST_USER_ID, }); const result = await listInflightWorkflowsAggregatedByEntity(portId, 'client', clientId); const direct = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED'); @@ -2476,18 +2516,17 @@ Append to `src/lib/services/files.ts`: * Returns the mutated insert payload so callers can keep their * single-insert flow. */ -export async function applyEntityFkFromFolder(portId: string, payload: T): Promise { +export async function applyEntityFkFromFolder< + T extends { + clientId?: string | null; + companyId?: string | null; + yachtId?: string | null; + folderId?: string | null; + }, +>(portId: string, payload: T): Promise { if (!payload.folderId) return payload; const folder = await db.query.documentFolders.findFirst({ - where: and( - eq(documentFolders.id, payload.folderId), - eq(documentFolders.portId, portId), - ), + where: and(eq(documentFolders.id, payload.folderId), eq(documentFolders.portId, portId)), columns: { systemManaged: true, entityType: true, entityId: true }, }); if (!folder || !folder.systemManaged || !folder.entityType || !folder.entityId) { @@ -2521,10 +2560,15 @@ describe('files service · applyEntityFkFromFolder', () => { beforeEach(async () => { portId = await setupTestPort(); await ensureSystemRoots(portId, TEST_USER_ID); - const [c] = await db.insert(clients).values({ - portId, firstName: 'A', lastName: 'B', - email: `a-${crypto.randomUUID()}@example.com`, - }).returning(); + const [c] = await db + .insert(clients) + .values({ + portId, + firstName: 'A', + lastName: 'B', + email: `a-${crypto.randomUUID()}@example.com`, + }) + .returning(); clientId = c!.id; folderId = (await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID)).id; }); @@ -2540,9 +2584,15 @@ describe('files service · applyEntityFkFromFolder', () => { }); it('is a no-op for non-system folders', async () => { - const [user] = await db.insert(documentFolders).values({ - portId, parentId: null, name: 'My templates', createdBy: TEST_USER_ID, - }).returning(); + const [user] = await db + .insert(documentFolders) + .values({ + portId, + parentId: null, + name: 'My templates', + createdBy: TEST_USER_ID, + }) + .returning(); const out = await applyEntityFkFromFolder(portId, { folderId: user!.id, clientId: null }); expect(out.clientId).toBeUndefined(); }); @@ -2767,20 +2817,41 @@ import { describe, it, expect, beforeEach } from 'vitest'; it('hides completed workflows when folderId is set', async () => { const portId = await setupTestPort(); await ensureSystemRoots(portId, TEST_USER_ID); - const [folder] = await db.insert(documentFolders).values({ - portId, parentId: null, name: 'Deals 2026', createdBy: TEST_USER_ID, - }).returning(); + const [folder] = await db + .insert(documentFolders) + .values({ + portId, + parentId: null, + name: 'Deals 2026', + createdBy: TEST_USER_ID, + }) + .returning(); await db.insert(documents).values([ { - portId, folderId: folder!.id, title: 'In flight', - documentType: 'eoi', status: 'sent', createdBy: TEST_USER_ID, + portId, + folderId: folder!.id, + title: 'In flight', + documentType: 'eoi', + status: 'sent', + createdBy: TEST_USER_ID, }, { - portId, folderId: folder!.id, title: 'Done', - documentType: 'eoi', status: 'completed', createdBy: TEST_USER_ID, + portId, + folderId: folder!.id, + title: 'Done', + documentType: 'eoi', + status: 'completed', + createdBy: TEST_USER_ID, }, ]); - const result = await listDocuments(portId, { folderId: folder!.id, page: 1, limit: 50, sort: 'createdAt', order: 'desc', tab: 'all' }); + const result = await listDocuments(portId, { + folderId: folder!.id, + page: 1, + limit: 50, + sort: 'createdAt', + order: 'desc', + tab: 'all', + }); expect(result.data.map((d) => d.title)).toEqual(['In flight']); }); ``` @@ -2859,51 +2930,79 @@ describe('backfill-document-folders · idempotency + isolation', () => { beforeEach(async () => { portId = await setupTestPort(); await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); - const [c] = await db.insert(clients).values({ - portId, firstName: 'John', lastName: 'Smith', - email: `john-${crypto.randomUUID()}@example.com`, - }).returning(); + const [c] = await db + .insert(clients) + .values({ + portId, + firstName: 'John', + lastName: 'Smith', + email: `john-${crypto.randomUUID()}@example.com`, + }) + .returning(); clientId = c!.id; }); it('creates system roots and entity subfolders for entities with attached files', async () => { await db.insert(files).values({ - portId, clientId, filename: 'a.pdf', originalName: 'a.pdf', - storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', + portId, + clientId, + filename: 'a.pdf', + originalName: 'a.pdf', + storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', uploadedBy: TEST_USER_ID, }); await runBackfill({ portId }); - const roots = await db.select().from(documentFolders).where( - and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')), - ); + const roots = await db + .select() + .from(documentFolders) + .where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root'))); expect(roots.map((r) => r.name).sort()).toEqual(['Clients', 'Companies', 'Yachts']); - const sub = await db.select().from(documentFolders).where( - and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), - ); + const sub = await db + .select() + .from(documentFolders) + .where(and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId))); expect(sub).toHaveLength(1); }); it('sets files.folder_id from entity FKs', async () => { - const [file] = await db.insert(files).values({ - portId, clientId, filename: 'a.pdf', originalName: 'a.pdf', - storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', - uploadedBy: TEST_USER_ID, - }).returning(); + const [file] = await db + .insert(files) + .values({ + portId, + clientId, + filename: 'a.pdf', + originalName: 'a.pdf', + storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', + uploadedBy: TEST_USER_ID, + }) + .returning(); await runBackfill({ portId }); const updated = await db.query.files.findFirst({ where: eq(files.id, file!.id) }); expect(updated?.folderId).not.toBeNull(); }); it('copies entity FKs from completed workflows onto signed files', async () => { - const [signedFile] = await db.insert(files).values({ - portId, // NOTE: no clientId — legacy completion left it blank - filename: 'signed.pdf', originalName: 'signed.pdf', - storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', - uploadedBy: 'system', - }).returning(); + const [signedFile] = await db + .insert(files) + .values({ + portId, // NOTE: no clientId — legacy completion left it blank + filename: 'signed.pdf', + originalName: 'signed.pdf', + storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', + uploadedBy: 'system', + }) + .returning(); await db.insert(documents).values({ - portId, clientId, signedFileId: signedFile!.id, title: 'EOI', - documentType: 'eoi', status: 'completed', createdBy: TEST_USER_ID, + portId, + clientId, + signedFileId: signedFile!.id, + title: 'EOI', + documentType: 'eoi', + status: 'completed', + createdBy: TEST_USER_ID, }); await runBackfill({ portId }); const updated = await db.query.files.findFirst({ where: eq(files.id, signedFile!.id) }); @@ -2913,30 +3012,52 @@ describe('backfill-document-folders · idempotency + isolation', () => { it('is idempotent — second run produces the same result', async () => { await db.insert(files).values({ - portId, clientId, filename: 'a.pdf', originalName: 'a.pdf', - storagePath: `test/${crypto.randomUUID()}`, storageBucket: 'test', + portId, + clientId, + filename: 'a.pdf', + originalName: 'a.pdf', + storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', uploadedBy: TEST_USER_ID, }); await runBackfill({ portId }); - const after1 = await db.select().from(documentFolders).where(eq(documentFolders.portId, portId)); + const after1 = await db + .select() + .from(documentFolders) + .where(eq(documentFolders.portId, portId)); await runBackfill({ portId }); - const after2 = await db.select().from(documentFolders).where(eq(documentFolders.portId, portId)); + const after2 = await db + .select() + .from(documentFolders) + .where(eq(documentFolders.portId, portId)); expect(after2).toHaveLength(after1.length); }); it('respects port isolation — does not touch other ports', async () => { const otherPort = await setupTestPort(); - const [otherClient] = await db.insert(clients).values({ - portId: otherPort, firstName: 'Other', lastName: 'Port', - email: `other-${crypto.randomUUID()}@example.com`, - }).returning(); + const [otherClient] = await db + .insert(clients) + .values({ + portId: otherPort, + firstName: 'Other', + lastName: 'Port', + email: `other-${crypto.randomUUID()}@example.com`, + }) + .returning(); await db.insert(files).values({ - portId: otherPort, clientId: otherClient!.id, filename: 'b.pdf', - originalName: 'b.pdf', storagePath: `test/${crypto.randomUUID()}`, - storageBucket: 'test', uploadedBy: TEST_USER_ID, + portId: otherPort, + clientId: otherClient!.id, + filename: 'b.pdf', + originalName: 'b.pdf', + storagePath: `test/${crypto.randomUUID()}`, + storageBucket: 'test', + uploadedBy: TEST_USER_ID, }); await runBackfill({ portId }); // only this port - const otherRoots = await db.select().from(documentFolders).where(eq(documentFolders.portId, otherPort)); + const otherRoots = await db + .select() + .from(documentFolders) + .where(eq(documentFolders.portId, otherPort)); expect(otherRoots).toHaveLength(0); }); }); @@ -3009,11 +3130,13 @@ export async function runBackfill(opts: BackfillOptions = {}): Promise { ); for (const d of completedDocs) { if (!d.signedFileId) continue; - const owner = - d.clientId ? ({ type: 'client', id: d.clientId } as const) - : d.companyId ? ({ type: 'company', id: d.companyId } as const) - : d.yachtId ? ({ type: 'yacht', id: d.yachtId } as const) - : null; + const owner = d.clientId + ? ({ type: 'client', id: d.clientId } as const) + : d.companyId + ? ({ type: 'company', id: d.companyId } as const) + : d.yachtId + ? ({ type: 'yacht', id: d.yachtId } as const) + : null; if (!owner) continue; await tx .update(files) @@ -3030,8 +3153,8 @@ export async function runBackfill(opts: BackfillOptions = {}): Promise { owner.type === 'client' ? files.clientId : owner.type === 'company' - ? files.companyId - : files.yachtId, + ? files.companyId + : files.yachtId, ), ), ); @@ -3043,11 +3166,13 @@ export async function runBackfill(opts: BackfillOptions = {}): Promise { .from(files) .where(and(eq(files.portId, portId), isNull(files.folderId))); for (const f of fileRows) { - const owner: { type: EntityType; id: string } | null = - f.clientId ? { type: 'client', id: f.clientId } - : f.companyId ? { type: 'company', id: f.companyId } - : f.yachtId ? { type: 'yacht', id: f.yachtId } - : null; + const owner: { type: EntityType; id: string } | null = f.clientId + ? { type: 'client', id: f.clientId } + : f.companyId + ? { type: 'company', id: f.companyId } + : f.yachtId + ? { type: 'yacht', id: f.yachtId } + : null; if (!owner) continue; try { const folder = await ensureEntityFolder(portId, owner.type, owner.id, systemUser); @@ -3172,8 +3297,7 @@ export function useAggregatedFiles( ) { return useQuery<{ data: { groups: AggregatedGroup[] } }>({ queryKey: ['files', 'aggregated', entityType, entityId], - queryFn: () => - apiFetch(`/api/v1/files?entityType=${entityType}&entityId=${entityId}`), + queryFn: () => apiFetch(`/api/v1/files?entityType=${entityType}&entityId=${entityId}`), enabled: Boolean(entityType && entityId), staleTime: 10_000, }); @@ -3186,8 +3310,7 @@ export function useAggregatedWorkflows( ) { return useQuery<{ data: { groups: AggregatedGroup[] } }>({ queryKey: ['documents', 'aggregated', entityType, entityId], - queryFn: () => - apiFetch(`/api/v1/documents?entityType=${entityType}&entityId=${entityId}`), + queryFn: () => apiFetch(`/api/v1/documents?entityType=${entityType}&entityId=${entityId}`), enabled: Boolean(entityType && entityId), staleTime: 10_000, }); @@ -3434,7 +3557,10 @@ export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props)
    {data.data.signers.map((s) => ( -
  • +
  • {s.signerName} {s.signerEmail} @@ -3536,8 +3662,10 @@ import { Lock } from 'lucide-react'; > {/* existing chevron + folder icon */} {node.name} - {node.systemManaged ? : null} - + {node.systemManaged ? ( + + ) : null} +; ``` Read the actual existing template first — the file is ~160 lines, copy its current row JSX and patch in the lock icon + archived muted class. Do not invent props or rewrite the structure. @@ -3548,15 +3676,13 @@ The actions menu shows Create / Rename / Move / Delete buttons or menu items key ```tsx // Find the selected folder in the tree -const selected = selectedFolderId - ? findInTree(tree, selectedFolderId) - : null; +const selected = selectedFolderId ? findInTree(tree, selectedFolderId) : null; const isSystem = selected?.systemManaged ?? false; // Disable Rename + Move + Delete buttons (or hide them) when isSystem: +; // + a tooltip explaining "System folders can't be renamed/moved/deleted" ``` @@ -3576,18 +3702,20 @@ function findInTree(tree: FolderNode[], id: string): FolderNode | null { Wrap each disabled button in a `` (from `@/components/ui/tooltip`) explaining why when `isSystem`: ```tsx -{isSystem ? ( - - - - - - - System folders can't be renamed. - -) : ( - -)} +{ + isSystem ? ( + + + + + + + System folders can't be renamed. + + ) : ( + + ); +} ``` - [ ] **Step 4: Verify type + visual sanity in dev** @@ -3742,10 +3870,7 @@ import { ClipboardSignature, FileText, Eye } from 'lucide-react'; import { AggregatedSection } from './aggregated-section'; import { SigningDetailsDialog } from './signing-details-dialog'; -import { - useAggregatedFiles, - useAggregatedWorkflows, -} from '@/hooks/use-aggregated-listing'; +import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing'; import { StatusPill } from '@/components/ui/status-pill'; interface Props { @@ -3879,7 +4004,9 @@ return ( {selectedFolderId === undefined ? ( - ) : selectedFolder?.entityType && selectedFolder.entityType !== 'root' && selectedFolder.entityId ? ( + ) : selectedFolder?.entityType && + selectedFolder.entityType !== 'root' && + selectedFolder.entityId ? ( { - // eslint-disable-next-line no-console console.log('Backfill complete'); process.exit(0); }) diff --git a/src/app/(portal)/portal/documents/page.tsx b/src/app/(portal)/portal/documents/page.tsx index 6f6f37a2..8aa899db 100644 --- a/src/app/(portal)/portal/documents/page.tsx +++ b/src/app/(portal)/portal/documents/page.tsx @@ -36,26 +36,19 @@ export default async function PortalDocumentsPage() {

    Documents

    -

    - Your contracts, EOIs, and signed agreements -

    +

    Your contracts, EOIs, and signed agreements

    {documents.length === 0 ? (

    No documents on file

    -

    - Documents shared with you will appear here. -

    +

    Documents shared with you will appear here.

    ) : (
    {documents.map((doc) => ( -
    +
    @@ -89,7 +82,11 @@ export default async function PortalDocumentsPage() { : 'text-gray-500' } > - {signer.status === 'signed' ? '✓' : signer.status === 'declined' ? '✗' : '○'} + {signer.status === 'signed' + ? '✓' + : signer.status === 'declined' + ? '✗' + : '○'} {signer.signerName} diff --git a/src/app/api/v1/clients/[id]/relationships/route.ts b/src/app/api/v1/clients/[id]/relationships/route.ts index 06df6222..1a4e190a 100644 --- a/src/app/api/v1/clients/[id]/relationships/route.ts +++ b/src/app/api/v1/clients/[id]/relationships/route.ts @@ -8,13 +8,7 @@ import { listRelationships, createRelationship } from '@/lib/services/clients.se const createRelationshipSchema = z.object({ clientBId: z.string().min(1), - relationshipType: z.enum([ - 'referred_by', - 'broker_for', - 'family_member', - 'same_vessel', - 'custom', - ]), + relationshipType: z.enum(['referred_by', 'broker_for', 'family_member', 'same_vessel', 'custom']), description: z.string().optional(), }); diff --git a/src/app/api/v1/clients/[id]/route.ts b/src/app/api/v1/clients/[id]/route.ts index ca500a9e..7d80ec7b 100644 --- a/src/app/api/v1/clients/[id]/route.ts +++ b/src/app/api/v1/clients/[id]/route.ts @@ -3,11 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; -import { - getClientById, - updateClient, - archiveClient, -} from '@/lib/services/clients.service'; +import { getClientById, updateClient, archiveClient } from '@/lib/services/clients.service'; import { updateClientSchema } from '@/lib/validators/clients'; export const GET = withAuth( diff --git a/src/app/api/v1/document-templates/[id]/route.ts b/src/app/api/v1/document-templates/[id]/route.ts index 2cc4c5ba..7475a605 100644 --- a/src/app/api/v1/document-templates/[id]/route.ts +++ b/src/app/api/v1/document-templates/[id]/route.ts @@ -3,11 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; -import { - getTemplateById, - updateTemplate, - deleteTemplate, -} from '@/lib/services/document-templates'; +import { getTemplateById, updateTemplate, deleteTemplate } from '@/lib/services/document-templates'; import { updateTemplateSchema } from '@/lib/validators/document-templates'; export const GET = withAuth( diff --git a/src/app/api/v1/document-templates/route.ts b/src/app/api/v1/document-templates/route.ts index f42c4614..a93328c6 100644 --- a/src/app/api/v1/document-templates/route.ts +++ b/src/app/api/v1/document-templates/route.ts @@ -3,14 +3,8 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseQuery, parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; -import { - listTemplates, - createTemplate, -} from '@/lib/services/document-templates'; -import { - listTemplatesSchema, - createTemplateSchema, -} from '@/lib/validators/document-templates'; +import { listTemplates, createTemplate } from '@/lib/services/document-templates'; +import { listTemplatesSchema, createTemplateSchema } from '@/lib/validators/document-templates'; export const GET = withAuth( withPermission('documents', 'view', async (req, ctx) => { diff --git a/src/app/api/v1/expenses/[id]/route.ts b/src/app/api/v1/expenses/[id]/route.ts index 5c424a64..b15c9762 100644 --- a/src/app/api/v1/expenses/[id]/route.ts +++ b/src/app/api/v1/expenses/[id]/route.ts @@ -3,11 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; -import { - getExpenseById, - updateExpense, - archiveExpense, -} from '@/lib/services/expenses'; +import { getExpenseById, updateExpense, archiveExpense } from '@/lib/services/expenses'; import { updateExpenseSchema } from '@/lib/validators/expenses'; export const GET = withAuth( diff --git a/src/components/admin/document-templates/template-version-history.tsx b/src/components/admin/document-templates/template-version-history.tsx index 9a29f752..edf55375 100644 --- a/src/components/admin/document-templates/template-version-history.tsx +++ b/src/components/admin/document-templates/template-version-history.tsx @@ -50,7 +50,12 @@ export function TemplateVersionHistory({ }, [fetchVersions]); async function handleRollback(version: number) { - if (!confirm(`Roll back to version ${version}? This will create a new version ${currentVersion + 1}.`)) return; + if ( + !confirm( + `Roll back to version ${version}? This will create a new version ${currentVersion + 1}.`, + ) + ) + return; setRollingBack(version); setError(null); @@ -70,9 +75,7 @@ export function TemplateVersionHistory({ if (loading) { return ( -
    - Loading version history… -
    +
    Loading version history…
    ); } @@ -81,8 +84,7 @@ export function TemplateVersionHistory({

    - No previous versions found. Versions are saved whenever you update the - template content. + No previous versions found. Versions are saved whenever you update the template content.

    ); @@ -91,28 +93,21 @@ export function TemplateVersionHistory({ return (
    {error && ( -

    - {error} -

    +

    {error}

    )}

    - Current version: v{currentVersion}. Click Restore to - roll back to a previous version (creates a new version). + Current version: v{currentVersion}. Click Restore to roll back to a + previous version (creates a new version).

    {versions.map((v) => ( -
    +
    v{v.version} - - Version {v.version} - + Version {v.version}

    Saved{' '} diff --git a/src/components/admin/queue-overview.tsx b/src/components/admin/queue-overview.tsx index 1c173a62..532576f3 100644 --- a/src/components/admin/queue-overview.tsx +++ b/src/components/admin/queue-overview.tsx @@ -53,7 +53,9 @@ export function QueueOverview({ queues }: QueueOverviewProps) { onKeyDown={(e) => e.key === 'Enter' && handleQueueClick(queue.name)} > - {queue.name} + + {queue.name} +

    diff --git a/src/components/admin/tags/tag-list.tsx b/src/components/admin/tags/tag-list.tsx index 78c98e84..25968ce8 100644 --- a/src/components/admin/tags/tag-list.tsx +++ b/src/components/admin/tags/tag-list.tsx @@ -90,29 +90,20 @@ export function TagList() { { accessorKey: 'createdAt', header: 'Created', - cell: ({ row }) => - new Date(row.original.createdAt).toLocaleDateString(), + cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(), }, { id: 'actions', header: '', cell: ({ row }) => (
    - + @@ -158,12 +149,7 @@ export function TagList() { } /> - +
    ); } diff --git a/src/components/admin/webhooks/webhook-event-selector.tsx b/src/components/admin/webhooks/webhook-event-selector.tsx index dcc3ddbd..f775cc03 100644 --- a/src/components/admin/webhooks/webhook-event-selector.tsx +++ b/src/components/admin/webhooks/webhook-event-selector.tsx @@ -93,10 +93,7 @@ export function WebhookEventSelector({ selected, onChange }: WebhookEventSelecto checked={selected.includes(event)} onCheckedChange={() => toggle(event)} /> -
    diff --git a/src/components/admin/webhooks/webhook-secret-display.tsx b/src/components/admin/webhooks/webhook-secret-display.tsx index 3ef37326..4500c1ee 100644 --- a/src/components/admin/webhooks/webhook-secret-display.tsx +++ b/src/components/admin/webhooks/webhook-secret-display.tsx @@ -28,11 +28,7 @@ export function WebhookSecretDisplay({ plaintext, masked }: WebhookSecretDisplay Copy this secret now. It will not be shown again.
    - + @@ -43,12 +39,10 @@ export function WebhookSecretDisplay({ plaintext, masked }: WebhookSecretDisplay return (
    - - Use "Regenerate" to get a new secret + + + Use "Regenerate" to get a new secret +
    ); } diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index 85cbe49f..ff148ffd 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -135,7 +135,11 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) { entityId={selectedFolder!.entityId!} /> ) : ( - + )}
    diff --git a/src/components/documents/folder-actions-menu.tsx b/src/components/documents/folder-actions-menu.tsx index d9f37633..2eb0b7dd 100644 --- a/src/components/documents/folder-actions-menu.tsx +++ b/src/components/documents/folder-actions-menu.tsx @@ -21,12 +21,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toastError } from '@/lib/api/toast-error'; import { useCreateFolder, diff --git a/src/components/documents/folder-tree-sidebar.tsx b/src/components/documents/folder-tree-sidebar.tsx index 327a623c..4fad6488 100644 --- a/src/components/documents/folder-tree-sidebar.tsx +++ b/src/components/documents/folder-tree-sidebar.tsx @@ -147,7 +147,10 @@ function FolderRow({ )} {node.name} {node.systemManaged ? ( - + ) : null}
    diff --git a/src/components/documents/signing-progress.tsx b/src/components/documents/signing-progress.tsx index 48a3d7d5..4e9507b8 100644 --- a/src/components/documents/signing-progress.tsx +++ b/src/components/documents/signing-progress.tsx @@ -83,9 +83,7 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) { )}
    - {idx < sorted.length - 1 && ( -
    - )} + {idx < sorted.length - 1 &&
    }
    ))}
    diff --git a/src/components/email/email-draft-button.tsx b/src/components/email/email-draft-button.tsx index 68d1031f..d94bba78 100644 --- a/src/components/email/email-draft-button.tsx +++ b/src/components/email/email-draft-button.tsx @@ -98,12 +98,7 @@ export function EmailDraftButton({ return ( <> - - {error && ( -

    {error}

    - )} + {error &&

    {error}

    } Generated Email Draft - - Review and edit the draft before sending. - + Review and edit the draft before sending. {draft && ( diff --git a/src/components/files/file-grid.tsx b/src/components/files/file-grid.tsx index 814cb00c..217665b3 100644 --- a/src/components/files/file-grid.tsx +++ b/src/components/files/file-grid.tsx @@ -1,6 +1,16 @@ 'use client'; -import { Download, Eye, FileText, Film, Image, MoreHorizontal, Pencil, Sheet, Trash2 } from 'lucide-react'; +import { + Download, + Eye, + FileText, + Film, + Image, + MoreHorizontal, + Pencil, + Sheet, + Trash2, +} from 'lucide-react'; import { format } from 'date-fns'; import { Button } from '@/components/ui/button'; diff --git a/src/components/files/file-preview-dialog.tsx b/src/components/files/file-preview-dialog.tsx index 60eab595..d5d306ce 100644 --- a/src/components/files/file-preview-dialog.tsx +++ b/src/components/files/file-preview-dialog.tsx @@ -3,12 +3,7 @@ import { useEffect, useState } from 'react'; import { ExternalLink } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { apiFetch } from '@/lib/api/client'; interface FilePreviewDialogProps { @@ -99,11 +94,7 @@ export function FilePreviewDialog({ )} {!loading && !error && previewUrl && isPdf && ( -