Files
pn-new-crm/src/lib/audit.ts

115 lines
3.3 KiB
TypeScript
Raw Normal View History

import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
import { logger } from '@/lib/logger';
export type AuditAction =
| 'create'
| 'update'
| 'delete'
| 'archive'
| 'restore'
| 'merge'
| 'login'
| 'logout'
| 'permission_denied'
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
| 'revert'
| 'revoke_invite'
feat(gdpr): staff-triggered client-data export bundle (Article 15) Adds a full GDPR Article 15 (right of access) workflow. Staff trigger an export from the client detail; a BullMQ worker assembles every row keyed to that client (profile, contacts, addresses, notes, tags, yachts, company memberships, interests, reservations, invoices, documents, last 500 audit events) into JSON + a self-contained HTML report, ZIPs them, uploads to MinIO, and optionally emails the client a 7-day signed download link. - New table gdpr_exports tracks lifecycle (pending → building → ready → sent / failed) with a 30-day cleanup target - Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant- scoped, with HTML escaping to block injection from rogue field values - Worker hook in export queue dispatches on job name 'gdpr-export' - New audit actions: 'request_gdpr_export', 'send_gdpr_export' - API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports rate-limit, Article-15 audit on POST); GET /:exportId returns a fresh signed URL - UI: <GdprExportButton> dialog on client detail header — admin-only, shows recent exports, supports email-to-client + override recipient, polls every 5s while open - Validation: refuses email-to-client when no primary email + no override (rather than silently dropping the send) Tests: 778/778 vitest (was 771) — +7 covering builder happy path, HTML escaping, tenant isolation, empty client, request-flow validation, and audit / queue interaction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:06:31 +02:00
| 'resend_invite'
| 'request_gdpr_export'
| 'send_gdpr_export';
export interface AuditLogParams {
/** Null for system-generated events. */
userId: string | null;
/** Null for system-level events not tied to a port. */
portId: string | null;
action: AuditAction;
entityType: string;
entityId: string;
fieldChanged?: string;
oldValue?: Record<string, unknown>;
newValue?: Record<string, unknown>;
metadata?: Record<string, unknown>;
ipAddress: string;
userAgent: string;
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']);
/**
* Masks sensitive field values to prevent PII or secrets from being stored
* verbatim in the audit log (SECURITY-GUIDELINES.md §5.2).
*
* Strings are replaced with a partial mask first 2 chars + *** + last 2 chars.
*/
export function maskSensitiveFields(
data?: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!data) return undefined;
const masked = { ...data };
for (const key of Object.keys(masked)) {
if (SENSITIVE_FIELDS.has(key) && typeof masked[key] === 'string') {
const val = masked[key] as string;
masked[key] = val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
}
}
return masked;
}
/**
* Computes a field-level diff between two records.
* Returns one entry per changed field with the old and new values.
*/
export function diffFields(
oldRecord: Record<string, unknown>,
newRecord: Record<string, unknown>,
): Array<{ field: string; oldValue: unknown; newValue: unknown }> {
const changes: Array<{ field: string; oldValue: unknown; newValue: unknown }> = [];
for (const key of Object.keys(newRecord)) {
if (JSON.stringify(oldRecord[key]) !== JSON.stringify(newRecord[key])) {
changes.push({ field: key, oldValue: oldRecord[key], newValue: newRecord[key] });
}
}
return changes;
}
/**
* Inserts an audit log entry into the database.
*
* This function NEVER throws errors are caught and logged so that an audit
* failure never rolls back or disrupts the parent operation.
*/
export async function createAuditLog(params: AuditLogParams): Promise<void> {
try {
await db.insert(auditLogs).values({
portId: params.portId,
userId: params.userId,
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
fieldChanged: params.fieldChanged ?? null,
oldValue: maskSensitiveFields(params.oldValue) ?? null,
newValue: maskSensitiveFields(params.newValue) ?? null,
metadata: params.metadata ?? null,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
});
} catch (err) {
// Strip old/new values from the log to avoid secondary exposure of the data
// that just failed to persist.
logger.error(
{
err,
audit: {
userId: params.userId,
portId: params.portId,
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
},
},
'Failed to write audit log',
);
}
}