189 lines
7.3 KiB
Python
189 lines
7.3 KiB
Python
"""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")
|