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,80 @@
|
||||
/**
|
||||
* Registratura Audit API — Read-only access to audit trail.
|
||||
*
|
||||
* GET — Retrieve audit events by entry ID or company.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
import {
|
||||
getAuditHistory,
|
||||
getAuditByCompany,
|
||||
} from "@/modules/registratura/services/audit-service";
|
||||
|
||||
// ── Auth ──
|
||||
|
||||
interface Actor {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function authenticateRequest(req: NextRequest): Promise<Actor | null> {
|
||||
const session = await getAuthSession();
|
||||
if (session?.user) {
|
||||
const u = session.user as { id?: string; name?: string | null; email?: string | null };
|
||||
return {
|
||||
id: u.id ?? u.email ?? "unknown",
|
||||
name: u.name ?? u.email ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── GET — Audit history ──
|
||||
|
||||
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 entryId = url.searchParams.get("entryId");
|
||||
const company = url.searchParams.get("company");
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
const limit = url.searchParams.get("limit");
|
||||
|
||||
try {
|
||||
if (entryId) {
|
||||
const events = await getAuditHistory(entryId);
|
||||
return NextResponse.json({ success: true, events, total: events.length });
|
||||
}
|
||||
|
||||
if (company) {
|
||||
const events = await getAuditByCompany(company, {
|
||||
from: from ?? undefined,
|
||||
to: to ?? undefined,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
return NextResponse.json({ success: true, events, total: events.length });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Provide entryId or company query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Reserved Slots API — Generate and list reserved registration slots.
|
||||
*
|
||||
* POST — Generate reserved slots for a company + month
|
||||
* GET — List reserved slots (with claimed/unclaimed status)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
import {
|
||||
generateReservedSlots,
|
||||
countReservedSlots,
|
||||
} from "@/modules/registratura/services/reserved-slots-service";
|
||||
import { logAuditEvent } from "@/modules/registratura/services/audit-service";
|
||||
import type { RegistryEntry } from "@/modules/registratura/types";
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
|
||||
const NAMESPACE = "registratura";
|
||||
const STORAGE_PREFIX = "entry:";
|
||||
|
||||
// ── Auth (same as main route) ──
|
||||
|
||||
interface Actor {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function authenticateRequest(req: NextRequest): Promise<Actor | null> {
|
||||
const session = await getAuthSession();
|
||||
if (session?.user) {
|
||||
const u = session.user as { id?: string; name?: string | null; email?: string | null };
|
||||
return {
|
||||
id: u.id ?? u.email ?? "unknown",
|
||||
name: u.name ?? u.email ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
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 ──
|
||||
|
||||
async function loadAllEntries(): 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) {
|
||||
entries.push(row.value as unknown as RegistryEntry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── POST — Generate reserved slots ──
|
||||
|
||||
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 { company, year, month } = body as {
|
||||
company: CompanyId;
|
||||
year: number;
|
||||
month: number; // 0-indexed
|
||||
};
|
||||
|
||||
if (!company || year == null || month == null) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: company, year, month" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (month < 0 || month > 11) {
|
||||
return NextResponse.json(
|
||||
{ error: "Month must be 0-11 (0-indexed)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if slots already exist for this company+month
|
||||
const allEntries = await loadAllEntries();
|
||||
const existing = countReservedSlots(allEntries, company, year, month);
|
||||
if (existing >= 2) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reserved slots already exist for this month", existingCount: existing },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Generate slots
|
||||
const slots = await generateReservedSlots(
|
||||
company,
|
||||
year,
|
||||
month,
|
||||
actor.id,
|
||||
actor.name,
|
||||
);
|
||||
|
||||
// Save to KeyValueStore
|
||||
for (const slot of slots) {
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: {
|
||||
namespace_key: {
|
||||
namespace: NAMESPACE,
|
||||
key: `${STORAGE_PREFIX}${slot.id}`,
|
||||
},
|
||||
},
|
||||
update: { value: slot as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: NAMESPACE,
|
||||
key: `${STORAGE_PREFIX}${slot.id}`,
|
||||
value: slot as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
entryId: slot.id,
|
||||
entryNumber: slot.number,
|
||||
action: "reserved_created",
|
||||
actor: actor.id,
|
||||
actorName: actor.name,
|
||||
company,
|
||||
detail: { date: slot.date, month, year },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, slots }, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET — List reserved slots ──
|
||||
|
||||
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 company = url.searchParams.get("company");
|
||||
const year = url.searchParams.get("year");
|
||||
const month = url.searchParams.get("month");
|
||||
|
||||
const allEntries = await loadAllEntries();
|
||||
let reserved = allEntries.filter((e) => e.isReserved === true);
|
||||
|
||||
if (company) reserved = reserved.filter((e) => e.company === company);
|
||||
if (year) {
|
||||
const yr = parseInt(year, 10);
|
||||
reserved = reserved.filter((e) => new Date(e.date).getFullYear() === yr);
|
||||
}
|
||||
if (month) {
|
||||
const m = parseInt(month, 10);
|
||||
reserved = reserved.filter((e) => new Date(e.date).getMonth() === m);
|
||||
}
|
||||
|
||||
reserved.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
return NextResponse.json({ success: true, slots: reserved, total: reserved.length });
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* 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<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",
|
||||
INT: "intern",
|
||||
};
|
||||
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
|
||||
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<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;
|
||||
|
||||
const updated: RegistryEntry = {
|
||||
...existing,
|
||||
...updates,
|
||||
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);
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user