feat(documents): hub page with tabs, filters, and live counts

Replaces /documents with the Phase A hub: tabs (All/Awaiting them/
Awaiting me/Completed/Expired) backed by per-tab counts via a new
hub-counts endpoint, signature-only chip, type filter, expandable
signer rows, and real-time invalidation across the eight document
socket events. listDocuments grew tab/watcher/signatureOnly/sent-window
filters; the legacy file browser moved to /documents/files where the
sidebar already linked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:35:36 +02:00
parent 398d6322f1
commit da7262f18f
9 changed files with 718 additions and 146 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm';
import { and, count, eq, gte, inArray, lt, lte, ne, sql, exists } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
@@ -43,15 +43,143 @@ interface AuditMeta {
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listDocuments(portId: string, query: ListDocumentsInput) {
const { page, limit, sort, order, search, interestId, clientId, documentType, status } = query;
import { documentWatchers as documentWatchersTable } from '@/lib/db/schema/documents';
const filters = [];
const NON_SIGNATURE_TYPES = [
'welcome_letter',
'handover_checklist',
'acknowledgment',
'correspondence',
];
function buildHubTabFilters(
tab: ListDocumentsInput['tab'],
currentUserEmail: string | undefined,
): ReturnType<typeof and>[] {
const filters: ReturnType<typeof and>[] = [];
if (!tab || tab === 'all') return filters;
switch (tab) {
case 'awaiting_them':
// "awaiting them" = pending signers other than the current user.
// Without a known caller email we cannot make that distinction, so
// short-circuit to empty rather than silently widen the result set.
if (!currentUserEmail) {
filters.push(sql`1 = 0`);
break;
}
filters.push(inArray(documents.status, ['sent', 'partially_signed']));
filters.push(
exists(
db
.select({ x: sql`1` })
.from(documentSigners)
.where(
and(
eq(documentSigners.documentId, documents.id),
eq(documentSigners.status, 'pending'),
ne(documentSigners.signerEmail, currentUserEmail),
),
),
),
);
break;
case 'awaiting_me':
if (!currentUserEmail) {
// Without a current-user email there is no concept of "awaiting me"
filters.push(sql`1 = 0`);
break;
}
filters.push(
exists(
db
.select({ x: sql`1` })
.from(documentSigners)
.where(
and(
eq(documentSigners.documentId, documents.id),
eq(documentSigners.status, 'pending'),
eq(documentSigners.signerEmail, currentUserEmail),
),
),
),
);
break;
case 'completed':
filters.push(inArray(documents.status, ['completed', 'signed']));
break;
case 'expired':
// Either explicitly expired, or in-flight past their expiry date.
// (Documents schema doesn't yet have an `expires_at` column, so for
// now this is just status='expired' — extend when expiry lands.)
filters.push(eq(documents.status, 'expired'));
break;
}
return filters;
}
export interface ListDocumentsExtra {
/** Email of the calling user — used by hub tab filtering for "awaiting me". */
currentUserEmail?: string;
}
export async function listDocuments(
portId: string,
query: ListDocumentsInput,
extra: ListDocumentsExtra = {},
) {
const {
page,
limit,
sort,
order,
search,
interestId,
clientId,
documentType,
status,
tab,
watcherUserId,
signatureOnly,
sentSince,
sentUntil,
} = query;
const filters: ReturnType<typeof and>[] = [];
if (interestId) filters.push(eq(documents.interestId, interestId));
if (clientId) filters.push(eq(documents.clientId, clientId));
if (documentType) filters.push(eq(documents.documentType, documentType));
if (status) filters.push(eq(documents.status, status));
if (sentSince) filters.push(gte(documents.createdAt, new Date(sentSince)));
if (sentUntil) filters.push(lte(documents.createdAt, new Date(sentUntil)));
if (signatureOnly === true) {
filters.push(
sql`${documents.documentType} NOT IN ('welcome_letter','handover_checklist','acknowledgment','correspondence')`,
);
} else if (signatureOnly === false) {
// Pass-through, no extra filter needed.
}
if (watcherUserId) {
filters.push(
exists(
db
.select({ x: sql`1` })
.from(documentWatchersTable)
.where(
and(
eq(documentWatchersTable.documentId, documents.id),
eq(documentWatchersTable.userId, watcherUserId),
),
),
),
);
}
filters.push(...buildHubTabFilters(tab, extra.currentUserEmail));
void NON_SIGNATURE_TYPES;
void lt;
const sortColumn =
sort === 'title'
@@ -70,13 +198,52 @@ export async function listDocuments(portId: string, query: ListDocumentsInput) {
updatedAtColumn: documents.updatedAt,
searchColumns: [documents.title],
searchTerm: search,
filters,
filters: filters.filter(Boolean) as Parameters<typeof buildListQuery>[0]['filters'],
sort: sort ? { column: sortColumn, direction: order } : undefined,
page,
pageSize: limit,
});
}
// ─── Hub tab counts ───────────────────────────────────────────────────────────
export interface HubTabCounts {
all: number;
awaiting_them: number;
awaiting_me: number;
completed: number;
expired: number;
}
/**
* Compute hub tab counts in a single roundtrip per tab. Uses
* idx_docs_status_port for cheap aggregation.
*/
export async function getHubTabCounts(
portId: string,
currentUserEmail: string | undefined,
): Promise<HubTabCounts> {
async function tabCount(tab: ListDocumentsInput['tab']): Promise<number> {
const filters: ReturnType<typeof and>[] = [eq(documents.portId, portId)];
filters.push(...buildHubTabFilters(tab, currentUserEmail));
const [row] = await db
.select({ count: count() })
.from(documents)
.where(and(...filters));
return row?.count ?? 0;
}
const [all, awaiting_them, awaiting_me, completed, expired] = await Promise.all([
tabCount('all'),
tabCount('awaiting_them'),
tabCount('awaiting_me'),
tabCount('completed'),
tabCount('expired'),
]);
return { all, awaiting_them, awaiting_me, completed, expired };
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getDocumentById(id: string, portId: string) {

View File

@@ -17,11 +17,31 @@ export const updateDocumentSchema = z.object({
status: z.enum(DOCUMENT_STATUSES).optional(),
});
export const documentsHubTabs = [
'all',
'awaiting_them',
'awaiting_me',
'completed',
'expired',
] as const;
export type DocumentsHubTab = (typeof documentsHubTabs)[number];
export const listDocumentsSchema = baseListQuerySchema.extend({
interestId: z.string().optional(),
clientId: z.string().optional(),
documentType: z.string().optional(),
status: z.string().optional(),
/** Hub tab filter — applies tab-specific status / signer-membership constraints. */
tab: z.enum(documentsHubTabs).optional(),
/** Restrict to docs being watched by this user id. */
watcherUserId: z.string().optional(),
/** When true, only docs intended for signing (default true on hub). */
signatureOnly: z
.enum(['true', 'false'])
.optional()
.transform((v) => (v === undefined ? undefined : v === 'true')),
sentSince: z.string().datetime().optional(),
sentUntil: z.string().datetime().optional(),
});
export const uploadSignedSchema = z.object({