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:
@@ -29,10 +29,10 @@
|
||||
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, clientMergeLog } from '@/lib/db/schema/clients';
|
||||
import { clients, clientContacts, clientMergeLog } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { files, documents, formSubmissions } from '@/lib/db/schema/documents';
|
||||
@@ -40,6 +40,7 @@ import { documentSends } from '@/lib/db/schema/brochures';
|
||||
import { emailThreads } from '@/lib/db/schema/email';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { scratchpadNotes } from '@/lib/db/schema/system';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { user as authUser } from '@/lib/db/schema/users';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
@@ -189,6 +190,29 @@ export async function hardDeleteClient(args: {
|
||||
if (!locked) throw new NotFoundError('client');
|
||||
if (!locked.archivedAt) throw new ConflictError('Client must be archived');
|
||||
|
||||
// Read email contacts BEFORE the cascade so we can wipe matching
|
||||
// website_submissions rows — that table has no clientId FK (raw
|
||||
// inquiry-form data, pre-promotion), matched only by email in the
|
||||
// JSONB payload. Article-17 requires removing the data subject's
|
||||
// submitted form data too.
|
||||
const emailContactRows = await tx
|
||||
.select({ value: clientContacts.value })
|
||||
.from(clientContacts)
|
||||
.where(and(eq(clientContacts.clientId, args.clientId), eq(clientContacts.channel, 'email')));
|
||||
const emailValues = emailContactRows
|
||||
.map((r) => r.value.trim().toLowerCase())
|
||||
.filter((v) => v.length > 0);
|
||||
if (emailValues.length > 0) {
|
||||
await tx
|
||||
.delete(websiteSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(websiteSubmissions.portId, args.portId),
|
||||
inArray(sql<string>`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Detach nullable FKs so we keep their audit history.
|
||||
await tx.update(files).set({ clientId: null }).where(eq(files.clientId, args.clientId));
|
||||
await tx.update(documents).set({ clientId: null }).where(eq(documents.clientId, args.clientId));
|
||||
@@ -265,7 +289,7 @@ function hashIds(ids: string[]): string {
|
||||
// Stable hash so the same set always produces the same key — order
|
||||
// independent. SHA-1 is more than enough for collision-avoidance on
|
||||
// a per-user keyspace.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
||||
const { createHash } = require('node:crypto') as typeof import('node:crypto');
|
||||
const sorted = [...ids].sort().join('|');
|
||||
return createHash('sha1').update(sorted).digest('hex');
|
||||
|
||||
@@ -25,43 +25,52 @@ const BODY_MAX_BYTES = 1 * 1024;
|
||||
// A 5xx in /api/v1/clients (create / update) was landing full client
|
||||
// PII (full name, DOB, address, phone, nationality, email) in
|
||||
// error_events.request_body_excerpt for the super-admin inspector.
|
||||
// Extend to cover GDPR-relevant fields too.
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
// Match fragments case-insensitively + snake/kebab-normalized so the
|
||||
// redactor catches `recipientEmail`, `client_email`, `phone_number`,
|
||||
// `tax_id`, `passwordHash`, etc. without an exhaustive enumeration.
|
||||
const SENSITIVE_KEY_FRAGMENTS = [
|
||||
// Credentials
|
||||
'password',
|
||||
'newPassword',
|
||||
'oldPassword',
|
||||
'token',
|
||||
'secret',
|
||||
'apiKey',
|
||||
'accessKey',
|
||||
'secretKey',
|
||||
'creditCard',
|
||||
'cardNumber',
|
||||
'api_key',
|
||||
'apikey',
|
||||
'access_key',
|
||||
'secret_key',
|
||||
'credit_card',
|
||||
'card_number',
|
||||
'cvv',
|
||||
'ssn',
|
||||
'authorization',
|
||||
'cookie',
|
||||
// PII
|
||||
'email',
|
||||
'emails',
|
||||
'phone',
|
||||
'phoneNumber',
|
||||
'mobile',
|
||||
'whatsapp',
|
||||
'dob',
|
||||
'dateOfBirth',
|
||||
'date_of_birth',
|
||||
'birthdate',
|
||||
'address',
|
||||
'street',
|
||||
'postcode',
|
||||
'zip',
|
||||
'nationalId',
|
||||
'national_id',
|
||||
'passport',
|
||||
'taxId',
|
||||
'fullName',
|
||||
'firstName',
|
||||
'lastName',
|
||||
]);
|
||||
'iban',
|
||||
'tax_id',
|
||||
'taxid',
|
||||
'full_name',
|
||||
'fullname',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'recipient',
|
||||
];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const k = key.toLowerCase().replace(/[-]/g, '_');
|
||||
return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag));
|
||||
}
|
||||
|
||||
/** Drop sensitive keys + cap the JSON length. */
|
||||
function sanitizeBody(body: unknown): string | null {
|
||||
@@ -77,7 +86,7 @@ function sanitizeBody(body: unknown): string | null {
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (SENSITIVE_KEYS.has(k)) {
|
||||
if (isSensitiveKey(k)) {
|
||||
out[k] = '[REDACTED]';
|
||||
} else {
|
||||
out[k] = walk(v);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ export const SETTING_KEYS = {
|
||||
emailFromName: 'email_from_name',
|
||||
emailFromAddress: 'email_from_address',
|
||||
emailReplyTo: 'email_reply_to',
|
||||
emailSignatureHtml: 'email_signature_html',
|
||||
emailFooterHtml: 'email_footer_html',
|
||||
// email_signature_html / email_footer_html — removed; the email shell
|
||||
// reads branding_email_header_html / branding_email_footer_html from
|
||||
// /admin/branding, which is the source of truth.
|
||||
emailAllowPersonalAccountSends: 'email_allow_personal_account_sends',
|
||||
smtpHostOverride: 'smtp_host_override',
|
||||
smtpPortOverride: 'smtp_port_override',
|
||||
@@ -120,8 +121,6 @@ export interface PortEmailConfig {
|
||||
fromName: string;
|
||||
fromAddress: string;
|
||||
replyTo: string | null;
|
||||
signatureHtml: string | null;
|
||||
footerHtml: string | null;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string | null;
|
||||
@@ -139,8 +138,6 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
||||
fromName,
|
||||
fromAddress,
|
||||
replyTo,
|
||||
signatureHtml,
|
||||
footerHtml,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
@@ -150,8 +147,6 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
||||
readSetting<string>(SETTING_KEYS.emailFromName, portId),
|
||||
readSetting<string>(SETTING_KEYS.emailFromAddress, portId),
|
||||
readSetting<string>(SETTING_KEYS.emailReplyTo, portId),
|
||||
readSetting<string>(SETTING_KEYS.emailSignatureHtml, portId),
|
||||
readSetting<string>(SETTING_KEYS.emailFooterHtml, portId),
|
||||
readSetting<string>(SETTING_KEYS.smtpHostOverride, portId),
|
||||
readSetting<number>(SETTING_KEYS.smtpPortOverride, portId),
|
||||
readSetting<string>(SETTING_KEYS.smtpUserOverride, portId),
|
||||
@@ -176,8 +171,6 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
||||
fromName: fromName ?? envFromName,
|
||||
fromAddress: fromAddress ?? envFromAddress,
|
||||
replyTo: replyTo ?? null,
|
||||
signatureHtml: signatureHtml ?? null,
|
||||
footerHtml: footerHtml ?? null,
|
||||
smtpHost: smtpHost ?? env.SMTP_HOST,
|
||||
smtpPort: smtpPort ?? env.SMTP_PORT,
|
||||
smtpUser: smtpUser ?? env.SMTP_USER ?? null,
|
||||
|
||||
Reference in New Issue
Block a user