fix(documents): surface signedFromDocumentId + hub cleanup
Three follow-ups from Task 15 code review: 1. (Important) The aggregated files API now LEFT JOINs against documents to surface signedFromDocumentId per file row. The "view signing details" button on EntityFolderView's Files section now passes the workflow id to SigningDetailsDialog instead of the file id. Previously the button always 404'd and the dialog hung in the loading state. Drops the v1 filename-prefix heuristic. 2. (Minor) Drop dead initialTab prop + DocumentsHubTab import — leftover from the pre-refactor tab strip. 3. (Minor) FlatFolderListing remounts on folder switch via a key prop, restoring the pre-refactor typeFilter reset behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,6 @@ import { PermissionGate } from '@/components/shared/permission-gate';
|
|||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
||||||
import type { DocumentsHubTab } from '@/lib/validators/documents';
|
|
||||||
import { FolderActionsMenu } from './folder-actions-menu';
|
import { FolderActionsMenu } from './folder-actions-menu';
|
||||||
import { FolderBreadcrumb } from './folder-breadcrumb';
|
import { FolderBreadcrumb } from './folder-breadcrumb';
|
||||||
import { FolderTreeSidebar } from './folder-tree-sidebar';
|
import { FolderTreeSidebar } from './folder-tree-sidebar';
|
||||||
@@ -64,7 +63,6 @@ const SIGNER_STATUS_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
interface DocumentsHubProps {
|
interface DocumentsHubProps {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
initialTab?: DocumentsHubTab;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
|
function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
|
||||||
@@ -137,7 +135,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
entityId={selectedFolder!.entityId!}
|
entityId={selectedFolder!.entityId!}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FlatFolderListing portSlug={portSlug} folderId={selectedFolderId} />
|
<FlatFolderListing key={selectedFolderId ?? 'root'} portSlug={portSlug} folderId={selectedFolderId} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { AggregatedSection } from './aggregated-section';
|
|||||||
import { SigningDetailsDialog } from './signing-details-dialog';
|
import { SigningDetailsDialog } from './signing-details-dialog';
|
||||||
import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing';
|
import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
import type { AggregatedWorkflow, AggregatedFile, AggregatedGroup } from '@/hooks/use-aggregated-listing';
|
import type {
|
||||||
|
AggregatedWorkflow,
|
||||||
|
AggregatedFile,
|
||||||
|
AggregatedGroup,
|
||||||
|
} from '@/hooks/use-aggregated-listing';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
@@ -68,19 +72,17 @@ export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
|
|||||||
loading={filesLoading}
|
loading={filesLoading}
|
||||||
emptyMessage="No files for this entity yet."
|
emptyMessage="No files for this entity yet."
|
||||||
renderRow={(f: AggregatedFile, _group: AggregatedGroup<AggregatedFile>) => {
|
renderRow={(f: AggregatedFile, _group: AggregatedGroup<AggregatedFile>) => {
|
||||||
// Heuristic v1: auto-deposit handler (Task 7) names signed files with "signed-" prefix.
|
const signedFromDocumentId = f.signedFromDocumentId;
|
||||||
// Follow-up: surface signedFromDocumentId from the aggregated API for a principled check.
|
|
||||||
const isSigned = f.filename?.startsWith('signed-');
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<span className="truncate">{f.filename}</span>
|
<span className="truncate">{f.filename}</span>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||||
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
|
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
|
||||||
{isSigned ? (
|
{signedFromDocumentId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 text-brand hover:underline"
|
className="flex items-center gap-1 text-brand hover:underline"
|
||||||
onClick={() => setDetailsId(f.id)}
|
onClick={() => setDetailsId(signedFromDocumentId)}
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3" />
|
<Eye className="h-3 w-3" />
|
||||||
view signing details
|
view signing details
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface AggregatedFile {
|
|||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
companyId: string | null;
|
companyId: string | null;
|
||||||
yachtId: string | null;
|
yachtId: string | null;
|
||||||
|
signedFromDocumentId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AggregatedWorkflow {
|
export interface AggregatedWorkflow {
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export async function getFileById(id: string, portId: string) {
|
|||||||
export interface AggregatedFileGroup {
|
export interface AggregatedFileGroup {
|
||||||
label: string;
|
label: string;
|
||||||
source: 'direct' | 'client' | 'company' | 'yacht';
|
source: 'direct' | 'client' | 'company' | 'yacht';
|
||||||
files: Array<typeof files.$inferSelect>;
|
files: Array<typeof files.$inferSelect & { signedFromDocumentId: string | null }>;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,9 +415,7 @@ export async function collectRelatedEntities(
|
|||||||
companies,
|
companies,
|
||||||
and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)),
|
and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)),
|
||||||
)
|
)
|
||||||
.where(
|
.where(and(eq(companyMemberships.clientId, entityId), isNull(companyMemberships.endDate)));
|
||||||
and(eq(companyMemberships.clientId, entityId), isNull(companyMemberships.endDate)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const directYachts = await db
|
const directYachts = await db
|
||||||
.select({ id: yachts.id, name: yachts.name })
|
.select({ id: yachts.id, name: yachts.name })
|
||||||
@@ -463,9 +461,7 @@ export async function collectRelatedEntities(
|
|||||||
clients,
|
clients,
|
||||||
and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)),
|
and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)),
|
||||||
)
|
)
|
||||||
.where(
|
.where(and(eq(companyMemberships.companyId, entityId), isNull(companyMemberships.endDate)));
|
||||||
and(eq(companyMemberships.companyId, entityId), isNull(companyMemberships.endDate)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const ownedYachts = await db
|
const ownedYachts = await db
|
||||||
.select({ id: yachts.id, name: yachts.name })
|
.select({ id: yachts.id, name: yachts.name })
|
||||||
@@ -518,10 +514,37 @@ async function fetchGroupRows(
|
|||||||
portId: string,
|
portId: string,
|
||||||
predicate: ReturnType<typeof eq>,
|
predicate: ReturnType<typeof eq>,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<{ rows: Array<typeof files.$inferSelect>; total: number }> {
|
): Promise<{
|
||||||
|
rows: Array<typeof files.$inferSelect & { signedFromDocumentId: string | null }>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: files.id,
|
||||||
|
portId: files.portId,
|
||||||
|
clientId: files.clientId,
|
||||||
|
yachtId: files.yachtId,
|
||||||
|
companyId: files.companyId,
|
||||||
|
folderId: files.folderId,
|
||||||
|
filename: files.filename,
|
||||||
|
originalName: files.originalName,
|
||||||
|
mimeType: files.mimeType,
|
||||||
|
sizeBytes: files.sizeBytes,
|
||||||
|
storagePath: files.storagePath,
|
||||||
|
storageBucket: files.storageBucket,
|
||||||
|
category: files.category,
|
||||||
|
uploadedBy: files.uploadedBy,
|
||||||
|
createdAt: files.createdAt,
|
||||||
|
// Reverse-link: if any document row has this file as its signed_file_id,
|
||||||
|
// surface that document's id. LEFT JOIN preserves files with no workflow link.
|
||||||
|
// Defense-in-depth: portId filter on both the join condition and the outer where.
|
||||||
|
signedFromDocumentId: documents.id,
|
||||||
|
})
|
||||||
.from(files)
|
.from(files)
|
||||||
|
.leftJoin(
|
||||||
|
documents,
|
||||||
|
and(eq(documents.signedFileId, files.id), eq(documents.portId, portId)),
|
||||||
|
)
|
||||||
.where(and(eq(files.portId, portId), predicate))
|
.where(and(eq(files.portId, portId), predicate))
|
||||||
.orderBy(desc(files.createdAt))
|
.orderBy(desc(files.createdAt))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ describe('files service · listFilesAggregatedByEntity', () => {
|
|||||||
const yachtGroup = result.groups.find((g) => g.label.startsWith('FROM YACHT'));
|
const yachtGroup = result.groups.find((g) => g.label.startsWith('FROM YACHT'));
|
||||||
expect(yachtGroup?.source).toBe('yacht');
|
expect(yachtGroup?.source).toBe('yacht');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('file rows carry signedFromDocumentId=null when no workflow references them', async () => {
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||||||
|
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||||||
|
expect(directGroup).toBeDefined();
|
||||||
|
for (const f of directGroup!.files) {
|
||||||
|
expect(f).toHaveProperty('signedFromDocumentId');
|
||||||
|
expect(f.signedFromDocumentId).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('caps each group at 20 rows + surfaces total', () => {
|
describe('caps each group at 20 rows + surfaces total', () => {
|
||||||
@@ -213,6 +223,45 @@ describe('files service · listFilesAggregatedByEntity', () => {
|
|||||||
expect(result.groups).toHaveLength(0);
|
expect(result.groups).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('signedFromDocumentId reverse-link', () => {
|
||||||
|
let portId: string;
|
||||||
|
let clientId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
|
||||||
|
const client = await makeClient({ portId });
|
||||||
|
clientId = client.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces signedFromDocumentId when a workflow references the file', async () => {
|
||||||
|
const fileRow = await insertFile(portId, { clientId });
|
||||||
|
|
||||||
|
// Insert a document row with signedFileId pointing at the file
|
||||||
|
const [doc] = await db
|
||||||
|
.insert(documents)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
clientId,
|
||||||
|
documentType: 'contract',
|
||||||
|
title: 'Signed Contract',
|
||||||
|
status: 'completed',
|
||||||
|
signedFileId: fileRow.id,
|
||||||
|
createdBy: TEST_USER_ID,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
|
||||||
|
const directGroup = result.groups.find((g) => g.label === 'DIRECTLY ATTACHED');
|
||||||
|
expect(directGroup).toBeDefined();
|
||||||
|
|
||||||
|
const linkedFile = directGroup!.files.find((f) => f.id === fileRow.id);
|
||||||
|
expect(linkedFile).toBeDefined();
|
||||||
|
expect(linkedFile!.signedFromDocumentId).toBe(doc!.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── listInflightWorkflowsAggregatedByEntity ─────────────────────────────────
|
// ─── listInflightWorkflowsAggregatedByEntity ─────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user