← 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)}`;
}