"""Expand module-07 queue and stage canonical Bitrix mapping. Revision ID: 0011_module07_contract Revises: 0010_contact_map_dedup Create Date: 2026-08-06 """ from collections.abc import Sequence from alembic import op revision: str = "0011_module07_contract" down_revision: str | None = "0010_contact_map_dedup" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: # Expand first. Existing rows remain readable throughout the migration. op.execute( """ ALTER TABLE han_app.sync_queue ADD COLUMN IF NOT EXISTS locked_by varchar(128), ADD COLUMN IF NOT EXISTS locked_until timestamptz, ADD COLUMN IF NOT EXISTS lease_token uuid, ADD COLUMN IF NOT EXISTS last_error_code varchar(64), ADD COLUMN IF NOT EXISTS last_error_at timestamptz, ADD COLUMN IF NOT EXISTS completed_at timestamptz, ADD COLUMN IF NOT EXISTS cancel_reason varchar(255); UPDATE han_app.sync_queue SET status = CASE status WHEN 'processing' THEN 'pending' WHEN 'failed' THEN 'retry_wait' ELSE status END WHERE status IN ('processing', 'failed'); ALTER TABLE han_app.sync_queue DROP CONSTRAINT IF EXISTS sync_queue_status_check; ALTER TABLE han_app.sync_queue ADD CONSTRAINT sync_queue_status_check CHECK ( status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled') ) NOT VALID; ALTER TABLE han_app.sync_queue VALIDATE CONSTRAINT sync_queue_status_check; ALTER TABLE han_app.sync_queue DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key; DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next; CREATE INDEX IF NOT EXISTS ix_sync_queue_claim ON han_app.sync_queue(status, next_attempt_at, created_at); CREATE INDEX IF NOT EXISTS ix_sync_queue_expired_lease ON han_app.sync_queue(locked_until) WHERE status = 'leased'; CREATE INDEX IF NOT EXISTS ix_sync_queue_entity_history ON han_app.sync_queue(entity_type, entity_id, created_at DESC); CREATE UNIQUE INDEX IF NOT EXISTS uq_sync_queue_active_dedup ON han_app.sync_queue(dedup_key) WHERE status IN ('pending','leased','retry_wait'); """ ) # The bitrix-sync migration owns the canonical schema. If that migration # already ran, copy and verify legacy rows here; otherwise its follow-up # migration performs the same copy. The legacy table remains until the # readers have switched and the contract migration is explicitly approved. op.execute( """ DO $$ BEGIN IF to_regclass('bitrix_sync.entity_external_mapping') IS NOT NULL THEN INSERT INTO bitrix_sync.entity_external_mapping ( id, entity_type, entity_id, external_system, external_entity_type, external_id, status, opened_at, created_at, updated_at ) SELECT id, entity_type, entity_id, 'bitrix24', 'contact', external_id, 'active', created_at, created_at, created_at FROM han_app.entity_external_mapping ON CONFLICT DO NOTHING; IF EXISTS ( SELECT 1 FROM han_app.entity_external_mapping legacy LEFT JOIN bitrix_sync.entity_external_mapping canonical ON canonical.id = legacy.id AND canonical.entity_type = legacy.entity_type AND canonical.entity_id = legacy.entity_id AND canonical.external_id = legacy.external_id WHERE canonical.id IS NULL ) THEN RAISE EXCEPTION 'canonical mapping verification failed'; END IF; END IF; END $$; """ ) op.execute( """ CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync() RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = han_app, pg_temp AS $$ DECLARE v_user_id uuid; v_task_type varchar(64); v_reason varchar(64); v_dedup varchar(255); v_source_updated_at timestamptz; BEGIN IF current_setting('han.sync_suppress', true) = 'true' THEN RETURN NEW; END IF; IF TG_TABLE_NAME = 'user_identities' THEN v_user_id := NEW.id; v_source_updated_at := NEW.updated_at; IF TG_OP = 'INSERT' THEN IF NEW.record_status <> 'A' THEN RETURN NEW; END IF; v_task_type := 'contact.map_or_create'; v_reason := 'identity_created'; ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN v_task_type := 'contact.deactivate'; v_reason := 'identity_deactivated'; ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN v_task_type := 'contact.map_or_create'; v_reason := 'identity_reactivated'; ELSIF NEW.record_status = 'A' AND NEW.phone_number IS DISTINCT FROM OLD.phone_number THEN v_task_type := 'contact.update'; v_reason := 'identity_phone_changed'; ELSE RETURN NEW; END IF; ELSE v_user_id := NEW.user_id; v_source_updated_at := NEW.updated_at; IF TG_OP = 'INSERT' THEN IF NEW.record_status <> 'A' THEN RETURN NEW; END IF; v_task_type := 'contact.map_or_create'; v_reason := 'profile_created'; ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN v_task_type := 'contact.deactivate'; v_reason := 'profile_deactivated'; ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN v_task_type := 'contact.map_or_create'; v_reason := 'profile_reactivated'; ELSE RETURN NEW; END IF; END IF; v_dedup := v_task_type || ':' || v_user_id::text; INSERT INTO han_app.sync_queue ( id, task_type, entity_type, entity_id, dedup_key, payload_json, status, attempt_count, next_attempt_at, created_at, updated_at ) VALUES ( gen_random_uuid(), v_task_type, 'contact', v_user_id, v_dedup, jsonb_build_object( 'schema_version', 1, 'user_id', v_user_id, 'reason', v_reason, 'source_updated_at', v_source_updated_at ), 'pending', 0, now(), now(), now() ) ON CONFLICT (dedup_key) WHERE status IN ('pending','leased','retry_wait') DO UPDATE SET payload_json = EXCLUDED.payload_json, updated_at = now(); RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles; CREATE TRIGGER trg_profile_contact_sync AFTER INSERT OR UPDATE OF record_status ON han_app.client_profiles FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync(); """ ) def downgrade() -> None: raise RuntimeError("Module-07 staged contract migration is forward-only")