fix: registry number re-allocation on company change + extreme PDF large file support

- Registratura: re-allocate number when company/direction changes on update,
  recalculate old company's sequence counter from actual entries
- Extreme PDF: stream body to temp file instead of req.formData() to support large files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-11 14:05:13 +02:00
parent ed504bd1de
commit 1c51236c31
3 changed files with 118 additions and 13 deletions
+50 -10
View File
@@ -1,10 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
import { writeFile, readFile, unlink, mkdir, stat } from "fs/promises";
import { createWriteStream } from "fs";
import { execFile } from "child_process";
import { promisify } from "util";
import { randomUUID } from "crypto";
import { join } from "path";
import { tmpdir } from "os";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
const execFileAsync = promisify(execFile);
@@ -97,23 +100,60 @@ async function cleanup(dir: string) {
export async function POST(req: NextRequest) {
const tmpDir = join(tmpdir(), `pdf-extreme-${randomUUID()}`);
try {
const formData = await req.formData();
const fileBlob = formData.get("fileInput") as Blob | null;
if (!fileBlob) {
// Stream body directly to temp file — avoids req.formData() size limit
// that causes "Failed to parse body as FormData" on large files
await mkdir(tmpDir, { recursive: true });
const rawPath = join(tmpDir, "raw-upload");
const inputPath = join(tmpDir, "input.pdf");
const gsOutputPath = join(tmpDir, "gs-output.pdf");
const finalOutputPath = join(tmpDir, "final.pdf");
if (!req.body) {
return NextResponse.json(
{ error: "Lipsește fișierul PDF." },
{ status: 400 },
);
}
const originalSize = fileBlob.size;
await mkdir(tmpDir, { recursive: true });
// Write the raw multipart body to disk
const nodeStream = Readable.fromWeb(req.body as import("stream/web").ReadableStream);
await pipeline(nodeStream, createWriteStream(rawPath));
const inputPath = join(tmpDir, "input.pdf");
const gsOutputPath = join(tmpDir, "gs-output.pdf");
const finalOutputPath = join(tmpDir, "final.pdf");
// Extract the PDF from multipart: find the double CRLF after headers,
// then read until the boundary marker before the end
const rawBuf = await readFile(rawPath);
const headerEnd = rawBuf.indexOf(Buffer.from("\r\n\r\n"));
if (headerEnd === -1) {
return NextResponse.json(
{ error: "Lipsește fișierul PDF." },
{ status: 400 },
);
}
await writeFile(inputPath, Buffer.from(await fileBlob.arrayBuffer()));
// Extract boundary from Content-Type header
const contentType = req.headers.get("content-type") || "";
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
const boundary = boundaryMatch?.[1] ?? boundaryMatch?.[2] ?? "";
if (!boundary) {
return NextResponse.json(
{ error: "Lipsește fișierul PDF." },
{ status: 400 },
);
}
// File content starts after first double CRLF, ends before closing boundary
const closingBoundary = Buffer.from(`\r\n--${boundary}`);
const fileStart = headerEnd + 4;
const fileEnd = rawBuf.indexOf(closingBoundary, fileStart);
const pdfData = fileEnd !== -1 ? rawBuf.subarray(fileStart, fileEnd) : rawBuf.subarray(fileStart);
await writeFile(inputPath, pdfData);
const originalSize = pdfData.length;
// Clean up raw file early to free disk space
await unlink(rawPath).catch(() => {});
// Step 1: Ghostscript — aggressive image recompression + downsampling
try {
+23 -3
View File
@@ -14,7 +14,7 @@ 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 { allocateSequenceNumber, recalculateSequence } from "@/modules/registratura/services/registry-service";
import {
logAuditEvent,
computeEntryDiff,
@@ -349,9 +349,21 @@ export async function PUT(req: NextRequest) {
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(),
};
@@ -364,6 +376,14 @@ export async function PUT(req: NextRequest) {
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
@@ -373,11 +393,11 @@ export async function PUT(req: NextRequest) {
await logAuditEvent({
entryId: id,
entryNumber: existing.number,
entryNumber: updated.number,
action,
actor: actor.id,
actorName: actor.name,
company: existing.company,
company: updated.company,
detail: { changes: diff },
});
}
@@ -252,6 +252,51 @@ export async function allocateSequenceNumber(
};
}
/**
* Recalculate a company's sequence counter to match actual entries in the DB.
* Called when an entry is reassigned away from a company, so the counter
* reflects the real max sequence instead of staying artificially high.
*/
export async function recalculateSequence(
company: CompanyId,
direction: RegistryDirection,
year?: number,
): Promise<void> {
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();
// Find the actual max sequence from entries in KeyValueStore
const pattern = `${companyPrefix}-${yr}-${typeCode}-%`;
const rows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX(
CAST(SUBSTRING(value::text FROM ${`${companyPrefix}-${yr}-${typeCode}-(\\d{5})`}) AS INTEGER)
) AS "maxSeq"
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text LIKE ${`%"number":"${pattern}"%`}
`;
const actualMax = rows[0]?.maxSeq ?? 0;
// Reset the counter to the actual max (or delete if 0)
if (actualMax === 0) {
await prisma.$executeRaw`
DELETE FROM "RegistrySequence"
WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${typeCode}
`;
} else {
await prisma.$executeRaw`
UPDATE "RegistrySequence"
SET "lastSeq" = ${actualMax}, "updatedAt" = NOW()
WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${typeCode}
`;
}
}
// ── Number format detection + parsing ──
export interface ParsedRegistryNumber {