chore: prettier format pass on branch files
Auto-format all files modified during the documents-hub-split feature branch that were not yet aligned with the project's Prettier config (single quotes, semicolons, trailing commas). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<DocumentFolder[]> {
|
||||
export async function ensureSystemRoots(portId: string, userId: string): Promise<DocumentFolder[]> {
|
||||
// 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<ResolvedOwner | null> {
|
||||
async function resolveDocumentOwner(doc: {
|
||||
clientId: string | null;
|
||||
companyId: string | null;
|
||||
yachtId: string | null;
|
||||
interestId: string | null;
|
||||
}): Promise<ResolvedOwner | null> {
|
||||
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<T extends {
|
||||
clientId?: string | null;
|
||||
companyId?: string | null;
|
||||
yachtId?: string | null;
|
||||
folderId?: string | null;
|
||||
}>(portId: string, payload: T): Promise<T> {
|
||||
export async function applyEntityFkFromFolder<
|
||||
T extends {
|
||||
clientId?: string | null;
|
||||
companyId?: string | null;
|
||||
yachtId?: string | null;
|
||||
folderId?: string | null;
|
||||
},
|
||||
>(portId: string, payload: T): Promise<T> {
|
||||
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<void> {
|
||||
);
|
||||
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<void> {
|
||||
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<void> {
|
||||
.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<AggregatedFile>[] } }>({
|
||||
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<AggregatedWorkflow>[] } }>({
|
||||
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)
|
||||
</h5>
|
||||
<ul className="divide-y rounded border bg-muted/30">
|
||||
{data.data.signers.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between gap-2 px-3 py-2 text-xs">
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 text-xs"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{s.signerName}</span>
|
||||
<span className="ml-2 text-muted-foreground">{s.signerEmail}</span>
|
||||
@@ -3536,8 +3662,10 @@ import { Lock } from 'lucide-react';
|
||||
>
|
||||
{/* existing chevron + folder icon */}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.systemManaged ? <Lock className="ml-1 h-3 w-3 text-muted-foreground" aria-label="System folder" /> : null}
|
||||
</button>
|
||||
{node.systemManaged ? (
|
||||
<Lock className="ml-1 h-3 w-3 text-muted-foreground" aria-label="System folder" />
|
||||
) : null}
|
||||
</button>;
|
||||
```
|
||||
|
||||
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:
|
||||
<Button disabled={isSystem || pending} onClick={handleRename}>
|
||||
Rename
|
||||
</Button>
|
||||
</Button>;
|
||||
// + 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 `<Tooltip>` (from `@/components/ui/tooltip`) explaining why when `isSystem`:
|
||||
|
||||
```tsx
|
||||
{isSystem ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button disabled>Rename</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>System folders can't be renamed.</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button onClick={handleRename}>Rename</Button>
|
||||
)}
|
||||
{
|
||||
isSystem ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button disabled>Rename</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>System folders can't be renamed.</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button onClick={handleRename}>Rename</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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 ? (
|
||||
<HubRootView portSlug={portSlug} />
|
||||
) : selectedFolder?.entityType && selectedFolder.entityType !== 'root' && selectedFolder.entityId ? (
|
||||
) : selectedFolder?.entityType &&
|
||||
selectedFolder.entityType !== 'root' &&
|
||||
selectedFolder.entityId ? (
|
||||
<EntityFolderView
|
||||
portSlug={portSlug}
|
||||
entityType={selectedFolder.entityType as 'client' | 'company' | 'yacht'}
|
||||
@@ -4069,7 +4196,7 @@ grep -rln 'deploy' docs/ | head -5
|
||||
|
||||
If a runbook exists, add a step **after** the migration step:
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
### Documents hub split (Wave 11.B+)
|
||||
|
||||
Run after the `0051_documents_hub_split.sql` migration applies:
|
||||
@@ -4077,9 +4204,11 @@ Run after the `0051_documents_hub_split.sql` migration applies:
|
||||
```bash
|
||||
pnpm db:backfill:doc-folders
|
||||
```
|
||||
````
|
||||
|
||||
Idempotent — safe to re-run if the deploy is interrupted.
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
If no runbook exists, add the step to the README or a fresh `docs/deploy.md`. Don't create a docs file unless one exists or is the obvious home — the user-facing repo conventions matter more than perfect docs.
|
||||
|
||||
@@ -4092,7 +4221,7 @@ PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm \
|
||||
-f src/lib/db/migrations/0051_documents_hub_split.sql
|
||||
# Run backfill:
|
||||
pnpm db:backfill:doc-folders
|
||||
```
|
||||
````
|
||||
|
||||
Expected: zero errors, system roots populated, file folder_ids set.
|
||||
|
||||
@@ -4145,7 +4274,10 @@ test('open client entity folder, see aggregated groups, view signing details', a
|
||||
await page.getByRole('button', { name: /Smith, John/ }).click();
|
||||
await expect(page.getByText(/DIRECTLY ATTACHED/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /view signing details/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /view signing details/i }).first().click();
|
||||
await page
|
||||
.getByRole('button', { name: /view signing details/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByRole('dialog', { name: /Signing details/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user