/** * Registratura API — Main CRUD endpoints. * * POST — Create entry (atomic numbering, late registration, audit) * GET — List entries / get single entry * PUT — Update entry (diff audit) * DELETE — Delete entry (audit) * * Auth: NextAuth session OR Bearer API key (REGISTRY_API_KEY env). */ import { NextRequest, NextResponse } from "next/server"; import type { Prisma } from "@prisma/client"; import { v4 as uuid } from "uuid"; import { prisma } from "@/core/storage/prisma"; import { getAuthSession } from "@/core/auth"; import { allocateSequenceNumber, recalculateSequence, parseRegistryNumber } from "@/modules/registratura/services/registry-service"; import { REGISTRY_COMPANY_PREFIX, OLD_COMPANY_PREFIX } from "@/modules/registratura/types"; import { logAuditEvent, computeEntryDiff, TRACKED_FIELDS, } from "@/modules/registratura/services/audit-service"; import { findAvailableReservedSlot } from "@/modules/registratura/services/reserved-slots-service"; import type { RegistryEntry, RegistryDirection } from "@/modules/registratura/types"; import type { CompanyId } from "@/core/auth/types"; const NAMESPACE = "registratura"; const BLOB_NAMESPACE = "registratura-blobs"; const STORAGE_PREFIX = "entry:"; // ── Auth ── interface Actor { id: string; name: string; company?: string; } async function authenticateRequest(req: NextRequest): Promise { // 1. Check NextAuth session const session = await getAuthSession(); if (session?.user) { const u = session.user as { id?: string; name?: string | null; email?: string | null; company?: string }; return { id: u.id ?? u.email ?? "unknown", name: u.name ?? u.email ?? "unknown", company: u.company, }; } // 2. Check API key const apiKey = process.env.REGISTRY_API_KEY; if (apiKey) { const auth = req.headers.get("authorization"); if (auth === `Bearer ${apiKey}`) { return { id: "api-key", name: "ERP Integration" }; } } return null; } // ── Helpers ── /** Strip base64 data >1KB from entry JSON (same logic as /api/storage lightweight) */ function stripHeavyFields(obj: unknown): unknown { if (typeof obj === "string") return obj.length > 1024 ? "__stripped__" : obj; if (Array.isArray(obj)) return obj.map(stripHeavyFields); if (obj && typeof obj === "object") { const result: Record = {}; for (const [k, v] of Object.entries(obj)) { if ((k === "data" || k === "fileData") && typeof v === "string" && v.length > 1024) { result[k] = "__stripped__"; } else { result[k] = stripHeavyFields(v); } } return result; } return obj; } async function loadAllEntries(lightweight = true): Promise { const rows = await prisma.keyValueStore.findMany({ where: { namespace: NAMESPACE }, select: { key: true, value: true }, }); const entries: RegistryEntry[] = []; for (const row of rows) { if (row.key.startsWith(STORAGE_PREFIX) && row.value) { const val = lightweight ? stripHeavyFields(row.value) : row.value; entries.push(val as unknown as RegistryEntry); } } entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); return entries; } async function loadEntry(id: string): Promise { const row = await prisma.keyValueStore.findUnique({ where: { namespace_key: { namespace: NAMESPACE, key: `${STORAGE_PREFIX}${id}` } }, }); return (row?.value as unknown as RegistryEntry) ?? null; } async function saveEntryToDB(entry: RegistryEntry): Promise { await prisma.keyValueStore.upsert({ where: { namespace_key: { namespace: NAMESPACE, key: `${STORAGE_PREFIX}${entry.id}` } }, update: { value: entry as unknown as Prisma.InputJsonValue }, create: { namespace: NAMESPACE, key: `${STORAGE_PREFIX}${entry.id}`, value: entry as unknown as Prisma.InputJsonValue, }, }); } async function deleteEntryFromDB(id: string): Promise { await prisma.keyValueStore.deleteMany({ where: { namespace: NAMESPACE, key: `${STORAGE_PREFIX}${id}` }, }); // Clean up blobs await prisma.keyValueStore.deleteMany({ where: { namespace: BLOB_NAMESPACE, key: id }, }); } // ── GET — List or get single entry ── export async function GET(req: NextRequest) { const actor = await authenticateRequest(req); if (!actor) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const url = new URL(req.url); const id = url.searchParams.get("id"); const company = url.searchParams.get("company"); const year = url.searchParams.get("year"); const type = url.searchParams.get("type"); const status = url.searchParams.get("status"); const full = url.searchParams.get("full") === "true"; // Single entry if (id) { const entry = await loadEntry(id); if (!entry) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } return NextResponse.json({ success: true, entry }); } // List with optional filters let entries = await loadAllEntries(!full); if (company) entries = entries.filter((e) => e.company === company); if (year) { const yr = parseInt(year, 10); entries = entries.filter((e) => { const d = new Date(e.date); return d.getFullYear() === yr; }); } if (type) { const dirMap: Record = { IN: "intrat", OUT: "iesit", }; const dir = dirMap[type.toUpperCase()]; if (dir) entries = entries.filter((e) => e.direction === dir); } if (status) entries = entries.filter((e) => e.status === status); return NextResponse.json({ success: true, entries, total: entries.length }); } // ── POST — Create entry with atomic numbering ── export async function POST(req: NextRequest) { const actor = await authenticateRequest(req); if (!actor) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } try { const body = await req.json(); const entryData = body.entry as Partial; if (!entryData.company || !entryData.direction) { return NextResponse.json( { error: "Missing required fields: company, direction" }, { status: 400 }, ); } const company = entryData.company as CompanyId; const direction = entryData.direction as RegistryDirection; const now = new Date().toISOString(); const entryDate = entryData.date ?? now.slice(0, 10); // Check if this is a late registration (document date is in a past month) const docDate = new Date(entryDate); const today = new Date(); const isPastMonth = docDate.getFullYear() < today.getFullYear() || (docDate.getFullYear() === today.getFullYear() && docDate.getMonth() < today.getMonth()); let registryNumber: string; let registrationType: "normal" | "late" | "reserved-claimed" = "normal"; let claimedSlotId: string | undefined; if (isPastMonth && direction === "intrat") { // Try to claim a reserved slot — use advisory lock to prevent concurrent claims const lockKey = `reserved:${company}-${docDate.getFullYear()}-${docDate.getMonth()}`; const claimed = await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`; const allEntries = await loadAllEntries(true); const slot = findAvailableReservedSlot( allEntries, company, docDate.getFullYear(), docDate.getMonth(), ); if (!slot) return null; // Delete the placeholder slot within the lock await tx.keyValueStore.delete({ where: { namespace_key: { namespace: "registratura", key: slot.id } }, }); return slot; }); if (claimed) { registryNumber = claimed.number; registrationType = "reserved-claimed"; claimedSlotId = claimed.id; await logAuditEvent({ entryId: claimed.id, entryNumber: claimed.number, action: "reserved_claimed", actor: actor.id, actorName: actor.name, company: company, detail: { claimedBy: entryData.subject ?? "late registration" }, }); } else { // No reserved slot — allocate new number, mark as late const { number } = await allocateSequenceNumber(company, direction); registryNumber = number; registrationType = "late"; } } else if (isPastMonth) { // OUT/INT late registration — always get new number const { number } = await allocateSequenceNumber(company, direction); registryNumber = number; registrationType = "late"; } else { // Normal registration const { number } = await allocateSequenceNumber(company, direction); registryNumber = number; } const entry: RegistryEntry = { id: entryData.id ?? uuid(), number: registryNumber, date: entryDate, registrationDate: entryData.registrationDate ?? now.slice(0, 10), direction, documentType: entryData.documentType ?? "altele", subject: entryData.subject ?? "", sender: entryData.sender ?? "", senderContactId: entryData.senderContactId, recipient: entryData.recipient ?? "", recipientContactId: entryData.recipientContactId, recipientRegNumber: entryData.recipientRegNumber, recipientRegDate: entryData.recipientRegDate, company, status: entryData.status ?? "deschis", closureInfo: entryData.closureInfo, deadline: entryData.deadline, assignee: entryData.assignee, assigneeContactId: entryData.assigneeContactId, threadParentId: entryData.threadParentId, linkedEntryIds: entryData.linkedEntryIds ?? [], attachments: entryData.attachments ?? [], trackedDeadlines: entryData.trackedDeadlines, expiryDate: entryData.expiryDate, expiryAlertDays: entryData.expiryAlertDays, externalStatusUrl: entryData.externalStatusUrl, externalTrackingId: entryData.externalTrackingId, acValidity: entryData.acValidity, tags: entryData.tags ?? [], notes: entryData.notes ?? "", visibility: entryData.visibility ?? "internal", createdAt: now, updatedAt: now, isReserved: false, registrationType, createdBy: actor.id, createdByName: actor.name, claimedReservedSlotId: claimedSlotId, }; await saveEntryToDB(entry); // Audit: created await logAuditEvent({ entryId: entry.id, entryNumber: entry.number, action: registrationType === "late" ? "late_registration" : "created", actor: actor.id, actorName: actor.name, company: company, detail: { direction, registrationType, ...(claimedSlotId ? { claimedSlotId } : {}), }, }); return NextResponse.json({ success: true, entry }, { status: 201 }); } catch (err) { const message = err instanceof Error ? err.message : "Internal error"; return NextResponse.json({ error: message }, { status: 500 }); } } // ── PUT — Update entry ── export async function PUT(req: NextRequest) { const actor = await authenticateRequest(req); if (!actor) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } try { const body = await req.json(); const { id, updates } = body as { id: string; updates: Partial }; if (!id) { return NextResponse.json({ error: "Missing id" }, { status: 400 }); } const existing = await loadEntry(id); if (!existing) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Prevent changing immutable fields delete updates.id; delete updates.number; delete updates.createdAt; delete updates.createdBy; delete updates.createdByName; // Re-allocate registry number when company or direction changes const companyChanged = updates.company && updates.company !== existing.company; const directionChanged = updates.direction && updates.direction !== existing.direction; let newNumber: string | undefined; if (companyChanged || directionChanged) { const targetCompany = (updates.company ?? existing.company) as CompanyId; const targetDirection = (updates.direction ?? existing.direction) as RegistryDirection; const allocated = await allocateSequenceNumber(targetCompany, targetDirection); newNumber = allocated.number; } const updated: RegistryEntry = { ...existing, ...updates, ...(newNumber ? { number: newNumber } : {}), updatedAt: new Date().toISOString(), }; // Compute diff for audit const diff = computeEntryDiff( existing as unknown as Record, updated as unknown as Record, TRACKED_FIELDS, ); await saveEntryToDB(updated); // Recalculate old company's counter so next entry gets the correct number if (companyChanged || directionChanged) { await recalculateSequence( existing.company as CompanyId, existing.direction as RegistryDirection, ); } if (diff) { const action = updates.status === "inchis" && existing.status !== "inchis" ? "closed" as const : updates.status === "deschis" && existing.status === "inchis" ? "reopened" as const : "updated" as const; await logAuditEvent({ entryId: id, entryNumber: updated.number, action, actor: actor.id, actorName: actor.name, company: updated.company, detail: { changes: diff }, }); } return NextResponse.json({ success: true, entry: updated }); } catch (err) { const message = err instanceof Error ? err.message : "Internal error"; return NextResponse.json({ error: message }, { status: 500 }); } } // ── DELETE — Delete entry ── export async function DELETE(req: NextRequest) { const actor = await authenticateRequest(req); if (!actor) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const url = new URL(req.url); const id = url.searchParams.get("id"); if (!id) { return NextResponse.json({ error: "Missing id" }, { status: 400 }); } const existing = await loadEntry(id); if (!existing) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Only allow deleting the LAST entry in the sequence (prevent gaps) const parsed = parseRegistryNumber(existing.number); if (parsed) { const companyPrefix = parsed.format === "current" ? parsed.company : REGISTRY_COMPANY_PREFIX[existing.company as CompanyId] ?? parsed.company; const yr = parsed.year; const oldPrefix = OLD_COMPANY_PREFIX[companyPrefix] ?? ""; // Find the actual max sequence for this company+year // NOTE: JSONB value::text serializes with space after colons const newLike = `%"number": "${companyPrefix}-${yr}-%"%`; const newRegex = `${companyPrefix}-${yr}-([0-9]{5})`; const newMaxRows = await prisma.$queryRaw>` SELECT MAX( CAST(SUBSTRING(value::text FROM ${newRegex}) AS INTEGER) ) AS "maxSeq" FROM "KeyValueStore" WHERE namespace = 'registratura' AND key LIKE 'entry:%' AND value::text LIKE ${newLike} `; let maxSeq = newMaxRows[0]?.maxSeq ?? 0; // Also check old format if (oldPrefix) { const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`; const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`; const oldMaxRows = await prisma.$queryRaw>` SELECT MAX( CAST(SUBSTRING(value::text FROM ${oldRegex}) AS INTEGER) ) AS "maxSeq" FROM "KeyValueStore" WHERE namespace = 'registratura' AND key LIKE 'entry:%' AND value::text LIKE ${oldLike} `; maxSeq = Math.max(maxSeq, oldMaxRows[0]?.maxSeq ?? 0); } if (parsed.sequence < maxSeq) { return NextResponse.json( { error: `Nu poți șterge ${existing.number} — există înregistrări cu numere mai mari (max: ${maxSeq}). Doar ultimul număr din secvență poate fi șters.`, }, { status: 409 }, ); } } await deleteEntryFromDB(id); // Recalculate counter so next allocation reads the correct max await recalculateSequence( existing.company as CompanyId, existing.direction as RegistryDirection, ); await logAuditEvent({ entryId: id, entryNumber: existing.number, action: "deleted", actor: actor.id, actorName: actor.name, company: existing.company, detail: { subject: existing.subject }, }); return NextResponse.json({ success: true }); }