0c4b91707f
CRITICAL fixes: - Fix SQL injection in geoportal search (template literal in $queryRaw) - Preserve enrichment data during GIS re-sync (upsert update explicit fields only) - Fix ePay version race condition (advisory lock in transaction) - Add requireAuth() to compress-pdf and unlock routes (were unauthenticated) - Remove hardcoded Stirling PDF API key (env vars now required) IMPORTANT fixes: - Add admin role check on registratura debug-sequences endpoint - Fix reserved slot race condition with advisory lock in transaction - Use SSO identity in close-guard-dialog instead of hardcoded "Utilizator" - Storage DELETE catches only P2025 (not found), re-throws real errors - Add onDelete: SetNull for GisFeature → GisSyncRun relation - Move portal-only users to PORTAL_ONLY_USERS env var - Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Add periodic cleanup for eTerra/ePay session caches and progress store - Log warning when ePay dataDocument is missing (expiry fallback) Cleanup: - Delete orphaned rgi-test page (1086 lines, unregistered, inaccessible) - Delete legacy/ folder (5 files, unreferenced from src/) - Remove unused ensureBucketExists() from minio-client.ts Documentation: - Optimize CLAUDE.md: 464 → 197 lines (moved per-module details to docs/) - Create docs/ARCHITECTURE-QUICK.md (80 lines: data flow, deps, env vars) - Create docs/MODULE-MAP.md (140 lines: entry points, API routes, cross-deps) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
/**
|
|
* 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<Actor | null> {
|
|
// 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<string, unknown> = {};
|
|
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<RegistryEntry[]> {
|
|
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<RegistryEntry | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, RegistryDirection> = {
|
|
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<RegistryEntry>;
|
|
|
|
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<RegistryEntry> };
|
|
|
|
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<string, unknown>,
|
|
updated as unknown as Record<string, unknown>,
|
|
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<Array<{ maxSeq: number | null }>>`
|
|
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<Array<{ maxSeq: number | null }>>`
|
|
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 });
|
|
}
|