← back to NationalPaperHangers Yfr2 C3
db/migrations/024_measure_attribution_phase_a.sql
225 lines
-- 024 · Measure attribution Phase A structural contract
--
-- ADDITIVE / DORMANT ONLY. This migration defines storage and integrity
-- boundaries; it does not add API routes, enable feature flags, send messages,
-- create prices, backfill attribution, or alter legacy measure-job outcomes.
BEGIN;
ALTER TABLE measure_jobs
ADD COLUMN IF NOT EXISTS uuid UUID,
ADD COLUMN IF NOT EXISTS capture_idempotency_key TEXT,
ADD COLUMN IF NOT EXISTS captured_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS capture_source TEXT,
ADD COLUMN IF NOT EXISTS environment TEXT,
ADD COLUMN IF NOT EXISTS is_test BOOLEAN,
ADD COLUMN IF NOT EXISTS test_reason TEXT,
ADD COLUMN IF NOT EXISTS consent_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS consent_version TEXT,
ADD COLUMN IF NOT EXISTS consent_purposes TEXT[],
ADD COLUMN IF NOT EXISTS contact_preference TEXT,
ADD COLUMN IF NOT EXISTS measurement_method TEXT,
-- NULL deliberately preserves the distinction between legacy unknown media
-- and an explicit empty capture set.
ADD COLUMN IF NOT EXISTS room_captures JSONB,
ADD COLUMN IF NOT EXISTS surface_state TEXT,
ADD COLUMN IF NOT EXISTS access_constraints TEXT[],
ADD COLUMN IF NOT EXISTS timeline_band TEXT,
ADD COLUMN IF NOT EXISTS lead_state TEXT,
ADD COLUMN IF NOT EXISTS responded_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS declined_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS expired_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS tier_snapshot TEXT,
ADD COLUMN IF NOT EXISTS tier_reason_codes TEXT[],
ADD COLUMN IF NOT EXISTS tier_policy_version TEXT,
ADD COLUMN IF NOT EXISTS retention_class TEXT,
ADD COLUMN IF NOT EXISTS subject_erased_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS current_routing_attempt_id BIGINT;
CREATE UNIQUE INDEX IF NOT EXISTS measure_jobs_uuid_uq
ON measure_jobs (uuid) WHERE uuid IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS measure_jobs_capture_idempotency_uq
ON measure_jobs (capture_idempotency_key) WHERE capture_idempotency_key IS NOT NULL;
ALTER TABLE measure_jobs
ADD CONSTRAINT measure_jobs_capture_source_ck CHECK (
capture_source IS NULL OR capture_source IN ('measure_web','booking_handoff','partner','admin_test')
) NOT VALID,
ADD CONSTRAINT measure_jobs_environment_ck CHECK (
environment IS NULL OR environment IN ('production','staging','development','test')
) NOT VALID,
ADD CONSTRAINT measure_jobs_contact_preference_ck CHECK (
contact_preference IS NULL OR contact_preference IN ('email','phone','either')
) NOT VALID,
ADD CONSTRAINT measure_jobs_measurement_method_ck CHECK (
measurement_method IS NULL OR measurement_method IN ('manual','calculator','camera','installer')
) NOT VALID,
ADD CONSTRAINT measure_jobs_lead_state_ck CHECK (
lead_state IS NULL OR lead_state IN ('captured','no_installer','routed','viewed','accepted','declined','expired')
) NOT VALID,
ADD CONSTRAINT measure_jobs_tier_snapshot_ck CHECK (
tier_snapshot IS NULL OR tier_snapshot IN ('standard','spec_ready','ineligible')
) NOT VALID,
ADD CONSTRAINT measure_jobs_retention_class_ck CHECK (
retention_class IS NULL OR retention_class IN ('test','unconverted','converted','legal_hold')
) NOT VALID,
ADD CONSTRAINT measure_jobs_room_captures_array_ck CHECK (
room_captures IS NULL OR jsonb_typeof(room_captures) = 'array'
) NOT VALID;
CREATE TABLE measure_job_routing_attempts (
id BIGSERIAL PRIMARY KEY,
measure_job_id BIGINT NOT NULL REFERENCES measure_jobs(id) ON DELETE RESTRICT,
attempt_no INTEGER NOT NULL CHECK (attempt_no > 0),
installer_id BIGINT NOT NULL REFERENCES installers(id) ON DELETE RESTRICT,
state TEXT NOT NULL CHECK (state IN ('routed','viewed','accepted','declined','expired','superseded')),
routed_at TIMESTAMPTZ NOT NULL,
viewed_at TIMESTAMPTZ,
responded_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
declined_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ,
superseded_at TIMESTAMPTZ,
supersedes_job_id BIGINT,
supersedes_attempt_id BIGINT,
correlation_id UUID NOT NULL UNIQUE,
created_by TEXT NOT NULL,
UNIQUE (measure_job_id, attempt_no),
UNIQUE (id, measure_job_id, installer_id),
UNIQUE (measure_job_id, id),
CHECK (
(attempt_no = 1 AND supersedes_attempt_id IS NULL AND supersedes_job_id IS NULL)
OR (attempt_no > 1 AND supersedes_attempt_id IS NOT NULL
AND supersedes_job_id IS NOT NULL AND supersedes_job_id = measure_job_id
AND supersedes_attempt_id <> id)
)
);
ALTER TABLE measure_job_routing_attempts
ADD CONSTRAINT routing_attempt_supersedes_same_job_fk
FOREIGN KEY (supersedes_job_id, supersedes_attempt_id)
REFERENCES measure_job_routing_attempts (measure_job_id, id)
MATCH FULL ON DELETE RESTRICT;
CREATE UNIQUE INDEX routing_attempt_one_successor_idx
ON measure_job_routing_attempts (supersedes_attempt_id)
WHERE supersedes_attempt_id IS NOT NULL;
CREATE UNIQUE INDEX routing_attempt_one_root_per_job_idx
ON measure_job_routing_attempts (measure_job_id)
WHERE supersedes_attempt_id IS NULL;
CREATE UNIQUE INDEX routing_attempt_one_active_per_job_idx
ON measure_job_routing_attempts (measure_job_id)
WHERE state IN ('routed','viewed','accepted');
ALTER TABLE measure_jobs
ADD CONSTRAINT measure_jobs_current_route_fk
FOREIGN KEY (current_routing_attempt_id, id, routed_to)
REFERENCES measure_job_routing_attempts (id, measure_job_id, installer_id)
ON DELETE RESTRICT,
ADD CONSTRAINT measure_jobs_current_route_pair_ck CHECK (
current_routing_attempt_id IS NULL OR routed_to IS NOT NULL
) NOT VALID;
ALTER TABLE bookings
ADD CONSTRAINT bookings_id_installer_uq UNIQUE (id, installer_id);
CREATE TABLE measure_booking_attribution_candidates (
id BIGSERIAL PRIMARY KEY,
booking_id INTEGER NOT NULL,
measure_job_id BIGINT NOT NULL REFERENCES measure_jobs(id) ON DELETE RESTRICT,
routing_attempt_id BIGINT NOT NULL,
installer_id BIGINT NOT NULL,
attribution_method TEXT NOT NULL CHECK (attribution_method IN ('signed_handoff','admin_verified')),
policy_version TEXT NOT NULL,
qualified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
correlation_id UUID NOT NULL UNIQUE,
created_by TEXT NOT NULL,
UNIQUE (booking_id, id),
UNIQUE (booking_id, routing_attempt_id, attribution_method, policy_version),
FOREIGN KEY (booking_id, installer_id)
REFERENCES bookings (id, installer_id) ON DELETE RESTRICT,
FOREIGN KEY (routing_attempt_id, measure_job_id, installer_id)
REFERENCES measure_job_routing_attempts (id, measure_job_id, installer_id)
ON DELETE RESTRICT
);
CREATE TABLE measure_booking_primary_decisions (
id BIGSERIAL PRIMARY KEY,
booking_id INTEGER NOT NULL REFERENCES bookings(id) ON DELETE RESTRICT,
decision_kind TEXT NOT NULL CHECK (decision_kind IN ('select','clear')),
selected_booking_id INTEGER,
selected_candidate_id BIGINT,
supersedes_booking_id INTEGER,
supersedes_decision_id BIGINT,
decided_at TIMESTAMPTZ NOT NULL DEFAULT now(),
correlation_id UUID NOT NULL UNIQUE,
policy_version TEXT NOT NULL,
reason_code TEXT NOT NULL,
decided_by TEXT NOT NULL,
UNIQUE (booking_id, id),
CHECK (
(decision_kind = 'select' AND selected_booking_id IS NOT NULL
AND selected_booking_id = booking_id AND selected_candidate_id IS NOT NULL)
OR (decision_kind = 'clear' AND selected_booking_id IS NULL AND selected_candidate_id IS NULL)
),
CHECK (
(supersedes_decision_id IS NULL AND supersedes_booking_id IS NULL
AND decision_kind = 'select')
OR (supersedes_decision_id IS NOT NULL AND supersedes_booking_id IS NOT NULL
AND supersedes_booking_id = booking_id AND supersedes_decision_id <> id)
),
CHECK (decision_kind <> 'clear' OR supersedes_decision_id IS NOT NULL),
FOREIGN KEY (selected_booking_id, selected_candidate_id)
REFERENCES measure_booking_attribution_candidates (booking_id, id)
MATCH FULL ON DELETE RESTRICT,
FOREIGN KEY (supersedes_booking_id, supersedes_decision_id)
REFERENCES measure_booking_primary_decisions (booking_id, id)
MATCH FULL ON DELETE RESTRICT
);
CREATE UNIQUE INDEX primary_decision_one_root_per_booking_idx
ON measure_booking_primary_decisions (booking_id)
WHERE supersedes_decision_id IS NULL;
CREATE UNIQUE INDEX primary_decision_one_successor_idx
ON measure_booking_primary_decisions (supersedes_decision_id)
WHERE supersedes_decision_id IS NOT NULL;
CREATE TABLE measure_booking_primary_current (
booking_id INTEGER PRIMARY KEY REFERENCES bookings(id) ON DELETE RESTRICT,
decision_id BIGINT NOT NULL,
version BIGINT NOT NULL CHECK (version > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (booking_id, decision_id)
REFERENCES measure_booking_primary_decisions (booking_id, id) ON DELETE RESTRICT
);
CREATE TABLE measure_job_events (
id BIGSERIAL PRIMARY KEY,
measure_job_id BIGINT NOT NULL REFERENCES measure_jobs(id) ON DELETE RESTRICT,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_type TEXT NOT NULL,
installer_id BIGINT REFERENCES installers(id) ON DELETE RESTRICT,
booking_id INTEGER REFERENCES bookings(id) ON DELETE RESTRICT,
correlation_id UUID NOT NULL,
source_system TEXT,
source_event_id TEXT,
schema_version TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
CHECK (jsonb_typeof(metadata) = 'object'),
CHECK (
(source_event_id IS NULL AND source_system IS NULL)
OR (source_event_id IS NOT NULL AND source_system IS NOT NULL)
)
);
CREATE UNIQUE INDEX measure_job_events_source_idempotency_idx
ON measure_job_events (source_system, source_event_id)
WHERE source_event_id IS NOT NULL;
CREATE UNIQUE INDEX measure_job_events_internal_idempotency_idx
ON measure_job_events (measure_job_id, event_type, correlation_id)
WHERE source_event_id IS NULL;
COMMIT;