← back to Nationalrealestate
db/migrations/014_listing_lifecycle.sql
74 lines
-- TK-10155: listing-lifecycle tracking + withdrawal-snapshot preservation.
--
-- Two append-only tables + one "latest state" view. Built on the same discipline as
-- broker_of_record_history (migration 013): NOTHING here mutates existing listing/catalog
-- data — new tables/view only, all CREATE ... IF NOT EXISTS so the migration is idempotent
-- and reversible. No BEGIN/COMMIT here — migrate.ts wraps each file in a transaction.
--
-- GOAL (Steve): track every broker listing across its whole lifecycle — new, active, stale,
-- withdrawn, closed, reappeared — and, critically, PRESERVE A FULL SNAPSHOT of all property
-- info the moment a listing becomes withdrawn (disappears from the source feed), so the data
-- survives after the listing vanishes from the vendor.
-- ─────────────────────────────────────────────────────────────────────────────
-- listing_lifecycle_event — append-only, one row per DETECTED state transition.
-- The recorder inserts a row only when the derived state DIFFERS from the latest
-- observation for that listing (or when there is no prior event), so the table is
-- the change log of every lifecycle update.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS listing_lifecycle_event (
id BIGSERIAL PRIMARY KEY,
listing_id BIGINT REFERENCES listing(id), -- nullable: a listing may vanish/be deleted
source TEXT,
source_id TEXT,
address_key TEXT, -- normalized "address, city" (shared normalizer)
state TEXT NOT NULL, -- new | active | stale | withdrawn | closed | reappeared
prev_state TEXT,
price NUMERIC,
reason TEXT,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lle_listing_observed ON listing_lifecycle_event (listing_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_lle_addrkey_observed ON listing_lifecycle_event (address_key, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_lle_state ON listing_lifecycle_event (state);
CREATE INDEX IF NOT EXISTS idx_lle_source_sid_obs ON listing_lifecycle_event (source, source_id, observed_at DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- listing_snapshot — append-only, the FULL property info preserved at a point in time.
-- The snapshot JSONB carries the ENTIRE listing row + joined broker_name + firm_name +
-- region/county + every field available, so a pulled listing's data is still browsable
-- after it vanishes from the vendor feed.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS listing_snapshot (
id BIGSERIAL PRIMARY KEY,
listing_id BIGINT REFERENCES listing(id), -- nullable, same reasoning as above
source TEXT,
source_id TEXT,
address_key TEXT,
snapshot JSONB NOT NULL, -- full listing row + broker/firm/region names
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
captured_reason TEXT -- withdrawn | closed | stale | new | periodic | manual
);
CREATE INDEX IF NOT EXISTS idx_lsnap_source_sid_cap ON listing_snapshot (source, source_id, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_lsnap_addrkey_cap ON listing_snapshot (address_key, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_lsnap_reason ON listing_snapshot (captured_reason);
-- ─────────────────────────────────────────────────────────────────────────────
-- listing_current_state — the latest lifecycle state per listing (DISTINCT ON the
-- newest event per source/source_id). LEFT-JOINed by the listings API so every row
-- can carry its lifecycle_state + lifecycle_since.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW listing_current_state AS
SELECT DISTINCT ON (source, source_id)
source,
source_id,
listing_id,
address_key,
state,
reason,
observed_at
FROM listing_lifecycle_event
ORDER BY source, source_id, observed_at DESC;