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:
AI Assistant
2026-03-10 07:54:32 +02:00
parent f94529c380
commit a0dd35a066
15 changed files with 1354 additions and 124 deletions
@@ -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",
];
@@ -1,5 +1,6 @@
import type { CompanyId } from "@/core/auth/types";
import type { RegistryEntry, RegistryAttachment } from "../types";
import type { RegistryEntry, RegistryAttachment, RegistryDirection } from "../types";
import { REGISTRY_COMPANY_PREFIX, DIRECTION_TYPE_CODE } from "../types";
const STORAGE_PREFIX = "entry:";
@@ -173,6 +174,8 @@ export async function deleteEntry(
await blobStorage.delete(id).catch(() => {});
}
// ── Old-format numbering (deprecated — kept for backward compatibility) ──
const COMPANY_PREFIXES: Record<CompanyId, string> = {
beletage: "B",
"urban-switch": "US",
@@ -181,9 +184,8 @@ const COMPANY_PREFIXES: Record<CompanyId, string> = {
};
/**
* Generate company-specific registry number: B-0001/2026
* Uses the highest existing number + 1 for that company in that year.
* Parses actual numbers from entries to prevent duplicates.
* @deprecated Use allocateSequenceNumber() via the API instead.
* Client-side numbering — race-condition prone, kept only for fallback.
*/
export function generateRegistryNumber(
company: CompanyId,
@@ -194,8 +196,6 @@ export function generateRegistryNumber(
const year = now.getFullYear();
const prefix = COMPANY_PREFIXES[company];
// Parse the numeric part from existing numbers for this company+year
// Pattern: PREFIX-NNNN/YYYY
const regex = new RegExp(`^${prefix}-(\\d+)/${year}$`);
let maxNum = 0;
@@ -212,6 +212,91 @@ export function generateRegistryNumber(
return `${prefix}-${padded}/${year}`;
}
// ── New-format numbering (server-side, atomic) ──
/**
* Allocate the next sequence number atomically via PostgreSQL.
* Format: BTG-2026-IN-00001
*
* Uses INSERT ... ON CONFLICT ... UPDATE RETURNING for race-condition safety.
* Must be called from server-side only (API routes).
*/
export async function allocateSequenceNumber(
company: CompanyId,
direction: RegistryDirection,
year?: number,
): Promise<{ number: string; sequence: number }> {
// Dynamic import — prisma is only available server-side
const { prisma } = await import("@/core/storage/prisma");
const companyPrefix = REGISTRY_COMPANY_PREFIX[company];
const typeCode = DIRECTION_TYPE_CODE[direction];
const yr = year ?? new Date().getFullYear();
const result = await prisma.$queryRaw<Array<{ lastSeq: number }>>`
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${typeCode}, 1, NOW(), NOW())
ON CONFLICT (company, year, type)
DO UPDATE SET "lastSeq" = "RegistrySequence"."lastSeq" + 1, "updatedAt" = NOW()
RETURNING "lastSeq"
`;
const row = result[0];
if (!row) throw new Error("Failed to allocate sequence number");
const seq = row.lastSeq;
const padded = String(seq).padStart(5, "0");
return {
number: `${companyPrefix}-${yr}-${typeCode}-${padded}`,
sequence: seq,
};
}
// ── Number format detection + parsing ──
export interface ParsedRegistryNumber {
company: string;
year: number;
type?: string;
sequence: number;
format: "old" | "new";
}
/** Detect whether a number uses the new format (BTG-2026-IN-00125) */
export function isNewFormat(num: string): boolean {
return /^(BTG|SDT|USW|GRP)-\d{4}-(IN|OUT|INT)-\d{5}$/.test(num);
}
/** Parse a registry number in either old or new format */
export function parseRegistryNumber(num: string): ParsedRegistryNumber | null {
// New format: BTG-2026-IN-00125
const newMatch = num.match(
/^(BTG|SDT|USW|GRP)-(\d{4})-(IN|OUT|INT)-(\d{5})$/,
);
if (newMatch) {
return {
company: newMatch[1]!,
year: parseInt(newMatch[2]!, 10),
type: newMatch[3]!,
sequence: parseInt(newMatch[4]!, 10),
format: "new",
};
}
// Old format: B-0001/2026
const oldMatch = num.match(/^(B|US|SDT|G)-(\d+)\/(\d{4})$/);
if (oldMatch) {
return {
company: oldMatch[1]!,
year: parseInt(oldMatch[3]!, 10),
sequence: parseInt(oldMatch[2]!, 10),
format: "old",
};
}
return null;
}
/** Calculate days overdue (negative = days remaining, positive = overdue) */
export function getOverdueDays(deadline: string | undefined): number | null {
if (!deadline) return null;
@@ -0,0 +1,145 @@
/**
* Reserved registration slots for late registrations.
*
* Each month gets 2 reserved slots per company:
* - mid-month (~15th, snapped to nearest previous working day)
* - end-of-month (~last day, snapped to nearest previous working day)
*
* Slots use the IN sequence by default. When a late IN document arrives,
* it claims the slot's number. OUT/INT docs get new typed numbers instead.
*/
import { v4 as uuid } from "uuid";
import { isWorkingDay } from "./working-days";
import { allocateSequenceNumber } from "./registry-service";
import type { CompanyId } from "@/core/auth/types";
import type { RegistryEntry } from "../types";
/** Snap a date to the nearest previous working day (MonFri, not a holiday) */
function nearestPreviousWorkingDay(date: Date): Date {
const d = new Date(date);
while (!isWorkingDay(d)) {
d.setDate(d.getDate() - 1);
}
return d;
}
/** Get the last calendar day of a month */
function lastDayOfMonth(year: number, month: number): Date {
return new Date(year, month + 1, 0); // month is 0-indexed
}
/** Compute the two reservation target dates for a given month */
export function getReservationDates(
year: number,
month: number,
): [Date, Date] {
const mid = nearestPreviousWorkingDay(new Date(year, month, 15));
const end = nearestPreviousWorkingDay(lastDayOfMonth(year, month));
// Edge case: if both land on the same day, shift mid one working day earlier
if (mid.getTime() === end.getTime()) {
mid.setDate(mid.getDate() - 1);
while (!isWorkingDay(mid)) {
mid.setDate(mid.getDate() - 1);
}
}
return [mid, end];
}
/** Format a Date as YYYY-MM-DD */
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
/**
* Generate 2 reserved slot entries for a given company + month.
* Allocates real IN sequence numbers so they occupy positions in the registry.
*/
export async function generateReservedSlots(
company: CompanyId,
year: number,
month: number,
actorId: string,
actorName: string,
): Promise<RegistryEntry[]> {
const [midDate, endDate] = getReservationDates(year, month);
const now = new Date().toISOString();
const slots: RegistryEntry[] = [];
for (const targetDate of [midDate, endDate]) {
const { number } = await allocateSequenceNumber(
company,
"intrat",
year,
);
const dateStr = toDateStr(targetDate);
const entry: RegistryEntry = {
id: uuid(),
number,
date: dateStr,
registrationDate: dateStr,
direction: "intrat",
documentType: "altele",
subject: `[Slot rezervat] ${dateStr}`,
sender: "",
recipient: "",
company,
status: "reserved",
linkedEntryIds: [],
attachments: [],
tags: [],
notes: "",
visibility: "internal",
isReserved: true,
registrationType: "normal",
createdBy: actorId,
createdByName: actorName,
createdAt: now,
updatedAt: now,
};
slots.push(entry);
}
return slots;
}
/**
* Find an unclaimed reserved slot for a given company + month.
* Returns the first available slot, or null if none available.
*/
export function findAvailableReservedSlot(
entries: RegistryEntry[],
company: CompanyId,
targetYear: number,
targetMonth: number,
): RegistryEntry | null {
for (const e of entries) {
if (!e.isReserved || e.status !== "reserved") continue;
if (e.company !== company) continue;
const d = new Date(e.date);
if (d.getFullYear() === targetYear && d.getMonth() === targetMonth) {
return e;
}
}
return null;
}
/**
* Check if reserved slots already exist for a given company + month.
*/
export function countReservedSlots(
entries: RegistryEntry[],
company: CompanyId,
year: number,
month: number,
): number {
return entries.filter((e) => {
if (!e.isReserved) return false;
if (e.company !== company) return false;
const d = new Date(e.date);
return d.getFullYear() === year && d.getMonth() === month;
}).length;
}