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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user