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:
2026-05-13 11:50:07 +02:00
parent b2588ecdd8
commit 4233aa3ac3
94 changed files with 1674 additions and 895 deletions

View File

@@ -7,7 +7,7 @@ import { db } from '@/lib/db';
import { formSubmissions } from '@/lib/db/schema/documents';
import { gdprExports } from '@/lib/db/schema/gdpr';
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
import { errorEvents } from '@/lib/db/schema/system';
import { auditLogs, errorEvents } from '@/lib/db/schema/system';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { logger } from '@/lib/logger';
import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
@@ -19,6 +19,10 @@ const AI_USAGE_RETENTION_DAYS = 90;
/** error_events rows older than this are pruned. Migration 0040 declares
* this contract; the worker had no implementation until now. */
const ERROR_EVENTS_RETENTION_DAYS = 90;
/** audit_logs rows older than this are pruned. Mirrors error_events.
* Metadata is masked at insert time but older rows have no operational
* value past the window and represent residual stale-PII exposure. */
const AUDIT_LOGS_RETENTION_DAYS = 90;
/** Raw website inquiry payloads (website_submissions) — kept long enough
* to investigate "why didn't this lead reach the CRM" inbound questions
* but not indefinitely. 180d aligns with the typical sales cycle. */
@@ -139,6 +143,18 @@ export const maintenanceWorker = new Worker(
);
break;
}
case 'audit-logs-retention': {
const cutoff = new Date(Date.now() - AUDIT_LOGS_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const result = await db
.delete(auditLogs)
.where(lt(auditLogs.createdAt, cutoff))
.returning({ id: auditLogs.id });
logger.info(
{ deleted: result.length, retentionDays: AUDIT_LOGS_RETENTION_DAYS },
'Audit logs retention sweep complete',
);
break;
}
case 'website-submissions-retention': {
// Raw inquiry payloads from the marketing-site dual-write. Keep
// long enough to debug capture issues but not forever — these

View File

@@ -5,6 +5,18 @@ import type { ConnectionOptions } from 'bullmq';
import { logger } from '@/lib/logger';
import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
import { QUEUE_CONFIGS } from '@/lib/queue';
import { safeUrl } from '@/lib/email/shell';
/** HTML-escape user-supplied text so notification.description / .title
* can't break out of the surrounding `<p>` tag or smuggle <script>. */
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export const notificationsWorker = new Worker(
'notifications',
@@ -62,12 +74,19 @@ export const notificationsWorker = new Worker(
.limit(1);
if (!authUser?.email) break;
// Subject is set as plain text (not HTML) so escaping isn't
// needed there, but the body interpolates `notif.description`
// and `notif.link` into HTML — both attacker-influenceable via
// any service that enqueues a notification (e.g. document title
// copied from user-supplied filename, reminder note text).
const bodyText = escapeHtml(notif.description ?? notif.title);
const linkHtml = notif.link
? `<p><a href="${safeUrl(`${process.env.APP_URL ?? ''}${notif.link}`)}">View in CRM</a></p>`
: '';
await sendEmail(
authUser.email,
`[Port Nimara] ${notif.title}`,
`<p>${notif.description ?? notif.title}</p>${
notif.link ? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>` : ''
}`,
`<p>${bodyText}</p>${linkHtml}`,
);
await db