实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 09:25:44 +08:00
父节点 3d9ec4c5a7
当前提交 da35591bd5
共修改 29 个文件,包含 907 行新增和 527 行删除
+138 -36
查看文件
@@ -75,6 +75,14 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().trim().max(500).nullable().optional(),
travelPurpose: z.string().trim().max(200).nullable().optional(),
travelLocation: z.string().trim().max(200).nullable().optional(),
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
}).superRefine((value, ctx) => {
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
}
});
const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
@@ -86,7 +94,6 @@ const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
.object({ reason: z.string().trim().max(200).optional() })
.strict();
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
const REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA = z.coerce.number().int().min(0).max(39);
function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "")
@@ -123,9 +130,16 @@ const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
description: z.string().max(500).optional(),
templateId: z.string().uuid().optional(),
linkedTravelRequestId: z.string().uuid().optional(),
travelPurpose: z.string().trim().max(200).optional(),
travelLocation: z.string().trim().max(200).optional(),
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
}
if (value.templateId && value.linkedTravelRequestId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -1540,6 +1554,18 @@ async function reimbursementRoutes(fastify) {
timeZone: fastify.config.appTimezone,
});
const linkedTravelCities = linkedTravelRequestRecord
? [...new Set(linkedTravelRequestRecord.segments.flatMap((segment) => [segment.from_city, segment.to_city]).filter(Boolean))]
: [];
const travelPurpose = body.travelPurpose?.trim() || linkedTravelRequestRecord?.purpose || null;
const travelLocation = body.travelLocation?.trim() || linkedTravelCities.join("、") || null;
const travelStartDate = body.travelStartDate
? parseDateOnlyString(body.travelStartDate)
: linkedTravelRequestRecord?.start_date ?? null;
const travelEndDate = body.travelEndDate
? parseDateOnlyString(body.travelEndDate)
: linkedTravelRequestRecord?.end_date ?? null;
const created = await tx.reimbursementForm.create({
data: {
form_number: issued.documentNumber,
@@ -1549,6 +1575,10 @@ async function reimbursementRoutes(fastify) {
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: resolvedDescription,
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
travel_purpose: travelPurpose,
travel_location: travelLocation,
travel_start_date: travelStartDate,
travel_end_date: travelEndDate,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -1562,6 +1592,10 @@ async function reimbursementRoutes(fastify) {
owner_id: true,
submitter_id: true,
linked_travel_request_id: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
travel_request: {
select: {
id: true,
@@ -1658,6 +1692,12 @@ async function reimbursementRoutes(fastify) {
totalAmount: toMoneyString(reimbursement.total_amount),
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
purpose: reimbursement.travel_purpose ?? null,
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date),
endDate: toDateOnlyString(reimbursement.travel_end_date),
},
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
});
},
@@ -2061,6 +2101,10 @@ async function reimbursementRoutes(fastify) {
paid_at: true,
project_id: true,
linked_travel_request_id: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
paid_by_employee: {
select: {
id: true,
@@ -2261,6 +2305,12 @@ async function reimbursementRoutes(fastify) {
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
const travelItemDates = travelItems
.map((item) => item.expense_date)
.filter(Boolean)
.sort((left, right) => left.getTime() - right.getTime());
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
return sendData(reply, request, {
id: reimbursement.id,
@@ -2297,6 +2347,13 @@ async function reimbursementRoutes(fastify) {
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
isTravel: isTravelReimbursement,
purpose: reimbursement.travel_purpose ?? (isTravelReimbursement ? reimbursement.description : null),
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date ?? travelItemDates[0] ?? null),
endDate: toDateOnlyString(reimbursement.travel_end_date ?? travelItemDates.at(-1) ?? null),
},
owner: reimbursement.owner,
submitter: reimbursement.submitter,
items: reimbursement.items.map((item) => ({
@@ -2357,7 +2414,7 @@ async function reimbursementRoutes(fastify) {
const updated = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
SELECT id, owner_id, submitter_id, department_id, status, travel_start_date, travel_end_date
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -2392,6 +2449,20 @@ async function reimbursementRoutes(fastify) {
const nextDescription =
body.description === undefined ? undefined : body.description?.trim() || null;
const nextTravelStartDate = body.travelStartDate === undefined
? reimbursement.travel_start_date
: body.travelStartDate ? parseDateOnlyString(body.travelStartDate) : null;
const nextTravelEndDate = body.travelEndDate === undefined
? reimbursement.travel_end_date
: body.travelEndDate ? parseDateOnlyString(body.travelEndDate) : null;
if (nextTravelStartDate && nextTravelEndDate && nextTravelStartDate > nextTravelEndDate) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { travelEndDate: "invalid_date_range" },
});
}
return tx.reimbursementForm.update({
where: { id: reimbursementId },
@@ -2400,6 +2471,10 @@ async function reimbursementRoutes(fastify) {
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
: {}),
...(body.description !== undefined ? { description: nextDescription } : {}),
...(body.travelPurpose !== undefined ? { travel_purpose: body.travelPurpose?.trim() || null } : {}),
...(body.travelLocation !== undefined ? { travel_location: body.travelLocation?.trim() || null } : {}),
...(body.travelStartDate !== undefined ? { travel_start_date: nextTravelStartDate } : {}),
...(body.travelEndDate !== undefined ? { travel_end_date: nextTravelEndDate } : {}),
updated_by: request.employee.id,
},
select: {
@@ -2408,6 +2483,10 @@ async function reimbursementRoutes(fastify) {
reimbursement_date: true,
description: true,
total_amount: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
},
});
});
@@ -2418,6 +2497,12 @@ async function reimbursementRoutes(fastify) {
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
description: updated.description,
totalAmount: toMoneyString(updated.total_amount),
travelSummary: {
purpose: updated.travel_purpose,
location: updated.travel_location,
startDate: toDateOnlyString(updated.travel_start_date),
endDate: toDateOnlyString(updated.travel_end_date),
},
});
},
);
@@ -3003,6 +3088,7 @@ async function reimbursementRoutes(fastify) {
storage_path: true,
mime_type: true,
file_size: true,
file_sha256: true,
},
},
attachments: {
@@ -3012,6 +3098,7 @@ async function reimbursementRoutes(fastify) {
storage_path: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
@@ -3043,6 +3130,7 @@ async function reimbursementRoutes(fastify) {
storagePath: invoice.storage_path,
mimeType: invoice.mime_type,
fileSize: invoice.file_size,
fileSha256: invoice.file_sha256,
})),
...reimbursement.attachments.map((attachment, index) => ({
id: attachment.id,
@@ -3051,6 +3139,7 @@ async function reimbursementRoutes(fastify) {
storagePath: attachment.storage_path,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
fileSha256: attachment.file_sha256,
})),
];
if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) {
@@ -3079,22 +3168,14 @@ async function reimbursementRoutes(fastify) {
}
const origin = resolveSignedFileOrigin(fastify.config, request);
const pages = created.bundle.pages.map((page, pageIndex) => {
const previewToken = signPayload({
purpose: "reimbursement_material_preview",
reimbursementId,
bundleId: created.bundleId,
pageIndex,
actorId: request.employee.id,
exp: expiresAt,
}, fastify.config.jwtSecret);
return {
url: `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}/${pageIndex}?previewToken=${encodeURIComponent(previewToken)}`,
materialType: page.materialType,
materialIndex: page.materialIndex,
pageNumber: page.pageNumber,
};
});
const previewToken = signPayload({
purpose: "reimbursement_material_preview",
reimbursementId,
bundleId: created.bundleId,
actorId: request.employee.id,
exp: expiresAt,
}, fastify.config.jwtSecret);
const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}?previewToken=${encodeURIComponent(previewToken)}`;
try {
await fastify.prisma.auditLog.create({
@@ -3106,7 +3187,7 @@ async function reimbursementRoutes(fastify) {
extra: {
invoiceCount: reimbursement.invoice_pool_items.length,
attachmentCount: reimbursement.attachments.length,
pageCount: pages.length,
pageCount: created.bundle.pageCount,
reason,
expiresAt,
},
@@ -3119,30 +3200,31 @@ async function reimbursementRoutes(fastify) {
return sendData(reply, request, {
expiresAt: new Date(expiresAt).toISOString(),
pages,
signedUrl,
originalName: "reimbursement-materials-preview.pdf",
mimeType: "application/pdf",
fileSize: created.fileSize,
pageCount: created.bundle.pageCount,
});
},
);
fastify.get(
"/reimbursements/:id/materials/preview/:bundleId/:pageIndex",
"/reimbursements/:id/materials/preview/:bundleId",
{},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedBundleId = UUID_SCHEMA.safeParse(request.params?.bundleId);
const parsedPageIndex = REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA.safeParse(request.params?.pageIndex);
const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false };
const payload = verified.ok ? verified.payload : null;
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
if (
!parsedBundleId.success ||
!parsedPageIndex.success ||
!verified.ok ||
payload?.purpose !== "reimbursement_material_preview" ||
payload?.reimbursementId !== reimbursementId ||
payload?.bundleId !== parsedBundleId.data ||
payload?.pageIndex !== parsedPageIndex.data ||
typeof payload?.exp !== "number" ||
payload.exp <= Date.now() ||
!parsedActorId.success
@@ -3150,10 +3232,7 @@ async function reimbursementRoutes(fastify) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const resolved = materialPreviewStore.getPage({
bundleId: parsedBundleId.data,
pageIndex: parsedPageIndex.data,
});
const resolved = materialPreviewStore.getBundle({ bundleId: parsedBundleId.data });
if (
!resolved ||
resolved.bundle.reimbursementId !== reimbursementId ||
@@ -3170,19 +3249,17 @@ async function reimbursementRoutes(fastify) {
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
materialId: resolved.page.materialId,
materialType: resolved.page.materialType,
materialIndex: resolved.page.materialIndex,
pageNumber: resolved.page.pageNumber,
pageCount: resolved.bundle.pageCount,
},
}),
});
reply.type("image/png");
reply.type("application/pdf");
reply.header("cache-control", "private, no-store");
reply.header("referrer-policy", "no-referrer");
reply.header("content-disposition", "inline");
return reply.send(resolved.page.buffer);
reply.header("content-disposition", 'inline; filename="reimbursement-materials-preview.pdf"');
reply.header("content-length", String(resolved.artifact.buffer.length));
return reply.send(resolved.artifact.buffer);
},
);
@@ -3526,7 +3603,8 @@ async function reimbursementRoutes(fastify) {
const submitResult = await fastify.prisma.$transaction(async (tx) => {
let budgetCheck = null;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount,
linked_travel_request_id, travel_purpose, travel_location, travel_start_date, travel_end_date
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -3554,6 +3632,30 @@ async function reimbursementRoutes(fastify) {
ensureNonNegativeAmount(reimbursement.total_amount);
const travelItem = await tx.reimbursementItem.findFirst({
where: {
reimbursement_id: reimbursementId,
expense_type: { category: "travel" },
},
select: { id: true },
});
if (travelItem || reimbursement.linked_travel_request_id) {
const missingTravelFields = [
["travelPurpose", reimbursement.travel_purpose],
["travelLocation", reimbursement.travel_location],
["travelStartDate", reimbursement.travel_start_date],
["travelEndDate", reimbursement.travel_end_date],
].filter(([, value]) => !value).map(([field]) => field);
if (missingTravelFields.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { travelSummary: "incomplete", missingFields: missingTravelFields },
});
}
}
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
@@ -0,0 +1,100 @@
const crypto = require("node:crypto");
const { PDFDocument } = require("pdf-lib");
const sharp = require("sharp");
const FIXED_PDF_DATE = new Date("2000-01-01T00:00:00.000Z");
const PDF_CREATOR = "hyb-oa";
const PDF_PRODUCER = "hyb-oa reimbursement material merger";
function materialCollectionHash(materials, mergerVersion) {
const hash = crypto.createHash("sha256");
hash.update(String(mergerVersion));
for (const material of materials) {
hash.update("\0");
hash.update([
material.materialType,
material.id,
material.fileSha256 ?? "",
material.mimeType,
String(material.fileSize),
].join("\u001f"));
}
return hash.digest("hex");
}
async function normalizeImageForPdf(source, limits) {
const image = sharp(source, { failOn: "error", limitInputPixels: limits.maxImagePixels }).rotate();
const metadata = await image.metadata();
if (!metadata.width || !metadata.height || metadata.width * metadata.height > limits.maxImagePixels) {
throw new Error("material_preview_image_invalid");
}
const normalized = await image
.flatten({ background: "#ffffff" })
.jpeg({ quality: 90, chromaSubsampling: "4:4:4", mozjpeg: false })
.toBuffer();
return { buffer: normalized, width: metadata.width, height: metadata.height };
}
function fitPage(width, height) {
const maxSide = 842;
const scale = Math.min(1, maxSide / Math.max(width, height));
return { width: Math.max(1, width * scale), height: Math.max(1, height * scale) };
}
async function mergeMaterialsToPdf(materials, limits) {
const output = await PDFDocument.create();
output.setCreationDate(FIXED_PDF_DATE);
output.setModificationDate(FIXED_PDF_DATE);
output.setCreator(PDF_CREATOR);
output.setProducer(PDF_PRODUCER);
output.setTitle("Reimbursement Material Preview");
output.setSubject("Derived preview; originals retained separately");
let pageCount = 0;
for (const material of materials) {
const source = Buffer.from(material.source);
if (material.mimeType === "application/pdf") {
let input;
try {
input = await PDFDocument.load(source, { updateMetadata: false });
} catch {
throw new Error("material_preview_pdf_invalid");
}
const indices = input.getPageIndices();
if (!indices.length || pageCount + indices.length > limits.maxPages) {
throw new Error(indices.length ? "material_preview_page_limit" : "material_preview_pdf_empty");
}
const pages = await output.copyPages(input, indices);
pages.forEach((page) => output.addPage(page));
pageCount += pages.length;
continue;
}
if (pageCount + 1 > limits.maxPages) throw new Error("material_preview_page_limit");
let normalized;
try {
normalized = await normalizeImageForPdf(source, limits);
} catch (error) {
if (error?.message === "material_preview_image_invalid") throw error;
throw new Error("material_preview_image_invalid");
}
const embedded = await output.embedJpg(normalized.buffer);
const size = fitPage(normalized.width, normalized.height);
const page = output.addPage([size.width, size.height]);
page.drawImage(embedded, { x: 0, y: 0, width: size.width, height: size.height });
pageCount += 1;
}
if (!pageCount) throw new Error("material_preview_empty");
const bytes = await output.save({
useObjectStreams: true,
addDefaultPage: false,
objectsPerTick: 50,
updateFieldAppearances: false,
});
const buffer = Buffer.from(bytes);
if (buffer.length > limits.maxOutputBytes) throw new Error("material_preview_output_size_limit");
return { buffer, pageCount };
}
module.exports = { materialCollectionHash, mergeMaterialsToPdf };
@@ -1,23 +1,17 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { promisify } = require("node:util");
const { execFile } = require("node:child_process");
const sharp = require("sharp");
const execFileAsync = promisify(execFile);
const { Worker } = require("node:worker_threads");
const { materialCollectionHash } = require("./reimbursementMaterialPdf");
const MATERIAL_PREVIEW_MERGER_VERSION = "pdf-v1";
const MATERIAL_PREVIEW_LIMITS = Object.freeze({
maxMaterials: 20,
maxSourceBytes: 50 * 1024 * 1024,
maxPages: 40,
maxPageWidth: 1800,
maxPagePixels: 40_000_000,
maxOutputBytes: 40 * 1024 * 1024,
maxCacheBytes: 80 * 1024 * 1024,
rasterDpi: 144,
rasterTimeoutMs: 20_000,
maxImagePixels: 40_000_000,
maxOutputBytes: 50 * 1024 * 1024,
maxCacheBytes: 100 * 1024 * 1024,
mergeTimeoutMs: 20_000,
});
class MaterialPreviewError extends Error {
@@ -28,183 +22,137 @@ class MaterialPreviewError extends Error {
}
}
function pageNumberFromName(fileName) {
const matched = fileName.match(/-(\d+)\.png$/i);
return matched ? Number(matched[1]) : Number.MAX_SAFE_INTEGER;
}
async function normalizePreviewImage(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
try {
return await sharp(buffer, { failOn: "error", limitInputPixels: limits.maxPagePixels })
.rotate()
.resize({ width: limits.maxPageWidth, withoutEnlargement: true })
.png({ compressionLevel: 8, adaptiveFiltering: true })
.toBuffer();
} catch {
throw new MaterialPreviewError("material_preview_image_invalid");
}
}
async function rasterizePdfWithPoppler(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-material-preview-"));
const sourcePath = path.join(tempDir, "source.pdf");
const outputPrefix = path.join(tempDir, "page");
try {
await fs.writeFile(sourcePath, buffer, { flag: "wx" });
try {
await execFileAsync(
"pdftoppm",
[
"-png",
"-r",
String(limits.rasterDpi),
"-f",
"1",
"-l",
String(limits.maxPages + 1),
"-scale-to-x",
String(limits.maxPageWidth),
"-scale-to-y",
"-1",
sourcePath,
outputPrefix,
],
{
timeout: limits.rasterTimeoutMs,
maxBuffer: 1024 * 1024,
windowsHide: true,
},
);
} catch (error) {
if (error?.code === "ENOENT") {
throw new MaterialPreviewError("material_preview_rasterizer_unavailable");
function runMergeWorker(materials, limits) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, "reimbursementMaterialPreviewWorker.js"), {
workerData: { materials, limits },
});
const timeout = setTimeout(() => {
worker.terminate().catch(() => {});
reject(new MaterialPreviewError("material_preview_merge_timeout"));
}, limits.mergeTimeoutMs);
timeout.unref?.();
worker.once("message", (message) => {
clearTimeout(timeout);
if (!message?.ok) {
reject(new MaterialPreviewError(message?.code || "material_preview_merge_failed"));
return;
}
if (error?.killed || error?.signal) {
throw new MaterialPreviewError("material_preview_raster_timeout");
resolve({ buffer: Buffer.from(message.bytes), pageCount: message.pageCount });
});
worker.once("error", () => {
clearTimeout(timeout);
reject(new MaterialPreviewError("material_preview_merge_failed"));
});
worker.once("exit", (code) => {
if (code !== 0) {
clearTimeout(timeout);
reject(new MaterialPreviewError("material_preview_merge_failed"));
}
throw new MaterialPreviewError("material_preview_pdf_invalid");
}
const fileNames = (await fs.readdir(tempDir))
.filter((fileName) => /^page-\d+\.png$/i.test(fileName))
.sort((left, right) => pageNumberFromName(left) - pageNumberFromName(right));
if (!fileNames.length) {
throw new MaterialPreviewError("material_preview_pdf_empty");
}
const pages = [];
for (const fileName of fileNames) {
pages.push(await normalizePreviewImage(await fs.readFile(path.join(tempDir, fileName)), limits));
}
return pages;
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
}
function createReimbursementMaterialPreviewStore(options = {}) {
const limits = { ...MATERIAL_PREVIEW_LIMITS, ...(options.limits ?? {}) };
const rasterizePdf = options.rasterizePdf ?? ((buffer) => rasterizePdfWithPoppler(buffer, limits));
const normalizeImage = options.normalizeImage ?? ((buffer) => normalizePreviewImage(buffer, limits));
const mergePdf = options.mergePdf ?? ((materials) => runMergeWorker(materials, limits));
const now = options.now ?? (() => Date.now());
const randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
const bundles = new Map();
const grants = new Map();
const artifacts = new Map();
const pendingArtifacts = new Map();
let cachedBytes = 0;
function deleteBundle(bundleId) {
const bundle = bundles.get(bundleId);
if (!bundle) return;
cachedBytes -= bundle.outputBytes;
bundles.delete(bundleId);
const grant = grants.get(bundleId);
if (!grant) return;
grants.delete(bundleId);
const stillUsed = [...grants.values()].some((candidate) => candidate.cacheKey === grant.cacheKey);
if (!stillUsed) {
const artifact = artifacts.get(grant.cacheKey);
if (artifact) cachedBytes -= artifact.buffer.length;
artifacts.delete(grant.cacheKey);
}
}
function pruneExpired() {
const current = now();
for (const [bundleId, bundle] of bundles) {
if (bundle.expiresAt <= current) deleteBundle(bundleId);
for (const [bundleId, grant] of grants) {
if (grant.expiresAt <= current) deleteBundle(bundleId);
}
}
async function createBundle({ actorId, reimbursementId, materials, expiresAt, readSource }) {
pruneExpired();
if (!Array.isArray(materials) || materials.length === 0) {
throw new MaterialPreviewError("material_preview_empty");
}
if (materials.length > limits.maxMaterials) {
throw new MaterialPreviewError("material_preview_material_limit");
}
const sourceBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
if (!Number.isSafeInteger(sourceBytes) || sourceBytes <= 0 || sourceBytes > limits.maxSourceBytes) {
if (!Array.isArray(materials) || !materials.length) throw new MaterialPreviewError("material_preview_empty");
if (materials.length > limits.maxMaterials) throw new MaterialPreviewError("material_preview_material_limit");
const declaredBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0 || declaredBytes > limits.maxSourceBytes) {
throw new MaterialPreviewError("material_preview_source_size_limit");
}
const pages = [];
let outputBytes = 0;
let actualSourceBytes = 0;
for (const material of materials) {
const source = await readSource(material);
if (!Buffer.isBuffer(source) || source.length === 0) {
throw new MaterialPreviewError("material_preview_source_missing");
const collectionHash = materialCollectionHash(materials, MATERIAL_PREVIEW_MERGER_VERSION);
const cacheKey = `${reimbursementId}:${collectionHash}:${MATERIAL_PREVIEW_MERGER_VERSION}`;
let artifact = artifacts.get(cacheKey);
if (!artifact) {
let pending = pendingArtifacts.get(cacheKey);
if (!pending) pending = (async () => {
const loaded = [];
let actualBytes = 0;
for (const material of materials) {
const source = await readSource(material);
if (!Buffer.isBuffer(source) || !source.length) throw new MaterialPreviewError("material_preview_source_missing");
actualBytes += source.length;
if (actualBytes > limits.maxSourceBytes) throw new MaterialPreviewError("material_preview_source_size_limit");
loaded.push({ ...material, source });
}
actualSourceBytes += source.length;
if (actualSourceBytes > limits.maxSourceBytes) {
throw new MaterialPreviewError("material_preview_source_size_limit");
let generated;
try {
generated = await mergePdf(loaded);
} catch (error) {
if (error instanceof MaterialPreviewError) throw error;
throw new MaterialPreviewError(error?.message || "material_preview_merge_failed");
}
const renderedPages = material.mimeType === "application/pdf"
? await rasterizePdf(source)
: [await normalizeImage(source)];
for (let index = 0; index < renderedPages.length; index += 1) {
if (pages.length >= limits.maxPages) {
throw new MaterialPreviewError("material_preview_page_limit");
if (!Buffer.isBuffer(generated?.buffer) || !generated.buffer.length) {
throw new MaterialPreviewError("material_preview_merge_failed");
}
if (generated.buffer.length > limits.maxOutputBytes) throw new MaterialPreviewError("material_preview_output_size_limit");
if (cachedBytes + generated.buffer.length > limits.maxCacheBytes) {
pruneExpired();
if (cachedBytes + generated.buffer.length > limits.maxCacheBytes) {
throw new MaterialPreviewError("material_preview_cache_limit");
}
const image = renderedPages[index];
outputBytes += image.length;
if (outputBytes > limits.maxOutputBytes) {
throw new MaterialPreviewError("material_preview_output_size_limit");
}
pages.push({
buffer: image,
materialId: material.id,
materialType: material.materialType,
materialIndex: material.materialIndex,
pageNumber: index + 1,
});
}
}
if (cachedBytes + outputBytes > limits.maxCacheBytes) {
pruneExpired();
if (cachedBytes + outputBytes > limits.maxCacheBytes) {
throw new MaterialPreviewError("material_preview_cache_limit");
}
artifacts.set(cacheKey, generated);
cachedBytes += generated.buffer.length;
return generated;
})().finally(() => pendingArtifacts.delete(cacheKey));
pendingArtifacts.set(cacheKey, pending);
artifact = await pending;
}
const bundleId = randomUUID();
const bundle = { actorId, reimbursementId, expiresAt, outputBytes, pages };
bundles.set(bundleId, bundle);
cachedBytes += outputBytes;
const bundle = { actorId, reimbursementId, expiresAt, cacheKey, collectionHash, pageCount: artifact.pageCount };
grants.set(bundleId, bundle);
const timer = setTimeout(() => deleteBundle(bundleId), Math.max(1, expiresAt - now()));
timer.unref?.();
return { bundleId, bundle };
return { bundleId, bundle, fileSize: artifact.buffer.length };
}
function getPage({ bundleId, pageIndex }) {
function getBundle({ bundleId }) {
pruneExpired();
const bundle = bundles.get(bundleId);
if (!bundle || !Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= bundle.pages.length) {
return null;
}
return { bundle, page: bundle.pages[pageIndex] };
const bundle = grants.get(bundleId);
const artifact = bundle ? artifacts.get(bundle.cacheKey) : null;
return bundle && artifact ? { bundle, artifact } : null;
}
return { createBundle, getPage, deleteBundle };
return { createBundle, getBundle, deleteBundle };
}
module.exports = {
MATERIAL_PREVIEW_LIMITS,
MATERIAL_PREVIEW_MERGER_VERSION,
MaterialPreviewError,
createReimbursementMaterialPreviewStore,
normalizePreviewImage,
rasterizePdfWithPoppler,
runMergeWorker,
};
@@ -0,0 +1,11 @@
const { parentPort, workerData } = require("node:worker_threads");
const { mergeMaterialsToPdf } = require("./reimbursementMaterialPdf");
mergeMaterialsToPdf(workerData.materials, workerData.limits)
.then(({ buffer, pageCount }) => {
const bytes = Uint8Array.from(buffer);
parentPort.postMessage({ ok: true, bytes, pageCount }, [bytes.buffer]);
})
.catch((error) => {
parentPort.postMessage({ ok: false, code: error?.message || "material_preview_merge_failed" });
});