"""Initial han_app schema, seed and CRM sync triggers. Revision ID: 0001_initial Revises: Create Date: 2026-07-10 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op from app.db import Base revision: str = "0001_initial" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None SEED = { "auth.phone.enabled": ("true", "boolean", True), "auth.password.enabled": ("false", "boolean", True), "otp.phone.max_send_attempts_per_24h": ("3", "integer", False), "otp.phone.min_seconds_between_attempts": ("30", "integer", False), "operator.call.phone": ("+74999591007", "string", True), "consent.personal_data.required": ("true", "boolean", True), "consent.personal_data.document_url": ( "https://www.han0107.ru/privacy/persdata-agree-mobile", "string", True, ), "consent.personal_data.version": ("2026-06-10", "string", True), "consent.user_agreement.required": ("true", "boolean", True), "consent.user_agreement.document_url": ( "https://www.han0107.ru/user-agreement", "string", True, ), "consent.user_agreement.version": ("2026-06-10", "string", True), "consent.marketing.required": ("false", "boolean", True), "consent.marketing.version": ("2026-06-10", "string", True), "chat.attachments.allowed_extensions": ( "jpg,jpeg,png,webp,heic,heif,pdf", "string_list", True, ), "chat.attachments.allowed_mime_types": ( "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", "string_list", True, ), "chat.attachments.disallowed_extensions": ("svg,doc,docx,xls,xlsx,csv", "string_list", False), "chat.attachments.max_size_mb": ("5", "integer", True), "chat.attachments.storage": ("selectel_s3", "string", False), "chat.attachments.upload_mode": ("presigned_put", "string", False), "chat.attachments.safety_scan_required": ("true", "boolean", False), "chat.attachments.presigned_upload_ttl_seconds": ("600", "integer", False), "rate_limit.message_send.per_user": ("30/minute", "string", False), "rate_limit.message_send.per_dialog": ("20/minute", "string", False), "rate_limit.download_url.per_user": ("60/hour", "string", False), "rate_limit.public_endpoints.per_ip": ("60/minute", "string", False), "rate_limit.login.per_ip": ("10/minute", "string", False), "ux.session.idle_timeout_minutes": ("30", "integer", True), "security.cors.allowed_origins": ("https://tohin.ru", "string_list", False), "security.public_cache.max_age_seconds": ("3600", "integer", False), } def upgrade() -> None: bind = op.get_bind() op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") op.execute("CREATE SCHEMA IF NOT EXISTS han_app") Base.metadata.create_all(bind=bind, checkfirst=True) op.execute( """ CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user ON han_app.dialogs(user_id) WHERE record_status='A' AND status IN ('open','waiting_for_company','waiting_for_client') """ ) op.execute( """ CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact ON han_app.client_profiles(bitrix_contact_id) WHERE bitrix_contact_id IS NOT NULL AND record_status='A' """ ) 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 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; v_dedup := v_task_type || ':' || v_entity_id::text || ':' || encode(digest(row_to_json(NEW)::text, 'sha256'), 'hex'); 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; $$ """ ) op.execute( "DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities" ) op.execute( """ CREATE TRIGGER trg_identity_contact_sync AFTER INSERT OR UPDATE OF phone_number, record_status ON han_app.user_identities FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync() """ ) op.execute( "DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles" ) op.execute( """ CREATE TRIGGER trg_profile_contact_sync AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone, foreign_phone, email, record_status ON han_app.client_profiles FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync() """ ) for key, (value, value_type, public) in SEED.items(): bind.execute( sa.text( """ INSERT INTO han_app.app_settings (setting_key, setting_value, value_type, is_public, record_status, updated_at) VALUES (:key, :value, :value_type, :public, 'A', now()) ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, value_type = EXCLUDED.value_type, is_public = EXCLUDED.is_public, record_status = 'A', updated_at = now() """ ), {"key": key, "value": value, "value_type": value_type, "public": public}, ) def downgrade() -> None: raise RuntimeError("Initial data migration is forward-only")