import fs from "node:fs"; import path from "node:path"; import PDFDocument from "pdfkit"; export class ReportValidationError extends Error {} export interface ReportPayload { recipientEmails?: string[] | string; assessmentPurpose?: string; reportTitle?: string; subtitle?: string; generatedAt?: string; preparedBy?: string; reportNotes?: string; subjectProperty?: Record; verdict?: Record; snapshot?: unknown; whatILike?: unknown; whatIDontLike?: unknown; compView?: unknown; carryView?: unknown; risksAndDiligence?: unknown; photoReview?: Record; publicRecords?: Record; sourceLinks?: unknown; } export function isDecisionGradeReportPayload(payload: ReportPayload): boolean { const decision = String(payload.verdict?.decision || "").trim().toLowerCase(); const fairValueRange = String(payload.verdict?.fairValueRange || "").trim().toLowerCase(); const photoReviewStatus = String(payload.photoReview?.status || "").trim().toLowerCase(); if (!decision || decision === "pending") return false; if (!fairValueRange || fairValueRange === "not established") return false; if (photoReviewStatus !== "completed") return false; return true; } function asStringArray(value: unknown): string[] { if (value == null) return []; if (typeof value === "string") { const text = value.trim(); return text ? [text] : []; } if (typeof value === "number" || typeof value === "boolean") { return [String(value)]; } if (Array.isArray(value)) { const out: string[] = []; for (const item of value) { out.push(...asStringArray(item)); } return out; } if (typeof value === "object") { return Object.entries(value as Record) .filter(([, item]) => item != null && item !== "") .map(([key, item]) => `${key}: ${item}`); } return [String(value)]; } function currency(value: unknown): string { if (value == null || value === "") return "N/A"; const num = Number(value); if (Number.isFinite(num)) return `$${num.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; return String(value); } export function validateReportPayload(payload: ReportPayload): string[] { const recipients = asStringArray(payload.recipientEmails).map((item) => item.trim()).filter(Boolean); if (!recipients.length) { throw new ReportValidationError( "Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF." ); } const address = payload.subjectProperty && typeof payload.subjectProperty.address === "string" ? payload.subjectProperty.address.trim() : ""; if (!address) { throw new ReportValidationError("The report payload must include subjectProperty.address."); } if (!isDecisionGradeReportPayload(payload)) { throw new ReportValidationError( "The report payload is still preliminary. Stop and complete the decision-grade analysis, including subject-unit photo review, before generating or sending the property assessment PDF." ); } return recipients; } function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void { const x = doc.page.margins.left; const y = doc.y; const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; const height = 18; doc.save(); doc.roundedRect(x, y, width, height, 3).fill("#123B5D"); doc .fillColor("white") .font("Helvetica-Bold") .fontSize(12) .text(title, x + 8, y + 4, { width: width - 16 }); doc.restore(); doc.moveDown(1.2); } function drawBulletList(doc: PDFKit.PDFDocument, value: unknown, fallback = "Not provided."): void { const items = asStringArray(value); if (!items.length) { doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(fallback); doc.moveDown(0.6); return; } for (const item of items) { const startY = doc.y; doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D"); doc .fillColor("#1E2329") .font("Helvetica") .fontSize(10.5) .text(item, doc.page.margins.left + 14, startY, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14, lineGap: 2 }); doc.moveDown(0.35); } doc.moveDown(0.2); } function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void { const left = doc.page.margins.left; const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const keyWidth = 150; const valueWidth = totalWidth - keyWidth; for (const [key, value] of rows) { const startY = doc.y; const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 }); const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 }); const rowHeight = Math.max(keyHeight, valueHeight) + 12; doc.save(); doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7"); doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF"); doc .lineWidth(0.5) .strokeColor("#C7D0D9") .rect(left, startY, totalWidth, rowHeight) .stroke(); doc .moveTo(left + keyWidth, startY) .lineTo(left + keyWidth, startY + rowHeight) .stroke(); doc.restore(); doc.fillColor("#1E2329").font("Helvetica-Bold").fontSize(9.5).text(key, left + 6, startY + 6, { width: keyWidth - 12 }); doc.fillColor("#1E2329").font("Helvetica").fontSize(9.5).text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 }); doc.y = startY + rowHeight; } doc.moveDown(0.8); } function drawVerdictPanel(doc: PDFKit.PDFDocument, verdict: Record | undefined): void { const decision = String(verdict?.decision || "pending").trim().toLowerCase(); const badgeColor = decision === "buy" ? "#1E6B52" : decision === "pass" ? "#8B2E2E" : "#7A5D12"; const left = doc.page.margins.left; const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; const top = doc.y; const bodyText = String( verdict?.offerGuidance || "Offer guidance not provided." ); const bodyHeight = doc.heightOfString(bodyText, { width: width - 20 }) + 16; doc.save(); doc.roundedRect(left, top, width, 26, 4).fill(badgeColor); doc .fillColor("white") .font("Helvetica-Bold") .fontSize(12) .text(String(verdict?.decision || "N/A").toUpperCase(), left + 10, top + 7, { width: width - 20 }); doc .roundedRect(left, top + 26, width, bodyHeight, 4) .fillAndStroke("#F4F6F8", "#C7D0D9"); doc .fillColor("#1E2329") .font("Helvetica") .fontSize(10.5) .text(bodyText, left + 10, top + 36, { width: width - 20, lineGap: 2 }); doc.restore(); doc.y = top + 26 + bodyHeight + 10; } function drawLinks(doc: PDFKit.PDFDocument, value: unknown): void { const items = Array.isArray(value) ? value : []; if (!items.length) { drawBulletList(doc, [], "Not provided."); return; } for (const item of items as Array>) { const label = typeof item.label === "string" ? item.label : "Link"; const url = typeof item.url === "string" ? item.url : ""; const line = url ? `${label}: ${url}` : label; const startY = doc.y; doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D"); doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5); if (url) { doc.text(line, doc.page.margins.left + 14, startY, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14, lineGap: 2, link: url, underline: true }); } else { doc.text(line, doc.page.margins.left + 14, startY, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14, lineGap: 2 }); } doc.moveDown(0.35); } doc.moveDown(0.2); } export async function renderReportPdf( payload: ReportPayload, outputPath: string ): Promise { const recipients = validateReportPayload(payload); await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); const doc = new PDFDocument({ size: "LETTER", margin: 50, info: { Title: payload.reportTitle || "Property Assessment Report", Author: String(payload.preparedBy || "OpenClaw property-assessor") } }); const stream = fs.createWriteStream(outputPath); doc.pipe(stream); const generatedAt = payload.generatedAt || new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }); const subject = payload.subjectProperty || {}; const verdict = payload.verdict || {}; const publicRecords = payload.publicRecords || {}; doc.fillColor("#123B5D").font("Helvetica-Bold").fontSize(22).text(payload.reportTitle || "Property Assessment Report"); doc.moveDown(0.2); doc.fillColor("#1E2329").font("Helvetica").fontSize(11).text( String( payload.subtitle || "Decision-grade acquisition review with listing, public-record, comp, and risk analysis." ) ); doc.moveDown(0.4); doc.fillColor("#5A6570").font("Helvetica").fontSize(9); doc.text(`Prepared for: ${recipients.join(", ")}`); doc.text(`Generated: ${generatedAt}`); doc.moveDown(0.8); drawVerdictPanel(doc, verdict); drawKeyValueTable(doc, [ ["Address", String(subject.address || "N/A")], ["Assessment Purpose", String(payload.assessmentPurpose || "N/A")], ["Ask / Last Price", currency(subject.listingPrice)], ["Type", String(subject.propertyType || "N/A")], ["Beds / Baths", `${subject.beds ?? "N/A"} / ${subject.baths ?? "N/A"}`], ["Sqft", String(subject.squareFeet ?? "N/A")], ["Year Built", String(subject.yearBuilt ?? "N/A")], ["Verdict", String(verdict.decision || "N/A")], ["Fair Value Range", String(verdict.fairValueRange || "N/A")], ["Public-Record Jurisdiction", String(publicRecords.jurisdiction || "N/A")], ["Assessed Total", currency(publicRecords.assessedTotalValue)] ]); const sections: Array<[string, unknown, "list" | "links"]> = [ ["Snapshot", payload.snapshot, "list"], ["What I Like", payload.whatILike, "list"], ["What I Do Not Like", payload.whatIDontLike, "list"], ["Comp View", payload.compView, "list"], ["Underwriting / Carry View", payload.carryView, "list"], ["Risks and Diligence Items", payload.risksAndDiligence, "list"], [ "Photo Review", [ ...(asStringArray((payload.photoReview || {}).status ? [`Photo review: ${String((payload.photoReview || {}).status)}${(payload.photoReview || {}).source ? ` via ${String((payload.photoReview || {}).source)}` : ""}`] : [])), ...asStringArray((payload.photoReview || {}).attempts), ...asStringArray((payload.photoReview || {}).summary ? [`Condition read: ${String((payload.photoReview || {}).summary)}`] : []) ], "list" ], [ "Public Records", [ ...asStringArray({ Jurisdiction: publicRecords.jurisdiction, "Account Number": publicRecords.accountNumber, "Owner Name": publicRecords.ownerName, "Land Value": publicRecords.landValue != null ? currency(publicRecords.landValue) : undefined, "Improvement Value": publicRecords.improvementValue != null ? currency(publicRecords.improvementValue) : undefined, "Assessed Total": publicRecords.assessedTotalValue != null ? currency(publicRecords.assessedTotalValue) : undefined, Exemptions: publicRecords.exemptions }), ...asStringArray((publicRecords.links || []).map((item: any) => `${item.label}: ${item.url}`)) ], "list" ], ["Source Links", payload.sourceLinks, "links"] ]; for (const [title, content, kind] of sections) { if (doc.y > 660) doc.addPage(); drawSectionHeader(doc, title); if (kind === "links") { drawLinks(doc, content); } else { drawBulletList(doc, content); } } doc.addPage(); drawSectionHeader(doc, "Report Notes"); doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text( String( payload.reportNotes || "This report uses the property-assessor fixed PDF template. Listing data should be reconciled against official public records when available, and public-record links should be included in any delivered report." ), { lineGap: 3 } ); doc.end(); await new Promise((resolve, reject) => { stream.on("finish", () => resolve()); stream.on("error", reject); }); return outputPath; } export async function loadReportPayload(inputPath: string): Promise { return JSON.parse(await fs.promises.readFile(inputPath, "utf8")) as ReportPayload; }