feat(flight-finder): implement milestone M2 - report workflow and delivery gates

This commit is contained in:
2026-03-30 17:00:09 -05:00
parent ba5b0e4e67
commit c30ad85e0d
20 changed files with 1050 additions and 24 deletions

View File

@@ -0,0 +1,94 @@
import fs from "node:fs";
import path from "node:path";
import PDFDocument from "pdfkit";
import { getFlightReportStatus } from "./report-status.js";
import { normalizeFlightReportRequest } from "./request-normalizer.js";
import type { FlightReportPayload } from "./types.js";
export class ReportValidationError extends Error {}
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
return value?.length ? value : [fallback];
}
export async function renderFlightReportPdf(
payload: FlightReportPayload,
outputPath: string
): Promise<string> {
const status = getFlightReportStatus(
normalizeFlightReportRequest(payload.request),
payload
);
if (!status.pdfReady) {
throw new ReportValidationError(
"The flight report payload is still incomplete. Finish the report before generating the PDF."
);
}
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const doc = new PDFDocument({
size: "LETTER",
margin: 50
});
const stream = fs.createWriteStream(outputPath);
doc.pipe(stream);
try {
doc.fontSize(20).text(payload.request.tripName || "Flight report", {
align: "left"
});
doc.moveDown(0.25);
doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`);
doc.fillColor("#000");
doc.moveDown();
doc.fontSize(14).text("Executive Summary");
bulletLines(payload.executiveSummary).forEach((line) => {
doc.fontSize(10).text(`${line}`);
});
doc.moveDown();
doc.fontSize(14).text("Recommended Options");
payload.rankedOptions.forEach((option) => {
doc.fontSize(11).text(
`${option.backup ? "Backup" : "Primary"}: ${option.title}$${option.totalPriceUsd.toFixed(2)}`
);
doc.fontSize(10).text(option.rationale);
doc.moveDown(0.5);
});
doc.moveDown();
doc.fontSize(14).text("Source Findings");
payload.sourceFindings.forEach((finding) => {
doc.fontSize(11).text(`${finding.source}: ${finding.status}`);
bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`${line}`));
doc.moveDown(0.25);
});
doc.moveDown();
doc.fontSize(14).text("Warnings And Degraded Conditions");
bulletLines(
[...payload.reportWarnings, ...payload.degradedReasons],
"No additional warnings."
).forEach((line) => {
doc.fontSize(10).text(`${line}`);
});
doc.end();
await new Promise<void>((resolve, reject) => {
stream.on("finish", () => resolve());
stream.on("error", reject);
});
} catch (error) {
stream.destroy();
await fs.promises.unlink(outputPath).catch(() => {});
throw error;
}
return outputPath;
}