Port property assessor helpers to TypeScript

This commit is contained in:
2026-03-27 22:23:58 -05:00
parent 954374ce48
commit e6d987d725
14 changed files with 2155 additions and 202 deletions

View 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;
}