Port property assessor helpers to TypeScript
This commit is contained in:
343
skills/property-assessor/src/report-pdf.ts
Normal file
343
skills/property-assessor/src/report-pdf.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
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;
|
||||
reportTitle?: string;
|
||||
subtitle?: string;
|
||||
generatedAt?: string;
|
||||
preparedBy?: string;
|
||||
reportNotes?: string;
|
||||
subjectProperty?: Record<string, unknown>;
|
||||
verdict?: Record<string, unknown>;
|
||||
snapshot?: unknown;
|
||||
whatILike?: unknown;
|
||||
whatIDontLike?: unknown;
|
||||
compView?: unknown;
|
||||
carryView?: unknown;
|
||||
risksAndDiligence?: unknown;
|
||||
photoReview?: Record<string, unknown>;
|
||||
publicRecords?: Record<string, unknown>;
|
||||
sourceLinks?: unknown;
|
||||
}
|
||||
|
||||
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<string, unknown>)
|
||||
.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.");
|
||||
}
|
||||
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<string, unknown> | 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<Record<string, unknown>>) {
|
||||
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<string> {
|
||||
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")],
|
||||
["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<void>((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
export async function loadReportPayload(inputPath: string): Promise<ReportPayload> {
|
||||
return JSON.parse(await fs.promises.readFile(inputPath, "utf8")) as ReportPayload;
|
||||
}
|
||||
Reference in New Issue
Block a user