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:
@@ -44,6 +44,7 @@ const RECURRING_JOB_NAMES: ReadonlySet<string> = new Set([
|
||||
'gdpr-export-cleanup',
|
||||
'ai-usage-retention',
|
||||
'error-events-retention',
|
||||
'audit-logs-retention',
|
||||
'website-submissions-retention',
|
||||
]);
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ export async function registerRecurringJobs(): Promise<void> {
|
||||
{ queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' },
|
||||
// Migration 0040 contract: error_events older than 90 days get pruned.
|
||||
{ queue: 'maintenance', name: 'error-events-retention', pattern: '0 6 * * *' },
|
||||
// 90-day retention for audit_logs — mirrors error_events. Metadata
|
||||
// is masked at insert time but old rows still represent stale PII
|
||||
// exposure that has no operational value past the window.
|
||||
{ queue: 'maintenance', name: 'audit-logs-retention', pattern: '15 6 * * *' },
|
||||
// Raw website inquiry payloads — 180-day retention.
|
||||
{ queue: 'maintenance', name: 'website-submissions-retention', pattern: '0 7 * * *' },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user