← back to Homesonspec
packages/schemas/src/index.ts
139 lines
import { z } from "zod";
/**
* FieldValue — the per-field evidence envelope. Every extracted fact carries
* its own provenance. `value: null` means the source did not state it;
* values are NEVER inferred or guessed.
*/
export const fieldValueSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
z.object({
value: valueSchema.nullable(),
raw: z.string().nullable(),
evidenceText: z.string().nullable(),
sourceUrl: z.string(),
confidence: z.number().min(0).max(1),
});
export type FieldValue<V> = {
value: V | null;
raw: string | null;
evidenceText: string | null;
sourceUrl: string;
confidence: number;
};
export const entityTypeSchema = z.enum([
"community",
"floor_plan",
"inventory_home",
"incentive",
"sales_office",
"lot",
]);
export type ExtractedEntityType = z.infer<typeof entityTypeSchema>;
export const canonicalHintsSchema = z.object({
builderSlug: z.string().min(1),
communityName: z.string().nullish(),
address: z.string().nullish(),
lotNumber: z.string().nullish(),
builderInventoryId: z.string().nullish(),
lat: z.number().nullish(),
lon: z.number().nullish(),
planName: z.string().nullish(),
});
export const constructionStatusSchema = z.enum([
"PLANNED",
"UNDER_CONSTRUCTION",
"MOVE_IN_READY",
]);
export const homeTypeSchema = z.enum([
"SINGLE_FAMILY",
"TOWNHOME",
"CONDO",
"DUPLEX",
"OTHER",
]);
/** Payload shape for an extracted inventory home (staged, pre-publish). */
export const inventoryHomePayloadSchema = z.object({
street: fieldValueSchema(z.string()),
city: fieldValueSchema(z.string()),
state: fieldValueSchema(z.string().length(2)),
zip: fieldValueSchema(z.string().regex(/^\d{5}$/)),
price: fieldValueSchema(z.number().positive()),
beds: fieldValueSchema(z.number().int()),
bathsTotal: fieldValueSchema(z.number()),
sqft: fieldValueSchema(z.number().int()),
stories: fieldValueSchema(z.number().int()),
garageSpaces: fieldValueSchema(z.number().int()),
homeType: fieldValueSchema(homeTypeSchema),
constructionStatus: fieldValueSchema(constructionStatusSchema),
estCompletionDate: fieldValueSchema(z.string()), // ISO date string
lotNumber: fieldValueSchema(z.string()),
builderInventoryId: fieldValueSchema(z.string()),
lat: fieldValueSchema(z.number()).optional(), // not every builder feed carries per-home geo
lon: fieldValueSchema(z.number()).optional(),
planName: fieldValueSchema(z.string()),
// Builder-supplied listing photos (largest srcset variant per asset). Optional
// so adapters that don't yet capture media still validate.
images: fieldValueSchema(z.array(z.string())).optional(),
});
export type InventoryHomePayload = z.infer<typeof inventoryHomePayloadSchema>;
/** Payload shape for an extracted community. */
export const communityPayloadSchema = z.object({
name: fieldValueSchema(z.string()),
street: fieldValueSchema(z.string()),
city: fieldValueSchema(z.string()),
state: fieldValueSchema(z.string().length(2)),
zip: fieldValueSchema(z.string().regex(/^\d{5}$/)),
county: fieldValueSchema(z.string()),
metro: fieldValueSchema(z.string()),
lat: fieldValueSchema(z.number()),
lon: fieldValueSchema(z.number()),
hoaFeeMonthly: fieldValueSchema(z.number()),
schoolDistrict: fieldValueSchema(z.string()),
ageRestricted: fieldValueSchema(z.boolean()),
salesPhone: fieldValueSchema(z.string()).optional(),
});
export type CommunityPayload = z.infer<typeof communityPayloadSchema>;
/** Payload shape for an extracted incentive. */
export const incentivePayloadSchema = z.object({
title: fieldValueSchema(z.string()),
description: fieldValueSchema(z.string()),
valueUsd: fieldValueSchema(z.number()),
expiresAt: fieldValueSchema(z.string()), // ISO date; null → evergreenLabel required
});
export type IncentivePayload = z.infer<typeof incentivePayloadSchema>;
export const extractedRecordSchema = z.object({
entityType: entityTypeSchema,
canonicalHints: canonicalHintsSchema,
fields: z.record(z.string(), fieldValueSchema(z.unknown())),
});
export type ExtractedRecord = z.infer<typeof extractedRecordSchema>;
const payloadSchemas: Partial<Record<ExtractedEntityType, z.ZodTypeAny>> = {
inventory_home: inventoryHomePayloadSchema,
community: communityPayloadSchema,
incentive: incentivePayloadSchema,
};
/** Validate a record's fields against its entity-specific payload schema. */
export function validatePayload(record: ExtractedRecord):
| { ok: true }
| { ok: false; issues: string[] } {
const schema = payloadSchemas[record.entityType];
if (!schema) return { ok: true }; // entity types without a strict payload contract yet
const parsed = schema.safeParse(record.fields);
if (parsed.success) return { ok: true };
return {
ok: false,
issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
};
}