← back to Homesonspec
packages/validation/src/rules.ts
212 lines
import { latLonInState, stateForZip } from "@homesonspec/shared";
import { fieldValue, type StagedCandidate, type ValidationRule } from "./types";
export const VALIDATOR_VERSION = "1.0.0";
/** Configurable market bounds — a $12k or $50M "home" is an extraction error. */
export const PRICE_BOUNDS = { min: 80_000, max: 5_000_000 };
const homeOnly: StagedCandidate["entityType"][] = ["inventory_home"];
export const priceWithinBounds: ValidationRule = {
id: "price.within-bounds",
severity: "error",
entityTypes: homeOnly,
check(record) {
const price = fieldValue<number>(record, "price");
if (price === null) return { passed: true }; // null price is legal (never guessed); rendering handles it
if (price < PRICE_BOUNDS.min || price > PRICE_BOUNDS.max) {
return {
passed: false,
message: `price ${price} outside market bounds [${PRICE_BOUNDS.min}, ${PRICE_BOUNDS.max}]`,
details: { price },
};
}
return { passed: true };
},
};
export const stateZipAgreement: ValidationRule = {
id: "geo.state-zip-agreement",
severity: "error",
entityTypes: ["inventory_home", "community"],
check(record) {
const state = fieldValue<string>(record, "state");
const zip = fieldValue<string>(record, "zip");
if (!state || !zip) return { passed: true }; // missing data handled elsewhere
const expected = stateForZip(zip);
if (expected === null) return { passed: true }; // unknown prefix → cannot verify, don't guess
if (expected !== state.toUpperCase()) {
return {
passed: false,
message: `zip ${zip} implies state ${expected}, record says ${state}`,
details: { zip, state, expected },
};
}
return { passed: true };
},
};
export const latLonInStateRule: ValidationRule = {
id: "geo.latlon-in-state",
severity: "error",
entityTypes: ["inventory_home", "community"],
check(record) {
const state = fieldValue<string>(record, "state");
const lat = fieldValue<number>(record, "lat");
const lon = fieldValue<number>(record, "lon");
if (!state || lat === null || lon === null) return { passed: true };
const inside = latLonInState(lat, lon, state);
if (inside === null) return { passed: true }; // no bbox for state → cannot verify
if (!inside) {
return {
passed: false,
message: `coordinates (${lat}, ${lon}) fall outside ${state}`,
details: { lat, lon, state },
};
}
return { passed: true };
},
};
export const plausibleBeds: ValidationRule = {
id: "home.plausible-beds",
severity: "error",
entityTypes: homeOnly,
check(record) {
const beds = fieldValue<number>(record, "beds");
if (beds === null) return { passed: true };
if (beds < 1 || beds > 8 || !Number.isInteger(beds)) {
return { passed: false, message: `implausible bedroom count ${beds}`, details: { beds } };
}
return { passed: true };
},
};
export const plausibleBaths: ValidationRule = {
id: "home.plausible-baths",
severity: "error",
entityTypes: homeOnly,
check(record) {
const baths = fieldValue<number>(record, "bathsTotal");
if (baths === null) return { passed: true };
if (baths < 0.5 || baths > 10) {
return { passed: false, message: `implausible bathroom count ${baths}`, details: { baths } };
}
return { passed: true };
},
};
export const sqftPositive: ValidationRule = {
id: "home.sqft-positive",
severity: "error",
entityTypes: homeOnly,
check(record) {
const sqft = fieldValue<number>(record, "sqft");
if (sqft === null) return { passed: true };
if (sqft < 400 || sqft > 15_000) {
return { passed: false, message: `implausible square footage ${sqft}`, details: { sqft } };
}
return { passed: true };
},
};
export const noDuplicateActiveAddress: ValidationRule = {
id: "dup.no-active-address",
severity: "error",
entityTypes: homeOnly,
async check(record, ctx) {
const address = record.canonicalHints.address;
if (!address) return { passed: true };
const { normalizeAddress } = await import("@homesonspec/shared");
const normalized = normalizeAddress(address);
if (!normalized) return { passed: true };
// Scoped to the same zip — an identical street string in another city
// is a coincidence, not a duplicate of the same physical home.
const zip = fieldValue<string>(record, "zip");
const exists = await ctx.db.activeHomeExistsAtAddress(normalized, zip, record.canonicalKey);
if (exists) {
return {
passed: false,
message: `another active inventory record already exists at "${normalized}" (zip ${zip ?? "unknown"})`,
details: { normalizedAddress: normalized, zip },
};
}
return { passed: true };
},
};
export const fkIntegrity: ValidationRule = {
id: "ref.fk-integrity",
severity: "error",
entityTypes: homeOnly,
async check(record, ctx) {
const builderSlug = record.canonicalHints.builderSlug;
const communityName = record.canonicalHints.communityName;
if (!(await ctx.db.builderExists(builderSlug))) {
return { passed: false, message: `builder "${builderSlug}" does not exist`, details: { builderSlug } };
}
if (communityName && !(await ctx.db.communityExists(communityName, builderSlug))) {
return {
passed: false,
message: `community "${communityName}" does not exist for builder "${builderSlug}"`,
details: { communityName, builderSlug },
};
}
return { passed: true };
},
};
/** Large price movement is suspicious — publishable only after human review. */
export const BIG_PRICE_CHANGE_PCT = 0.15;
export const bigPriceChange: ValidationRule = {
id: "price.big-change",
severity: "review",
entityTypes: homeOnly,
async check(record, ctx) {
const price = fieldValue<number>(record, "price");
if (price === null) return { passed: true };
const previous = await ctx.db.publishedPriceForCanonicalKey(record.canonicalKey);
if (previous === null || previous === 0) return { passed: true };
const change = Math.abs(price - previous) / previous;
if (change > BIG_PRICE_CHANGE_PCT) {
return {
passed: false,
message: `price moved ${(change * 100).toFixed(1)}% (${previous} → ${price}) — review required`,
details: { previous, price, changePct: change },
};
}
return { passed: true };
},
};
export const incentiveExpirationOrLabel: ValidationRule = {
id: "incentive.expiration-or-label",
severity: "error",
entityTypes: ["incentive"],
check(record) {
const expiresAt = fieldValue<string>(record, "expiresAt");
// A null expiration is allowed ONLY because publish maps it to the
// explicit "expiration not provided" evergreen label. What is illegal
// is a present-but-unparseable date.
if (expiresAt !== null && Number.isNaN(Date.parse(expiresAt))) {
return { passed: false, message: `unparseable incentive expiration "${expiresAt}"`, details: { expiresAt } };
}
return { passed: true };
},
};
export const ALL_RULES: ValidationRule[] = [
priceWithinBounds,
stateZipAgreement,
latLonInStateRule,
plausibleBeds,
plausibleBaths,
sqftPositive,
noDuplicateActiveAddress,
fkIntegrity,
bigPriceChange,
incentiveExpirationOrLabel,
];