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';
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
| 'resend_invite';
|
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>;
|
|
|
|
|
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']);
|
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).
|
|
|
|
|
*
|
|
|
|
|
* 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',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|