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

291 lines
9.7 KiB
TypeScript
Raw Normal View History

import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
import { logger } from '@/lib/logger';
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson Address the CRITICAL + high-leverage HIGH items from the types-auditor: **C1 — `tx: any` in client-restore.service** Export a canonical `Tx` type from `lib/db/utils.ts` (derived from Drizzle's `db.transaction` callback shape) and use it in `applyReversal` so the 12+ downstream tx writes get full inference. **C2 — berth-detail page stacked `useQuery<any>` escape hatches** Export `BerthDetailData` from berth-detail-header and consume it through useQuery + apiFetch. Removed three `any` escapes in the highest-traffic detail page. Also collapsed the duplicate `BerthData` in berth-tabs.tsx to import from berth-detail-header so the two types can't drift. **C3 — parseBody migration for portal/public routes** Replace raw `await req.json() + schema.parse(body)` with the project-standard `parseBody(req, schema)` helper across 7 routes: - portal/auth/{change-password, activate, reset-password} - auth/set-password - public/{interests, residential-inquiries} Skipped the three anti-enumeration routes (forgot-password, sign-in, sign-in-by-identifier) where the manual validation gives opaque errors on purpose. website-inquiries already wraps the parse in a custom 400 — left as-is. **HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)** Introduce `toAuditJson<T extends object>(row: T): Record<string, unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow` that already exists for the same reason). Codemod 21 `<row> as unknown as Record<string, unknown>` sites across: - invoices.ts × 6 - expenses.ts × 6 - berths.service × 2 - documents.service × 2 - ocr-config.service × 2 - ai-budget.service × 2 - yachts.service, companies.service, company-memberships.service × 1 each document-templates' `payload as unknown as Record<...>` is a different shape (Documenso form-values widening, not an audit log) — kept the manual cast there. Tests stay 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
/**
* Widen a Drizzle row (or any object) to the shape audit_logs.oldValue /
* newValue expects. Centralizes the structurally-safe `Record<string,
* unknown>` cast 20+ services were doing inline via
* `as unknown as Record<string, unknown>`. Mirrors gdpr-bundle-builder's
* `toJsonRow` helper (same audit-found motivation).
*/
export function toAuditJson<T extends object>(row: T): Record<string, unknown> {
return row as unknown as Record<string, unknown>;
}
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'
| 'password_change'
| 'portal_invite'
| 'portal_activate'
| 'portal_password_reset_request'
| 'portal_password_reset'
| 'send'
| 'view'
| 'request_hard_delete_code'
| 'hard_delete'
feat(branding): port logo upload pipeline for internal PDFs Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the brand-kit Header pulls in for every internal-only PDF. Server pipeline (src/lib/services/logo.service.ts): - magic-byte format check via sharp metadata - rejects animated/multi-frame inputs - SVGs sanitized via svgo preset-default + post-pass regex check (rejects <script>, on*=, javascript:, external href, <foreignObject>), then rasterized to PNG at 300 DPI - HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp - optional crop coords applied server-side (bounds-checked first) - auto-trim near-white borders - resize so longest edge <= 1200px, sRGB, palette-PNG - rejects undersized output (< 200px any side) or > 1MB - atomic system_settings upsert; soft-archives prior file row + storage object API: GET /api/v1/admin/branding/logo current logo metadata POST /api/v1/admin/branding/logo multipart upload + crop DELETE /api/v1/admin/branding/logo clear; future PDFs fall back to port-name text header GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx with the current logo so admins can spot-check letterboxing in real shell UI: src/components/admin/branding/pdf-logo-uploader.tsx - react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle - file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB) - dark-band preview swatch shows how the logo lands in the header - post-upload warnings panel surfaces every server-side normalization (resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.) - "Test with sample PDF" button streams a real PDF for spot-check - "Remove" tears down the file + storage object + setting Wired into the existing /admin/branding settings page beneath the Identity and Email-branding cards. Audit: Two new AuditAction enum values added: branding.logo.uploaded and branding.logo.archived. Captured per upload + per archived prior logo. Tests: tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path, undersized rejection, empty/oversized rejection, non-image rejection, out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG with embedded script rejection, SVG with external href rejection, JPEG-with-no-alpha warning collection. 1308/1308 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:51:49 +02:00
// Branding (port logo upload pipeline).
| 'branding.logo.uploaded'
| 'branding.logo.archived'
// System / background events.
| 'webhook_delivered'
| 'webhook_failed'
| 'webhook_dead_letter'
| 'webhook_retried'
| 'job_failed'
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
| 'cron_run'
// Berth-rule decision trace: emitted by the rules engine on every
// evaluateRule() call so admins can debug "why did this fire / not fire"
// without reading server logs. Distinct from the actual `update` audit
// row the auto-applied path emits when it mutates berth status.
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
| 'rule_evaluated'
// M-AU04: distinct verbs for outcome-set / outcome-cleared. The pre-fix
// path used a generic `update` row with `metadata.type = 'outcome_set'`,
// which the audit filter dropdown couldn't surface as its own bucket
// and the FTS GENERATED index missed entirely.
| 'outcome_set'
| 'outcome_cleared';
/**
* Common shape passed to service functions so they can stamp audit logs and
* propagate request context. Every authenticated route resolves these from
* the session + headers; services accept them rather than reaching into
* Next.js APIs themselves.
*/
export interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
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>;
/** Optional. Services that don't have request context (e.g. background
* jobs, internal helpers) may omit. */
ipAddress?: string;
userAgent?: string;
/** Defaults to 'info'. Bump to 'warning' for permission_denied,
* 'error' for failed background jobs / webhook DLQ, 'critical' for
* hard-deletes / security-relevant events. */
severity?: AuditSeverity;
/** Defaults to 'user'. Use 'auth' for session lifecycle,
* 'webhook' for delivery events, 'job' / 'cron' / 'system' for
* background work. The inspector filters on this column. */
source?: AuditSource;
}
// Lower-cased key fragments. A metadata key is masked if any fragment is
// contained as a substring after lowercase + snake/kebab normalization.
// Substring match catches `recipientEmail`, `sent_to_email`, `userEmail`,
// `attempted_email`, `from_address`, `phone_number`, `passwordHash`, etc.
const SENSITIVE_KEY_FRAGMENTS = [
'email',
'phone',
'password',
'token',
'credentials',
'secret',
'api_key',
'apikey',
'auth',
'authorization',
'cookie',
'address', // physical/mailing addresses
'dob',
'date_of_birth',
'birthdate',
'tax_id',
'taxid',
'national_id',
'ssn',
'passport',
'iban',
'card_number',
'cvv',
'recipient', // e.g. recipientEmail catches the parent too — preserves intent
'first_name',
'last_name',
'full_name',
'fullname',
];
function isSensitiveKey(key: string): boolean {
const k = key.toLowerCase().replace(/[-]/g, '_');
return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag));
}
function maskString(val: string): string {
return val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
}
function maskValue(value: unknown, depth: number): unknown {
if (depth > 4) return '[depth-limit]';
if (value === null || value === undefined) return value;
if (typeof value === 'string') return maskString(value);
if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
if (typeof value === 'object') {
// Recurse into nested object — only mask keys that themselves look
// sensitive. Parents stay traversable.
return maskObject(value as Record<string, unknown>, depth + 1);
}
// Non-string primitives (number/boolean/bigint/symbol) at sensitive keys
// are passed through unchanged. The original contract was "only mask
// strings" — a number at an `email` key is a type error upstream and
// shouldn't be silently replaced with `***`.
return value;
}
function maskObject(data: Record<string, unknown>, depth: number): Record<string, unknown> {
if (depth > 4) return { _truncated: '[depth-limit]' };
const masked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (isSensitiveKey(key)) {
masked[key] = maskValue(value, depth + 1);
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
masked[key] = maskObject(value as Record<string, unknown>, depth + 1);
} else if (Array.isArray(value)) {
masked[key] = value.map((v) =>
v && typeof v === 'object' && !Array.isArray(v)
? maskObject(v as Record<string, unknown>, depth + 1)
: v,
);
} else {
masked[key] = value;
}
}
return masked;
}
/**
* 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.
* Walks nested objects/arrays so e.g. `{recipient: {email: "a@b"}}` masks
* the inner value too.
*/
export function maskSensitiveFields(
data?: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!data) return undefined;
return maskObject(data, 0);
}
/**
* 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.
*/
// Some actions get a default severity bump so callers don't have to
// remember; explicit `severity` on the call still wins.
const DEFAULT_SEVERITY_BY_ACTION: Partial<Record<AuditAction, AuditSeverity>> = {
permission_denied: 'warning',
hard_delete: 'critical',
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
// L-AU01: explicit severities so the row badge in /admin/audit lights
// up correctly. Without these, security-relevant verbs landed as
// generic 'info' grey rows next to read events.
password_change: 'warning',
portal_invite: 'info',
portal_activate: 'info',
portal_password_reset_request: 'warning',
portal_password_reset: 'warning',
revoke_invite: 'warning',
request_gdpr_export: 'info',
send_gdpr_export: 'info',
request_hard_delete_code: 'warning',
outcome_set: 'info',
outcome_cleared: 'info',
// Webhook lifecycle defaults to warning when a delivery fails.
webhook_failed: 'warning',
webhook_dead_letter: 'error',
job_failed: 'error',
};
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);
export async function createAuditLog(params: AuditLogParams): Promise<void> {
try {
const severity = params.severity ?? DEFAULT_SEVERITY_BY_ACTION[params.action] ?? 'info';
const source = params.source ?? (AUTH_ACTIONS.has(params.action) ? 'auth' : 'user');
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,
// Mask metadata too — the audit found portal-auth, crm-invite,
// hard-delete, and email-accounts services were writing raw emails
// into this column.
metadata: maskSensitiveFields(params.metadata) ?? null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
severity,
source,
});
} 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',
);
}
}