← back to Homesonspec
packages/publisher/src/index.ts
269 lines
import { prisma, type CollectionMethod, type VerificationLabel } from "@homesonspec/database";
import { normalizeAddress } from "@homesonspec/shared";
/**
* THE ONLY WRITER OF PUBLISHED TABLES.
*
* Publishes a staged record whose status is VALIDATED (deterministic
* validators passed) or APPROVED (human review). Any other status throws.
* Collectors, extractors, and normalizers never touch published tables;
* they end at StagedRecord, and this function is the single bridge.
*
* Builder + community resolution comes from the hints captured at extract
* time. Community records publish (upsert) real Community rows, so an
* adapter's discovery order — communities before homes — makes the FK
* integrity rule pass naturally.
*/
interface Hints {
builderSlug: string;
communityName?: string | null;
address?: string | null;
lotNumber?: string | null;
builderInventoryId?: string | null;
lat?: number | null;
lon?: number | null;
planName?: string | null;
}
/** Verification label + demo flag derive from HOW the data was collected. */
export function labelForMethod(method: CollectionMethod): { label: VerificationLabel; isDemo: boolean } {
switch (method) {
case "FEED":
return { label: "VERIFIED_FROM_BUILDER", isDemo: false };
case "CRAWL":
return { label: "BUILDER_WEBSITE", isDemo: false };
case "MANUAL":
return { label: "BUILDER_SUBMITTED", isDemo: false };
case "FIXTURE":
case "SYNTHETIC":
return { label: "DEMONSTRATION", isDemo: true };
}
}
function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
export async function publishStagedRecord(stagedRecordId: string) {
const staged = await prisma.stagedRecord.findUniqueOrThrow({
where: { id: stagedRecordId },
include: { source: true },
});
if (staged.status !== "VALIDATED" && staged.status !== "APPROVED") {
throw new Error(
`refusing to publish staged record ${staged.id} with status ${staged.status} — only VALIDATED or human-APPROVED records publish`,
);
}
const payload = staged.payload as Record<string, { value: unknown; sourceUrl?: string }>;
const hints = (staged.hints ?? {}) as unknown as Hints;
const value = <T>(field: string): T | null => (payload[field]?.value ?? null) as T | null;
const { label, isDemo } = labelForMethod(staged.source.collectionMethod);
if (!hints.builderSlug) {
throw new Error(`staged record ${staged.id} has no builderSlug hint — cannot publish`);
}
const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: hints.builderSlug } });
const findCommunity = async () => {
if (!hints.communityName) throw new Error(`staged record ${staged.id} has no communityName hint`);
return prisma.community.findFirstOrThrow({
where: { name: hints.communityName, builderId: builder.id },
});
};
let publishedEntityId: string | null = null;
if (staged.entityType === "COMMUNITY") {
const name = value<string>("name");
if (!name) throw new Error(`staged community ${staged.id} has no name`);
const city = value<string>("city");
const state = value<string>("state");
const zip = value<string>("zip");
const existing = await prisma.community.findFirst({ where: { name, builderId: builder.id } });
const data = {
name,
street: value<string>("street"),
city: city ?? "",
state: state ?? "",
zip: zip ?? "",
county: value<string>("county"),
metro: value<string>("metro"),
lat: value<number>("lat"),
lon: value<number>("lon"),
hoaFeeMonthly: value<number>("hoaFeeMonthly"),
hoaRequired: value<number>("hoaFeeMonthly") !== null ? true : null,
schoolDistrict: value<string>("schoolDistrict"),
ageRestricted: value<boolean>("ageRestricted") ?? false,
salesPhone: value<string>("salesPhone") ?? undefined,
searchText: [name, city, state, zip, value<string>("metro"), value<string>("county")]
.filter(Boolean)
.join(" "),
sourceUrl: payload.name?.sourceUrl ?? null,
isDemo,
};
const community = existing
? await prisma.community.update({ where: { id: existing.id }, data })
: await prisma.community.create({
data: {
...data,
builderId: builder.id,
slug: await uniqueCommunitySlug(name, builder.slug),
},
});
publishedEntityId = community.id;
} else if (staged.entityType === "INVENTORY_HOME") {
const community = await findCommunity();
const priorPrice = await prisma.inventoryHome.findUnique({
where: { canonicalKey: staged.canonicalKey },
select: { price: true },
});
const price = value<number>("price");
const street = value<string>("street");
const home = await prisma.inventoryHome.upsert({
where: { canonicalKey: staged.canonicalKey },
create: {
canonicalKey: staged.canonicalKey,
communityId: community.id,
builderId: builder.id,
builderInventoryId: value<string>("builderInventoryId") ?? hints.builderInventoryId ?? null,
street,
city: value<string>("city") ?? community.city,
state: value<string>("state") ?? community.state,
zip: value<string>("zip") ?? community.zip,
normalizedAddress: street ? normalizeAddress(street) : null,
lotNumber: value<string>("lotNumber") ?? hints.lotNumber ?? null,
lat: value<number>("lat"),
lon: value<number>("lon"),
price,
beds: value<number>("beds"),
bathsTotal: value<number>("bathsTotal"),
sqft: value<number>("sqft"),
stories: value<number>("stories"),
garageSpaces: value<number>("garageSpaces"),
images: value<string[]>("images") ?? [],
homeType: (value<string>("homeType") as never) ?? "SINGLE_FAMILY",
constructionStatus: (value<string>("constructionStatus") as never) ?? "UNDER_CONSTRUCTION",
estCompletionDate: value<string>("estCompletionDate")
? new Date(value<string>("estCompletionDate")!)
: null,
status: "PUBLISHED",
freshness: "FRESH",
verificationLabel: label,
lastVerifiedAt: new Date(),
sourceId: staged.sourceId,
sourceUrl: payload.street?.sourceUrl ?? payload.price?.sourceUrl ?? null,
isDemo,
},
update: {
previousPrice: priorPrice?.price ?? undefined,
price,
beds: value<number>("beds"),
bathsTotal: value<number>("bathsTotal"),
sqft: value<number>("sqft"),
// Promote coords on re-extract too — the create branch wrote lat/lon but this update branch
// silently dropped them, so re-extracted homes (e.g. dr-horton's new in-feed community geo)
// never got geo. Null-coalesced: only overwrite when the new extract HAS a coord, so a later
// coordless extract never clobbers a good pin. [yolo iter-4 contrarian fix]
...(value<number>("lat") != null ? { lat: value<number>("lat"), lon: value<number>("lon") } : {}),
images: value<string[]>("images") ?? undefined,
constructionStatus: (value<string>("constructionStatus") as never) ?? undefined,
estCompletionDate: value<string>("estCompletionDate")
? new Date(value<string>("estCompletionDate")!)
: undefined,
lastVerifiedAt: new Date(),
freshness: "FRESH",
verificationLabel: label,
updatedAt: new Date(),
},
});
publishedEntityId = home.id;
if (priorPrice?.price && price !== null && Number(priorPrice.price) !== price) {
await prisma.changeEvent.create({
data: {
entityType: "INVENTORY_HOME",
entityId: home.id,
field: "price",
oldValue: String(priorPrice.price),
newValue: String(price),
sourceId: staged.sourceId,
},
});
}
} else if (staged.entityType === "INCENTIVE") {
const community = await findCommunity();
const expiresAt = value<string>("expiresAt");
const title = value<string>("title") ?? "Untitled offer";
const existing = await prisma.incentive.findFirst({
where: { communityId: community.id, title, sourceId: staged.sourceId },
});
const data = {
description: value<string>("description"),
valueUsd: value<number>("valueUsd"),
expiresAt: expiresAt ? new Date(expiresAt) : null,
// Spec rule: incentives need an expiration OR an explicit label.
evergreenLabel: expiresAt ? null : "expiration not provided",
isDemo,
};
const incentive = existing
? await prisma.incentive.update({ where: { id: existing.id }, data })
: await prisma.incentive.create({
data: {
...data,
scope: "COMMUNITY",
builderId: builder.id,
communityId: community.id,
title,
sourceId: staged.sourceId,
},
});
publishedEntityId = incentive.id;
} else if (staged.entityType === "FLOOR_PLAN") {
const community = await findCommunity();
const name = value<string>("name") ?? hints.planName;
if (!name) throw new Error(`staged floor plan ${staged.id} has no name`);
const existing = await prisma.floorPlan.findFirst({ where: { communityId: community.id, name } });
const data = {
beds: value<number>("beds"),
bathsFull: value<number>("bathsFull"),
bathsHalf: value<number>("bathsHalf"),
sqft: value<number>("sqft"),
stories: value<number>("stories"),
garageSpaces: value<number>("garageSpaces"),
basePrice: value<number>("basePrice"),
sourceUrl: payload.name?.sourceUrl ?? null,
isDemo,
};
const plan = existing
? await prisma.floorPlan.update({ where: { id: existing.id }, data })
: await prisma.floorPlan.create({
data: { ...data, communityId: community.id, builderId: builder.id, name, planMediaRights: "none" },
});
publishedEntityId = plan.id;
}
await prisma.stagedRecord.update({
where: { id: staged.id },
data: { status: "PUBLISHED", publishedEntityId },
});
// Re-point evidence at the published entity so consumer pages can cite it.
if (publishedEntityId) {
await prisma.sourceEvidence.updateMany({
where: { stagedRecordId: staged.id },
data: { entityType: staged.entityType, entityId: publishedEntityId },
});
}
return publishedEntityId;
}
async function uniqueCommunitySlug(name: string, builderSlug: string): Promise<string> {
const base = slugify(name);
if (!(await prisma.community.findUnique({ where: { slug: base } }))) return base;
const withBuilder = `${slugify(builderSlug)}-${base}`;
if (!(await prisma.community.findUnique({ where: { slug: withBuilder } }))) return withBuilder;
return `${withBuilder}-${Date.now().toString(36)}`;
}