Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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>;
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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'
|
2026-05-06 14:57:24 +02:00
|
|
|
| 'send_gdpr_export'
|
|
|
|
|
| 'password_change'
|
|
|
|
|
| 'portal_invite'
|
|
|
|
|
| 'portal_activate'
|
|
|
|
|
| 'portal_password_reset_request'
|
|
|
|
|
| 'portal_password_reset'
|
|
|
|
|
| 'send'
|
feat(clients): hard-delete with email-code confirmation (single + bulk)
Permanent client deletion is now reachable from:
- archived single-client detail page (icon button, gated by new
admin.permanently_delete_clients perm)
- archived clients list bulk action
Both flows are 2-stage: request a 4-digit code (sent to operator's
account email, 10min Redis TTL), then enter both code AND a typed
confirmation (client name single, "DELETE N CLIENTS" bulk). Cascade
strategy preserves audit trails: signed documents, email threads,
files and reminders are detached but retained; addresses, contacts,
notes, portal user, GDPR records, interests and reservations are
deleted via FK cascade or explicit tx delete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:26:42 +02:00
|
|
|
| 'view'
|
|
|
|
|
| 'request_hard_delete_code'
|
2026-05-06 20:35:34 +02:00
|
|
|
| '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'
|
2026-05-06 20:35:34 +02:00
|
|
|
// System / background events.
|
|
|
|
|
| 'webhook_delivered'
|
|
|
|
|
| 'webhook_failed'
|
|
|
|
|
| 'webhook_dead_letter'
|
|
|
|
|
| 'webhook_retried'
|
|
|
|
|
| 'job_failed'
|
|
|
|
|
| 'cron_run';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 20:35:34 +02:00
|
|
|
export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
|
|
|
|
|
export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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>;
|
2026-05-06 14:57:24 +02:00
|
|
|
/** Optional. Services that don't have request context (e.g. background
|
|
|
|
|
* jobs, internal helpers) may omit. */
|
|
|
|
|
ipAddress?: string;
|
|
|
|
|
userAgent?: string;
|
2026-05-06 20:35:34 +02:00
|
|
|
/** 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;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:50:07 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Masks sensitive field values to prevent PII or secrets from being stored
|
|
|
|
|
* verbatim in the audit log (SECURITY-GUIDELINES.md §5.2).
|
|
|
|
|
*
|
2026-05-04 22:57:01 +02:00
|
|
|
* Strings are replaced with a partial mask - first 2 chars + *** + last 2 chars.
|
2026-05-13 11:50:07 +02:00
|
|
|
* Walks nested objects/arrays so e.g. `{recipient: {email: "a@b"}}` masks
|
|
|
|
|
* the inner value too.
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
*/
|
|
|
|
|
export function maskSensitiveFields(
|
|
|
|
|
data?: Record<string, unknown>,
|
|
|
|
|
): Record<string, unknown> | undefined {
|
|
|
|
|
if (!data) return undefined;
|
2026-05-13 11:50:07 +02:00
|
|
|
return maskObject(data, 0);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2026-05-04 22:57:01 +02:00
|
|
|
* This function NEVER throws - errors are caught and logged so that an audit
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
* failure never rolls back or disrupts the parent operation.
|
|
|
|
|
*/
|
2026-05-06 20:35:34 +02:00
|
|
|
// 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',
|
|
|
|
|
};
|
|
|
|
|
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
|
|
|
|
try {
|
2026-05-06 20:35:34 +02:00
|
|
|
const severity = params.severity ?? DEFAULT_SEVERITY_BY_ACTION[params.action] ?? 'info';
|
|
|
|
|
const source = params.source ?? (AUTH_ACTIONS.has(params.action) ? 'auth' : 'user');
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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,
|
2026-05-12 17:02:10 +02:00
|
|
|
// 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,
|
2026-05-06 14:57:24 +02:00
|
|
|
ipAddress: params.ipAddress ?? null,
|
|
|
|
|
userAgent: params.userAgent ?? null,
|
2026-05-06 20:35:34 +02:00
|
|
|
severity,
|
|
|
|
|
source,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
} 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',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|