/** * 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 } from "@/modules/registratura/services/registry-service"; 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 const allEntries = await loadAllEntries(true); const slot = findAvailableReservedSlot( allEntries, company, docDate.getFullYear(), docDate.getMonth(), ); if (slot) { // Claim the reserved slot — reuse its number registryNumber = slot.number; registrationType = "reserved-claimed"; claimedSlotId = slot.id; // Delete the placeholder slot await deleteEntryFromDB(slot.id); await logAuditEvent({ entryId: slot.id, entryNumber: slot.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; const updated: RegistryEntry = { ...existing, ...updates, 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); 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: existing.number, action, actor: actor.id, actorName: actor.name, company: existing.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 }); } await deleteEntryFromDB(id); 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 }); }