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:
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user