-- Document Reconciliation v2 (additive; no data backfill is run here). CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS opportunity_document_links ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE RESTRICT, document_id UUID NOT NULL REFERENCES commercial_documents(id) ON DELETE RESTRICT, document_kind TEXT NOT NULL, relationship TEXT NOT NULL CHECK (relationship IN ('PRIMARY','SECONDARY','HISTORICAL','IGNORED','REMOVED','REASSIGNED','REVIEW_REQUIRED')), is_manual BOOLEAN NOT NULL DEFAULT FALSE, decision_reason TEXT, decided_by TEXT, decided_at TIMESTAMPTZ, source TEXT NOT NULL, origin_opportunity_id UUID REFERENCES opportunities(id) ON DELETE RESTRICT, destination_opportunity_id UUID REFERENCES opportunities(id) ON DELETE RESTRICT, correlation_id TEXT, request_id TEXT, restored_from_link_id UUID REFERENCES opportunity_document_links(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), ended_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), CONSTRAINT ck_document_link_reassigned CHECK ( relationship <> 'REASSIGNED' OR ( origin_opportunity_id IS NOT NULL AND destination_opportunity_id IS NOT NULL AND origin_opportunity_id <> destination_opportunity_id ) ) ); CREATE UNIQUE INDEX IF NOT EXISTS ux_document_link_current ON opportunity_document_links(opportunity_id, document_id) WHERE ended_at IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS ux_document_link_primary_kind ON opportunity_document_links(opportunity_id, document_kind) WHERE ended_at IS NULL AND relationship = 'PRIMARY'; CREATE INDEX IF NOT EXISTS idx_document_links_opportunity ON opportunity_document_links(opportunity_id, ended_at, relationship); CREATE INDEX IF NOT EXISTS idx_document_links_document ON opportunity_document_links(document_id, ended_at); CREATE INDEX IF NOT EXISTS idx_document_links_relationship ON opportunity_document_links(relationship, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_document_links_destination ON opportunity_document_links(destination_opportunity_id) WHERE destination_opportunity_id IS NOT NULL; CREATE TABLE IF NOT EXISTS opportunity_document_link_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), link_id UUID REFERENCES opportunity_document_links(id) ON DELETE SET NULL, opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE RESTRICT, document_id UUID NOT NULL REFERENCES commercial_documents(id) ON DELETE RESTRICT, event_type TEXT NOT NULL, actor TEXT NOT NULL, reason TEXT, old_relationship TEXT, new_relationship TEXT, old_opportunity_id UUID, new_opportunity_id UUID, old_jasmin_status TEXT, new_jasmin_status TEXT, correlation_id TEXT, request_id TEXT, idempotency_key TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), payload JSONB NOT NULL DEFAULT '{}'::jsonb ); CREATE UNIQUE INDEX IF NOT EXISTS ux_document_link_events_idempotency ON opportunity_document_link_events(idempotency_key) WHERE idempotency_key IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_document_link_events_link ON opportunity_document_link_events(link_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_document_link_events_opportunity ON opportunity_document_link_events(opportunity_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_document_link_events_document ON opportunity_document_link_events(document_id, created_at DESC); -- Command claims make the mutation itself idempotent, not merely its event. CREATE TABLE IF NOT EXISTS document_reconciliation_commands ( idempotency_key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, command_payload JSONB NOT NULL, result_payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ ); -- Events are an audit ledger: UPDATE and DELETE are rejected at database level. CREATE OR REPLACE FUNCTION reject_document_link_event_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'opportunity_document_link_events is append-only'; END $$; DROP TRIGGER IF EXISTS trg_document_link_events_append_only ON opportunity_document_link_events; CREATE TRIGGER trg_document_link_events_append_only BEFORE UPDATE OR DELETE ON opportunity_document_link_events FOR EACH ROW EXECUTE FUNCTION reject_document_link_event_mutation(); ALTER TABLE commercial_document_lines ADD COLUMN IF NOT EXISTS commercial_document_id UUID REFERENCES commercial_documents(id) ON DELETE RESTRICT; UPDATE commercial_document_lines SET commercial_document_id = document_id WHERE commercial_document_id IS NULL; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='ck_commercial_document_lines_document_present') THEN ALTER TABLE commercial_document_lines ADD CONSTRAINT ck_commercial_document_lines_document_present CHECK (commercial_document_id IS NOT NULL) NOT VALID; END IF; END $$; ALTER TABLE commercial_document_lines VALIDATE CONSTRAINT ck_commercial_document_lines_document_present; ALTER TABLE commercial_document_lines ALTER COLUMN commercial_document_id SET NOT NULL; ALTER TABLE commercial_document_lines ADD COLUMN IF NOT EXISTS opportunity_document_link_id UUID REFERENCES opportunity_document_links(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_document_lines_commercial_document ON commercial_document_lines(commercial_document_id); CREATE INDEX IF NOT EXISTS idx_document_lines_opportunity_link ON commercial_document_lines(opportunity_document_link_id); COMMENT ON TABLE opportunity_document_links IS 'Canonical ClientFlow document/opportunity relationship; legacy commercial_documents fields are not authoritative.'; COMMENT ON TABLE opportunity_document_link_events IS 'Append-only reconciliation audit ledger.';