diff --git a/codebase/backend/api-backend/alembic/versions/0010_contact_map_dedup.py b/codebase/backend/api-backend/alembic/versions/0010_contact_map_dedup.py new file mode 100644 index 0000000..8a21e5a --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0010_contact_map_dedup.py @@ -0,0 +1,75 @@ +"""Deduplicate contact mapping and skip no-op identity updates. + +Revision ID: 0010_contact_map_dedup +Revises: 0009_chat_message_max +Create Date: 2026-08-05 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0010_contact_map_dedup" +down_revision: str | None = "0009_chat_message_max" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + 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_entity_id uuid; + v_task_type text; + v_dedup text; + BEGIN + IF current_setting('han.sync_suppress', true) = 'true' THEN + RETURN NEW; + END IF; + IF TG_TABLE_NAME = 'user_identities' THEN + IF TG_OP = 'UPDATE' + AND NEW.phone_number IS NOT DISTINCT FROM OLD.phone_number + AND NEW.record_status IS NOT DISTINCT FROM OLD.record_status THEN + RETURN NEW; + END IF; + v_entity_id := NEW.id; + v_task_type := CASE WHEN TG_OP = 'INSERT' + THEN 'contact.map_or_create' ELSE 'contact.update' END; + ELSE + v_entity_id := NEW.user_id; + v_task_type := CASE WHEN TG_OP = 'INSERT' + THEN 'contact.map_or_create' ELSE 'contact.update' END; + END IF; + + IF v_task_type = 'contact.map_or_create' THEN + -- Identity and profile are inserted during the same bootstrap. + -- A stable key collapses both triggers into one logical task. + v_dedup := v_task_type || ':' || v_entity_id::text; + ELSE + v_dedup := v_task_type || ':' || v_entity_id::text || ':' || + encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex'); + END IF; + + 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_entity_id, v_dedup, + jsonb_build_object('entity_id', v_entity_id), 'pending', 0, + now(), now(), now()) + ON CONFLICT (dedup_key) DO NOTHING; + RETURN NEW; + END; + $$ + """ + ) + + +def downgrade() -> None: + raise RuntimeError("Contact map-or-create deduplication migration is forward-only") diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index 5609b1b..aa6ae2d 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -175,7 +175,7 @@ async def lifespan(app: FastAPI): telemetry.shutdown() -EXPECTED_API_DB_REVISION = "0009_chat_message_max" +EXPECTED_API_DB_REVISION = "0010_contact_map_dedup" app = FastAPI( diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index d9f81bb..ff7e64d 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -270,18 +270,32 @@ class InfrastructureConfigTests(unittest.TestCase): sms_db = (ROOT / "sms-service/app/db.py").read_text(encoding="utf-8") self.assertNotIn("server_settings", sms_db) - def test_contact_sync_qualifies_pgcrypto_digest(self) -> None: + def test_contact_sync_qualifies_digest_and_deduplicates_initial_map(self) -> None: initial = ( ROOT / "api-backend/alembic/versions/0001_initial_han_app.py" ).read_text(encoding="utf-8") fix = ( ROOT / "api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py" ).read_text(encoding="utf-8") + dedup_fix = ( + ROOT / "api-backend/alembic/versions/0010_contact_map_dedup.py" + ).read_text(encoding="utf-8") main = (ROOT / "api-backend/app/main.py").read_text(encoding="utf-8") self.assertIn("public.digest(", initial) self.assertIn("public.digest(", fix) self.assertIn('down_revision: str | None = "0001_initial"', fix) - self.assertIn('EXPECTED_API_DB_REVISION = "0009_chat_message_max"', main) + self.assertIn("IF v_task_type = 'contact.map_or_create' THEN", dedup_fix) + self.assertIn("v_dedup := v_task_type || ':' || v_entity_id::text;", dedup_fix) + self.assertIn( + "NEW.phone_number IS NOT DISTINCT FROM OLD.phone_number", + dedup_fix, + ) + self.assertIn( + "NEW.record_status IS NOT DISTINCT FROM OLD.record_status", + dedup_fix, + ) + self.assertIn('down_revision: str | None = "0009_chat_message_max"', dedup_fix) + self.assertIn('EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"', main) def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None: migration = (