← 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,
];