Block preliminary property assessor PDFs

This commit is contained in:
2026-03-28 00:34:28 -05:00
parent c68523386d
commit 761bd2f083
6 changed files with 66 additions and 7 deletions

View File

@@ -78,7 +78,8 @@ Current behavior:
- returns a structured preliminary report payload
- does not require recipient email(s) for the analysis-only run
- asks for recipient email(s) only when PDF rendering is explicitly requested
- renders the fixed-template PDF when recipient email(s) are present
- does not render/send the PDF from a preliminary helper payload with `decision: pending`
- only renders the fixed-template PDF after a decision-grade verdict and fair-value range are actually present
Important limitation:

View File

@@ -96,6 +96,8 @@ scripts/property-assessor render-report --input "<report-payload-json>" --output
- complete the analysis without requiring recipient email(s)
- only stop and ask for recipient email(s) when the user is explicitly rendering or sending the PDF
- render the PDF only after recipient email(s) are known
- do **not** render or send a PDF from the helper's preliminary payload while verdict is still `pending` or fair value is not established
- if comps, valuation, or decision-grade condition interpretation are still incomplete, return the preliminary payload and say that the PDF/send step must wait
## Public-record enrichment

View File

@@ -4,7 +4,11 @@ import path from "node:path";
import { discoverListingSources, type ListingDiscoveryResult } from "./listing-discovery.js";
import { extractPhotoData, type PhotoExtractionResult, type PhotoSource } from "./photo-review.js";
import { resolvePublicRecords, type PublicRecordsResolution } from "./public-records.js";
import { renderReportPdf, type ReportPayload } from "./report-pdf.js";
import {
isDecisionGradeReportPayload,
renderReportPdf,
type ReportPayload
} from "./report-pdf.js";
export interface AssessPropertyOptions {
address: string;
@@ -442,6 +446,20 @@ export async function assessProperty(
};
}
if (!isDecisionGradeReportPayload(reportPayload)) {
return {
ok: true,
needsAssessmentPurpose: false,
needsRecipientEmails: false,
pdfReady: false,
message:
"The report payload is still preliminary. Do not render or send the PDF until comps, valuation, and a decision-grade verdict are completed.",
outputPath: null,
reportPayload,
publicRecords
};
}
const outputPath =
options.output ||
path.join(

View File

@@ -25,6 +25,14 @@ export interface ReportPayload {
sourceLinks?: unknown;
}
export function isDecisionGradeReportPayload(payload: ReportPayload): boolean {
const decision = String(payload.verdict?.decision || "").trim().toLowerCase();
const fairValueRange = String(payload.verdict?.fairValueRange || "").trim().toLowerCase();
if (!decision || decision === "pending") return false;
if (!fairValueRange || fairValueRange === "not established") return false;
return true;
}
function asStringArray(value: unknown): string[] {
if (value == null) return [];
if (typeof value === "string") {
@@ -69,6 +77,11 @@ export function validateReportPayload(payload: ReportPayload): string[] {
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 before generating or sending the property assessment PDF."
);
}
return recipients;
}

View File

@@ -198,8 +198,9 @@ test("assessProperty falls back to HAR when Zillow photo extraction fails", asyn
);
});
test("assessProperty renders a PDF when recipient email is present", async () => {
test("assessProperty does not render a PDF from a preliminary helper payload even when recipient email is present", async () => {
const outputPath = path.join(os.tmpdir(), `property-assess-command-${Date.now()}.pdf`);
let renderCalls = 0;
const result = await assessProperty(
{
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
@@ -213,16 +214,21 @@ test("assessProperty renders a PDF when recipient email is present", async () =>
attempts: ["No listing sources discovered from the address."],
zillowUrl: null,
harUrl: null
})
}),
renderReportPdfFn: async () => {
renderCalls += 1;
return outputPath;
}
}
);
assert.equal(result.ok, true);
assert.equal(result.needsAssessmentPurpose, false);
assert.equal(result.needsRecipientEmails, false);
assert.equal(result.outputPath, outputPath);
const stat = await fs.promises.stat(outputPath);
assert.ok(stat.size > 1000);
assert.equal(renderCalls, 0);
assert.equal(result.pdfReady, false);
assert.equal(result.outputPath, null);
assert.match(result.message, /preliminary|decision-grade|cannot render/i);
});
test("assessProperty prioritizes student housing guidance over investment fallback keywords", async () => {

View File

@@ -68,3 +68,22 @@ test("renderReportPdf requires recipient email", async () => {
ReportValidationError
);
});
test("renderReportPdf rejects a preliminary report with pending verdict", async () => {
const outputPath = path.join(os.tmpdir(), `property-assessor-preliminary-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderReportPdf(
{
...samplePayload,
verdict: {
decision: "pending",
fairValueRange: "Not established",
offerGuidance: "Still needs comps and decision-grade analysis."
}
},
outputPath
),
/decision-grade|preliminary|pending/i
);
});