Files
ArchiTools/src/modules/registratura/services/audit-service.ts
T
AI Assistant a0dd35a066 feat(registratura): atomic numbering, reserved slots, audit trail, API endpoints + theme toggle animation
Registratura module:
- Atomic sequence numbering (BTG-2026-IN-00125 format) via PostgreSQL upsert
- Reserved monthly slots (2/company/month) for late registrations
- Append-only audit trail with diff tracking
- REST API: /api/registratura (CRUD), /api/registratura/reserved, /api/registratura/audit
- Auth: NextAuth session + Bearer API key support
- New "intern" direction type with UI support (form, filters, table, detail panel)
- Prisma models: RegistrySequence, RegistryAudit

Theme toggle:
- SVG mask-based sun/moon morph with 360° spin animation
- Inverted logic (sun in dark mode, moon in light mode)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:54:32 +02:00

127 lines
3.3 KiB
TypeScript

/**
* Append-only audit trail for Registratura entries.
* Server-side only — uses Prisma directly.
*/
import { prisma } from "@/core/storage/prisma";
import { Prisma } from "@prisma/client";
import type { RegistryAuditAction, RegistryAuditEvent } from "../types";
/** Log a single audit event (append-only, never updated or deleted) */
export async function logAuditEvent(params: {
entryId: string;
entryNumber: string;
action: RegistryAuditAction;
actor: string;
actorName?: string;
company: string;
detail?: Record<string, unknown>;
}): Promise<void> {
await prisma.registryAudit.create({
data: {
entryId: params.entryId,
entryNumber: params.entryNumber,
action: params.action,
actor: params.actor,
actorName: params.actorName ?? null,
company: params.company,
detail: (params.detail as Prisma.InputJsonValue) ?? Prisma.JsonNull,
},
});
}
/** Get chronological audit history for a single entry */
export async function getAuditHistory(
entryId: string,
): Promise<RegistryAuditEvent[]> {
const rows = await prisma.registryAudit.findMany({
where: { entryId },
orderBy: { createdAt: "asc" },
});
return rows.map(mapRow);
}
/** Get audit events for a company, optionally filtered by date range */
export async function getAuditByCompany(
company: string,
options?: { from?: string; to?: string; limit?: number },
): Promise<RegistryAuditEvent[]> {
const where: Prisma.RegistryAuditWhereInput = { company };
if (options?.from || options?.to) {
const createdAt: Prisma.DateTimeFilter = {};
if (options.from) createdAt.gte = new Date(options.from);
if (options.to) createdAt.lte = new Date(options.to);
where.createdAt = createdAt;
}
const rows = await prisma.registryAudit.findMany({
where,
orderBy: { createdAt: "desc" },
take: options?.limit ?? 100,
});
return rows.map(mapRow);
}
/** Compute diff between old and new entry for "updated" audit events */
export function computeEntryDiff(
oldEntry: Record<string, unknown>,
newEntry: Record<string, unknown>,
fieldsToTrack: string[],
): Record<string, { old: unknown; new: unknown }> | null {
const changes: Record<string, { old: unknown; new: unknown }> = {};
for (const field of fieldsToTrack) {
const oldVal = oldEntry[field];
const newVal = newEntry[field];
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changes[field] = { old: oldVal, new: newVal };
}
}
return Object.keys(changes).length > 0 ? changes : null;
}
// ── Internal helpers ──
function mapRow(r: {
id: string;
entryId: string;
entryNumber: string;
action: string;
actor: string;
actorName: string | null;
company: string;
detail: Prisma.JsonValue;
createdAt: Date;
}): RegistryAuditEvent {
return {
id: r.id,
entryId: r.entryId,
entryNumber: r.entryNumber,
action: r.action as RegistryAuditAction,
actor: r.actor,
actorName: r.actorName ?? undefined,
company: r.company,
detail: (r.detail as Record<string, unknown>) ?? undefined,
createdAt: r.createdAt.toISOString(),
};
}
/** Fields tracked for update diffs */
export const TRACKED_FIELDS = [
"subject",
"sender",
"recipient",
"direction",
"documentType",
"status",
"company",
"deadline",
"assignee",
"date",
"notes",
"tags",
"expiryDate",
];