fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to Sheet side=right so every detail-preview surface uses the same primitive. Document the doctrine: Sheet for side panels on both desktop and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX (currently just MoreSheet). Closes ui/ux M11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* yacht ownership rows that resolve to this client.
|
||||
*/
|
||||
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { and, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
@@ -20,15 +20,21 @@ import {
|
||||
clientNotes,
|
||||
clientRelationships,
|
||||
clientTags,
|
||||
clientMergeLog,
|
||||
} from '@/lib/db/schema/clients';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { tags, scratchpadNotes } from '@/lib/db/schema/system';
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { documents } from '@/lib/db/schema/documents';
|
||||
import { documents, files, formSubmissions } from '@/lib/db/schema/documents';
|
||||
import { auditLogs } from '@/lib/db/schema/system';
|
||||
import { portalUsers } from '@/lib/db/schema/portal';
|
||||
import { emailThreads, emailMessages } from '@/lib/db/schema/email';
|
||||
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
||||
import { documentSends } from '@/lib/db/schema/brochures';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
|
||||
export interface GdprBundle {
|
||||
/** Bundle metadata for traceability. */
|
||||
@@ -50,9 +56,22 @@ export interface GdprBundle {
|
||||
company: Record<string, unknown>;
|
||||
}>;
|
||||
interests: Record<string, unknown>[];
|
||||
contactLog: Record<string, unknown>[];
|
||||
reservations: Record<string, unknown>[];
|
||||
invoices: Record<string, unknown>[];
|
||||
documents: Record<string, unknown>[];
|
||||
files: Record<string, unknown>[];
|
||||
formSubmissions: Record<string, unknown>[];
|
||||
websiteSubmissions: Record<string, unknown>[];
|
||||
documentSends: Record<string, unknown>[];
|
||||
emailThreads: Array<{
|
||||
thread: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
}>;
|
||||
reminders: Record<string, unknown>[];
|
||||
scratchpadNotes: Record<string, unknown>[];
|
||||
portalUsers: Record<string, unknown>[];
|
||||
mergeLog: Record<string, unknown>[];
|
||||
auditTrail: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@@ -79,6 +98,14 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
||||
reservationRows,
|
||||
invoiceRows,
|
||||
documentRows,
|
||||
fileRows,
|
||||
formSubmissionRows,
|
||||
documentSendRows,
|
||||
threadRows,
|
||||
reminderRows,
|
||||
scratchpadRows,
|
||||
portalUserRows,
|
||||
mergeLogRows,
|
||||
auditRows,
|
||||
] = await Promise.all([
|
||||
db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId) }),
|
||||
@@ -127,6 +154,34 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
||||
db.query.documents.findMany({
|
||||
where: and(eq(documents.portId, portId), eq(documents.clientId, clientId)),
|
||||
}),
|
||||
db.query.files.findMany({
|
||||
where: and(eq(files.portId, portId), eq(files.clientId, clientId)),
|
||||
}),
|
||||
db.query.formSubmissions.findMany({
|
||||
where: eq(formSubmissions.clientId, clientId),
|
||||
}),
|
||||
db.query.documentSends.findMany({
|
||||
where: and(eq(documentSends.portId, portId), eq(documentSends.clientId, clientId)),
|
||||
}),
|
||||
db.query.emailThreads.findMany({
|
||||
where: and(eq(emailThreads.portId, portId), eq(emailThreads.clientId, clientId)),
|
||||
}),
|
||||
db.query.reminders.findMany({
|
||||
where: and(eq(reminders.portId, portId), eq(reminders.clientId, clientId)),
|
||||
}),
|
||||
db.query.scratchpadNotes.findMany({
|
||||
where: eq(scratchpadNotes.linkedClientId, clientId),
|
||||
}),
|
||||
db.query.portalUsers.findMany({ where: eq(portalUsers.clientId, clientId) }),
|
||||
db.query.clientMergeLog.findMany({
|
||||
where: and(
|
||||
eq(clientMergeLog.portId, portId),
|
||||
or(
|
||||
eq(clientMergeLog.survivingClientId, clientId),
|
||||
eq(clientMergeLog.mergedClientId, clientId),
|
||||
),
|
||||
),
|
||||
}),
|
||||
db.query.auditLogs.findMany({
|
||||
where: and(
|
||||
eq(auditLogs.portId, portId),
|
||||
@@ -138,6 +193,56 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
||||
}),
|
||||
]);
|
||||
|
||||
// Email messages are linked through threads (no direct clientId column).
|
||||
const threadIds = threadRows.map((t) => t.id);
|
||||
const messageRows = threadIds.length
|
||||
? await db.query.emailMessages.findMany({
|
||||
where: inArray(emailMessages.threadId, threadIds),
|
||||
orderBy: (t, { asc }) => [asc(t.sentAt)],
|
||||
})
|
||||
: [];
|
||||
const messagesByThread = new Map<string, Record<string, unknown>[]>();
|
||||
for (const m of messageRows) {
|
||||
const list = messagesByThread.get(m.threadId) ?? [];
|
||||
list.push(toJsonRow(m));
|
||||
messagesByThread.set(m.threadId, list);
|
||||
}
|
||||
const emailThreadBundle = threadRows.map((t) => ({
|
||||
thread: toJsonRow(t),
|
||||
messages: messagesByThread.get(t.id) ?? [],
|
||||
}));
|
||||
|
||||
// Interest contact-log has no clientId — fetch via the client's interests.
|
||||
const interestIds = interestRows.map((i) => i.id);
|
||||
const contactLogRows = interestIds.length
|
||||
? await db.query.interestContactLog.findMany({
|
||||
where: and(
|
||||
eq(interestContactLog.portId, portId),
|
||||
inArray(interestContactLog.interestId, interestIds),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.occurredAt)],
|
||||
})
|
||||
: [];
|
||||
|
||||
// Website submissions pre-date the client record (no FK). Match by any
|
||||
// of the client's email contacts against payload->>'email' (case-
|
||||
// insensitive) so the bundle includes inquiry forms that became this
|
||||
// client.
|
||||
const emailValues = contacts
|
||||
.filter((c) => c.channel === 'email' && c.value)
|
||||
.map((c) => c.value.toLowerCase());
|
||||
const websiteSubmissionRows = emailValues.length
|
||||
? await db
|
||||
.select()
|
||||
.from(websiteSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(websiteSubmissions.portId, portId),
|
||||
inArray(sql<string>`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -162,9 +267,19 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
||||
company: toJsonRow(row.company),
|
||||
})),
|
||||
interests: interestRows.map(toJsonRow),
|
||||
contactLog: contactLogRows.map(toJsonRow),
|
||||
reservations: reservationRows.map(toJsonRow),
|
||||
invoices: invoiceRows.map(toJsonRow),
|
||||
documents: documentRows.map(toJsonRow),
|
||||
files: fileRows.map(toJsonRow),
|
||||
formSubmissions: formSubmissionRows.map(toJsonRow),
|
||||
websiteSubmissions: websiteSubmissionRows.map(toJsonRow),
|
||||
documentSends: documentSendRows.map(toJsonRow),
|
||||
emailThreads: emailThreadBundle,
|
||||
reminders: reminderRows.map(toJsonRow),
|
||||
scratchpadNotes: scratchpadRows.map(toJsonRow),
|
||||
portalUsers: portalUserRows.map(toJsonRow),
|
||||
mergeLog: mergeLogRows.map(toJsonRow),
|
||||
auditTrail: auditRows.map(toJsonRow),
|
||||
};
|
||||
}
|
||||
@@ -245,9 +360,29 @@ export function renderBundleHtml(bundle: GdprBundle): string {
|
||||
})),
|
||||
),
|
||||
tableSection('Interests', bundle.interests),
|
||||
tableSection('Contact log', bundle.contactLog),
|
||||
tableSection('Reservations', bundle.reservations),
|
||||
tableSection('Invoices', bundle.invoices),
|
||||
tableSection('Documents', bundle.documents),
|
||||
tableSection('Files', bundle.files),
|
||||
tableSection('Form submissions', bundle.formSubmissions),
|
||||
tableSection('Website submissions (inquiry forms)', bundle.websiteSubmissions),
|
||||
tableSection('Document sends (PDFs / brochures emailed)', bundle.documentSends),
|
||||
tableSection(
|
||||
'Email threads',
|
||||
bundle.emailThreads.map((t) => ({
|
||||
...t.thread,
|
||||
messageCount: t.messages.length,
|
||||
})),
|
||||
),
|
||||
tableSection(
|
||||
'Email messages',
|
||||
bundle.emailThreads.flatMap((t) => t.messages.map((m) => ({ threadId: t.thread.id, ...m }))),
|
||||
),
|
||||
tableSection('Reminders', bundle.reminders),
|
||||
tableSection('Scratchpad notes', bundle.scratchpadNotes),
|
||||
tableSection('Portal users', bundle.portalUsers),
|
||||
tableSection('Merge log', bundle.mergeLog),
|
||||
tableSection('Audit trail (last 500 events)', bundle.auditTrail),
|
||||
].join('\n');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user