← back to Nationalrealestate
src/server/listing-lifecycle.ts
326 lines
/**
* TK-10155: listing lifecycle state model + detector/recorder.
*
* Classifies every broker listing into a lifecycle state, records each state TRANSITION
* into the append-only listing_lifecycle_event log, and — critically — PRESERVES a FULL
* listing_snapshot the moment a listing becomes withdrawn/closed/stale (or on its very first
* 'new' event), so the property data survives after the listing vanishes from the vendor feed.
*
* Append-only discipline mirrors broker-of-record.ts (migration 013): a new event is written
* only when the derived state DIFFERS from the latest observation for that listing (or none
* exists). Unchanged state = no-op, so it's safe to run repeatedly (idempotent).
*
* Callable both from server handlers and from ingest/backfill/sweep scripts — takes a db
* `query` handle so it doesn't hard-import a pool.
*
* "gone from feed" = a listing's last_seen is older than the source's MOST-RECENT successful
* ingest (the per-source max last_seen) by >= a threshold. That max is the ingest reference —
* it means "the feed was refreshed at time T, and this listing wasn't in it."
*/
import { computeAddressKey } from './broker-of-record.ts';
type QueryFn = <T extends Record<string, any> = any>(
text: string,
params?: unknown[],
) => Promise<{ rows: T[] }>;
// ── Tunable thresholds (named constants) ────────────────────────────────────
/** first_seen within this many days AND still currently seen ⇒ 'new' */
export const NEW_DAYS = 7;
/** not seen for >= this (but < WITHDRAWN_DAYS) ⇒ 'stale' */
export const STALE_DAYS = 14;
/** not seen for >= this ⇒ 'withdrawn' (gone from the feed → pulled) */
export const WITHDRAWN_DAYS = 30;
const DAY_MS = 86_400_000;
export type LifecycleState = 'new' | 'active' | 'stale' | 'withdrawn' | 'closed' | 'reappeared';
// status-string classifiers
const CLOSED_RE = /sold|closed|off.?market.*sold/i;
const WITHDRAWN_STATUS_RE = /withdrawn|cancell?ed|expired/i;
export interface LifecycleListing {
id?: number | null;
source: string;
source_id: string;
address?: string | null;
price?: number | string | null;
status?: string | null;
first_seen?: string | Date | null;
last_seen?: string | Date | null;
}
export interface LifecycleCtx {
/** per-source MOST-RECENT successful ingest reference (max last_seen for the source). */
sourceMaxLastSeen: Date;
/**
* TK-10155 fix 1 (false-withdrawal guard): the start time of the source's most-recent FULL
* rescan (MAX(started_at) over source_ingest_run WHERE mode='full'), or null if the source
* has NEVER had a full rescan (e.g. an incremental-only source like ColdwellBanker's new-day
* sitemap). Gone-from-feed → stale/withdrawn is ONLY derivable when a full rescan happened
* AFTER the listing's last_seen — otherwise absence from the feed is structural, not a pull.
* When null (or older than the listing's last_seen), gone-from-feed is suppressed and the
* listing stays active/new. Status-based withdrawn/closed is unaffected. Undefined = the
* legacy behavior (no guard), so old callers still compile.
*/
lastFullRescanAt?: Date | null;
/** "now" reference — defaults to new Date(); injectable for tests. */
now?: Date;
}
function toDate(v: string | Date | null | undefined): Date | null {
if (v == null) return null;
const d = v instanceof Date ? v : new Date(v);
return isNaN(d.getTime()) ? null : d;
}
/**
* classifyState — the pure state model. Returns one of new|active|stale|withdrawn|closed.
* ('reappeared' is a transition emitted by the recorder, not by pure classification.)
*
* - closed: status looks transacted (sold / closed / off-market-sold).
* - withdrawn: status looks withdrawn/cancelled/expired, OR gone from the feed
* (last_seen older than the source's max last_seen by >= WITHDRAWN_DAYS).
* - stale: gone from the feed by >= STALE_DAYS but < WITHDRAWN_DAYS.
* - new: first_seen within NEW_DAYS AND still currently seen.
* - active: currently seen and not new.
*/
export function classifyState(listing: LifecycleListing, ctx: LifecycleCtx): LifecycleState {
const status = (listing.status || '').trim();
// 1. transacted wins outright.
if (status && CLOSED_RE.test(status)) return 'closed';
// 2. explicit withdrawn/cancelled/expired status.
if (status && WITHDRAWN_STATUS_RE.test(status)) return 'withdrawn';
const ref = ctx.sourceMaxLastSeen;
const lastSeen = toDate(listing.last_seen);
const firstSeen = toDate(listing.first_seen);
const now = ctx.now ?? new Date();
// gone-from-feed age: how far behind the source's most-recent ingest this listing's
// last_seen sits. A listing seen in the latest ingest has age ~0 ⇒ currently seen.
const goneDays = lastSeen ? (ref.getTime() - lastSeen.getTime()) / DAY_MS : Infinity;
// TK-10155 fix 1: only allow gone-from-feed → stale/withdrawn if the source was FULLY
// re-scanned AFTER this listing's last_seen. Incremental-only sources (no full rescan, or
// the last full rescan predates last_seen) CANNOT prove the listing is gone — the feed just
// never re-surfaces it — so we must NOT fabricate a withdrawal. `undefined` = legacy callers
// with no guard (unchanged behavior); an explicit `null` = "no full rescan ever" (suppress).
const eligibleForGoneFromFeed =
ctx.lastFullRescanAt === undefined
? true // legacy path — no ingest-mode signal supplied
: ctx.lastFullRescanAt != null && lastSeen != null && ctx.lastFullRescanAt >= lastSeen;
if (eligibleForGoneFromFeed) {
if (goneDays >= WITHDRAWN_DAYS) return 'withdrawn';
if (goneDays >= STALE_DAYS) return 'stale';
}
// currently seen in (or very near) the latest ingest window.
const newAgeDays = firstSeen ? (now.getTime() - firstSeen.getTime()) / DAY_MS : Infinity;
if (newAgeDays <= NEW_DAYS) return 'new';
return 'active';
}
/** which states preserve a full snapshot when we transition INTO them */
const SNAPSHOT_STATES = new Set<LifecycleState>(['withdrawn', 'closed', 'stale', 'new']);
const norm = (v: unknown) => (v == null || v === '' ? null : v);
/**
* Build the FULL snapshot JSONB for a listing: the entire listing row + joined broker_name,
* firm_name, region/county name + state_code. Read straight from the DB so we capture every
* field available, not just what the caller happened to pass.
*/
async function buildSnapshot(db: QueryFn, listingId: number): Promise<Record<string, any> | null> {
const r = await db<Record<string, any>>(
`SELECT l.*,
b.name AS broker_name,
f.name AS firm_name,
rg.name AS region_name,
rg.name AS county_name,
rg.state_code AS region_state_code
FROM listing l
LEFT JOIN broker b ON b.id = l.broker_id
LEFT JOIN firm f ON f.id = l.firm_id
LEFT JOIN region rg ON rg.id = l.region_id
WHERE l.id = $1`,
[listingId],
);
return r.rows[0] ?? null;
}
/**
* TK-10155 (Cody FIX-FIRST gate): guarantee EVERY listing has at least one preserved snapshot,
* so preservation is a property of the DESIGN — not a side-effect of "nothing ever deletes a
* listing." Without this, a listing whose first-see snapshot hook FAILED (caught+logged) and
* that then derives straight to 'active' (e.g. first_seen already > NEW_DAYS) sits in the table
* with NO snapshot at all; if a future prune job ever hard-deletes it, its data is lost forever
* (the FK just SET-NULLs an event row that points at nothing). The sweep calls this for every
* listing: if the listing has ZERO snapshots, capture one 'baseline'. Cheap NOT-EXISTS guard,
* idempotent (no-op once any snapshot exists), append-only. Returns true iff it wrote one.
*/
export async function ensureBaselineSnapshot(
db: QueryFn,
listing: LifecycleListing,
): Promise<boolean> {
if (listing.id == null) return false;
const exists = await db<{ n: string }>(
`SELECT count(*)::text n FROM listing_snapshot WHERE source = $1 AND source_id = $2`,
[listing.source, listing.source_id],
);
if (Number(exists.rows[0]?.n ?? '0') > 0) return false; // already preserved — nothing to do
const snap = await buildSnapshot(db, Number(listing.id));
if (!snap) return false;
await db(
`INSERT INTO listing_snapshot
(listing_id, source, source_id, address_key, snapshot, captured_reason)
VALUES ($1,$2,$3,$4,$5,'baseline')`,
[
Number(listing.id),
listing.source,
listing.source_id,
computeAddressKey(listing.address, null) || null,
JSON.stringify(snap),
],
);
return true;
}
export interface RecordLifecycleResult {
state: LifecycleState;
prev_state: LifecycleState | null;
eventInserted: boolean;
snapshotInserted: boolean;
}
/**
* recordLifecycle — compute the current state for a listing; read its latest lifecycle_event;
* if the state CHANGED (or no event exists) INSERT a new lifecycle_event (append-only) with
* prev_state + reason; AND when transitioning INTO withdrawn|closed|stale|new INSERT a full
* listing_snapshot preserving the property info. Idempotent — unchanged state is a no-op.
*
* A 'reappeared' event is emitted when a listing that was previously stale/withdrawn is seen
* again (returns to active/new); no snapshot is captured for reappearance (it's coming BACK,
* not being lost).
*/
export interface RecordLifecycleOpts {
/**
* TK-10155 fix 4: when true, still write the lifecycle EVENT but SKIP the state-triggered
* snapshot. The backfill uses this because it already lays down one 'baseline' snapshot per
* listing itself — without this, a listing deriving to 'new' would get a SECOND ('new')
* snapshot, inflating the store (599 listings → 1026 snapshots pre-fix). Normal ingest/sweep
* callers leave this false so real transitions (withdrawn/closed/stale) still snapshot.
*/
suppressSnapshot?: boolean;
}
export async function recordLifecycle(
db: QueryFn,
listing: LifecycleListing,
ctx: LifecycleCtx,
opts: RecordLifecycleOpts = {},
): Promise<RecordLifecycleResult> {
let state = classifyState(listing, ctx);
// latest event for this listing (by source/source_id — stable even if listing_id is null).
const prevRes = await db<{ state: LifecycleState }>(
`SELECT state FROM listing_lifecycle_event
WHERE source = $1 AND source_id = $2
ORDER BY observed_at DESC, id DESC
LIMIT 1`,
[listing.source, listing.source_id],
);
const prevState: LifecycleState | null = prevRes.rows[0]?.state ?? null;
// 'reappeared' — was gone (stale/withdrawn), now current again (active/new).
if (prevState && (prevState === 'stale' || prevState === 'withdrawn')
&& (state === 'active' || state === 'new')) {
state = 'reappeared';
}
const result: RecordLifecycleResult = {
state,
prev_state: prevState,
eventInserted: false,
snapshotInserted: false,
};
// unchanged state ⇒ no-op (append-only, only records transitions).
if (prevState === state) return result;
const addressKey = computeAddressKey(listing.address, null) || null;
const price = norm(listing.price) as number | string | null;
const reason = reasonFor(state, listing, ctx);
await db(
`INSERT INTO listing_lifecycle_event
(listing_id, source, source_id, address_key, state, prev_state, price, reason)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
[
norm(listing.id) ?? null,
listing.source,
listing.source_id,
addressKey,
state,
prevState,
price,
reason,
],
);
result.eventInserted = true;
// preserve a FULL snapshot on transitions INTO a snapshot state (incl. first-ever 'new').
// 'reappeared' is intentionally NOT a snapshot state — the listing is coming back, not lost.
// fix 4: opts.suppressSnapshot lets the backfill skip this (it writes its own baseline snapshot).
if (!opts.suppressSnapshot && SNAPSHOT_STATES.has(state) && listing.id != null) {
const snap = await buildSnapshot(db, Number(listing.id));
if (snap) {
await db(
`INSERT INTO listing_snapshot
(listing_id, source, source_id, address_key, snapshot, captured_reason)
VALUES ($1,$2,$3,$4,$5,$6)`,
[
Number(listing.id),
listing.source,
listing.source_id,
addressKey,
JSON.stringify(snap),
state, // captured_reason mirrors the state that triggered preservation
],
);
result.snapshotInserted = true;
}
}
return result;
}
/** human-readable reason string for an event, tuned per state. */
function reasonFor(state: LifecycleState, listing: LifecycleListing, ctx: LifecycleCtx): string {
const status = (listing.status || '').trim();
const lastSeen = toDate(listing.last_seen);
const goneDays = lastSeen
? Math.round((ctx.sourceMaxLastSeen.getTime() - lastSeen.getTime()) / DAY_MS)
: null;
switch (state) {
case 'closed':
return status ? `status="${status}" matched closed/sold` : 'transacted';
case 'withdrawn':
return status && WITHDRAWN_STATUS_RE.test(status)
? `status="${status}" matched withdrawn/cancelled/expired`
: `gone from feed ${goneDays ?? '?'}d (>= ${WITHDRAWN_DAYS}d)`;
case 'stale':
return `not seen ${goneDays ?? '?'}d (>= ${STALE_DAYS}d, < ${WITHDRAWN_DAYS}d)`;
case 'new':
return `first seen within ${NEW_DAYS}d and currently listed`;
case 'reappeared':
return 'seen again in feed after being stale/withdrawn';
case 'active':
default:
return 'currently listed';
}
}