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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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",
|
||||
];
|
||||
Reference in New Issue
Block a user