"""Notification Center v1 schema, catalog and settings. Revision ID: 0008_notifications_v1 Revises: 0007_marketing_doc Create Date: 2026-07-27 """ import hashlib import os from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "0008_notifications_v1" down_revision: str | None = "0007_marketing_doc" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None COMMON = """ record_status varchar(1) NOT NULL DEFAULT 'A', status_changed_at timestamptz NULL, status_change_reason varchar(255) NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), updater_user_id uuid NULL """ def _execute_batch(sql: str) -> None: """Execute one statement at a time for the asyncpg prepared-statement dialect.""" statement: list[str] = [] in_dollar_quote = False index = 0 while index < len(sql): if sql[index : index + 2] == "$$": in_dollar_quote = not in_dollar_quote statement.append("$$") index += 2 continue character = sql[index] if character == ";" and not in_dollar_quote: value = "".join(statement).strip() if value: op.execute(value) statement.clear() else: statement.append(character) index += 1 value = "".join(statement).strip() if value: op.execute(value) def upgrade() -> None: bind = op.get_bind() _execute_batch( f""" CREATE TABLE han_app.notification_cta_actions ( id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE, description varchar(255) NOT NULL, requires_auth boolean NOT NULL, required_instance_fields varchar(64)[] NOT NULL DEFAULT '{{}}', {COMMON} ); CREATE TABLE han_app.notification_buttons ( id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE, label varchar(64) NOT NULL, sets_hidden boolean NOT NULL DEFAULT false, applies_hidden_ttl boolean NOT NULL DEFAULT false, close_reason varchar(32), submits_documents boolean NOT NULL DEFAULT false, {COMMON}, CHECK (NOT applies_hidden_ttl OR sets_hidden), CHECK (NOT submits_documents OR close_reason IS NOT NULL) ); CREATE TABLE han_app.notification_color_tokens ( id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE, description varchar(255) NOT NULL, sort_order smallint NOT NULL, {COMMON} ); CREATE TABLE han_app.notification_types ( id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE, contour varchar(1) NOT NULL CHECK (contour IN ('G','P')), priority smallint NOT NULL, countable boolean NOT NULL, label varchar(64) NOT NULL, color_token varchar(32) NOT NULL REFERENCES han_app.notification_color_tokens(code) ON DELETE RESTRICT, icon_code varchar(32), cta_text varchar(64) NOT NULL, cta_action varchar(32) NOT NULL REFERENCES han_app.notification_cta_actions(code) ON DELETE RESTRICT, cta_sets_hidden boolean NOT NULL DEFAULT false, cta_close_reason varchar(32), button_primary_code varchar(32) REFERENCES han_app.notification_buttons(code) ON DELETE RESTRICT, button_secondary_code varchar(32) REFERENCES han_app.notification_buttons(code) ON DELETE RESTRICT, hidden_ttl_days smallint, documents_allowed boolean NOT NULL DEFAULT false, hide_on_document_download boolean NOT NULL DEFAULT false, required_detail_blocks varchar(64)[] NOT NULL DEFAULT '{{}}', {COMMON}, CHECK (contour <> 'G' OR countable = false), CHECK (contour <> 'G' OR cta_action <> 'open_detail'), CHECK (cta_action <> 'open_detail' OR (cta_sets_hidden = false AND cta_close_reason IS NULL)), CHECK (cta_action = 'open_detail' OR (documents_allowed = false AND hide_on_document_download = false AND required_detail_blocks = '{{}}')), CHECK (NOT hide_on_document_download OR documents_allowed), CHECK (cta_action <> 'open_detail' OR button_primary_code IS NOT NULL), CHECK (cta_action = 'open_detail' OR (button_primary_code IS NULL AND button_secondary_code IS NULL)), CHECK (button_secondary_code IS NULL OR button_primary_code IS NOT NULL), CHECK (button_secondary_code IS NULL OR button_secondary_code <> button_primary_code) ); CREATE TABLE han_app.notification_sources ( id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE, description text, token_hash varchar(128) NOT NULL UNIQUE, token_rotated_at timestamptz, {COMMON} ); CREATE TABLE han_app.notifications ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT, notification_type varchar(32) NOT NULL REFERENCES han_app.notification_types(code) ON DELETE RESTRICT, source varchar(32) NOT NULL REFERENCES han_app.notification_sources(code) ON DELETE RESTRICT, external_id varchar(128) NOT NULL, request_fingerprint varchar(64) NOT NULL, notification_datetime timestamptz NOT NULL, header varchar(255) NOT NULL, text varchar(1024), priority_override smallint, date_expired timestamptz, price numeric(12,2), old_price numeric(12,2), payment_url text, details jsonb, details_schema_version smallint NOT NULL DEFAULT 1, chat_message_text varchar(1024), lifecycle_status varchar(16) NOT NULL DEFAULT 'active' CHECK (lifecycle_status IN ('active','closed')), visibility varchar(16) NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')), is_read boolean NOT NULL DEFAULT false, close_reason varchar(32) CHECK (close_reason IS NULL OR close_reason IN ('user_done','docs_submitted','offer_accepted','paid','expired','cancelled')), closed_at timestamptz, {COMMON}, CONSTRAINT uq_notifications_source_key UNIQUE (source, external_id), CHECK (old_price IS NULL OR price IS NOT NULL), CHECK (lifecycle_status <> 'closed' OR (close_reason IS NOT NULL AND closed_at IS NOT NULL)) ); CREATE INDEX ix_notifications_user_active ON han_app.notifications (user_id, lifecycle_status, visibility, notification_datetime DESC, id DESC) WHERE record_status='A'; CREATE INDEX ix_notifications_expire ON han_app.notifications(date_expired) WHERE record_status='A' AND lifecycle_status='active' AND date_expired IS NOT NULL; CREATE TABLE han_app.guest_notifications ( id uuid PRIMARY KEY, notification_type varchar(32) NOT NULL REFERENCES han_app.notification_types(code) ON DELETE RESTRICT, notification_datetime timestamptz NOT NULL, header varchar(255) NOT NULL, text varchar(1024), priority_override smallint, date_expired timestamptz, price numeric(12,2), old_price numeric(12,2), instruction_url text, chat_message_text varchar(1024), lifecycle_status varchar(16) NOT NULL DEFAULT 'active' CHECK (lifecycle_status IN ('active','closed')), closed_at timestamptz, {COMMON}, CHECK (old_price IS NULL OR price IS NOT NULL), CHECK (instruction_url IS NULL OR instruction_url LIKE 'https://%') ); CREATE INDEX ix_guest_notifications_active ON han_app.guest_notifications(lifecycle_status, notification_datetime DESC, id DESC) WHERE record_status='A'; CREATE INDEX ix_guest_notifications_expire ON han_app.guest_notifications(date_expired) WHERE record_status='A' AND lifecycle_status='active' AND date_expired IS NOT NULL; CREATE TABLE han_app.notification_documents ( id uuid PRIMARY KEY, notification_id uuid NOT NULL REFERENCES han_app.notifications(id) ON DELETE RESTRICT, document_id uuid NOT NULL REFERENCES han_app.documents(id) ON DELETE RESTRICT, sort_order smallint NOT NULL DEFAULT 0, download_url_issued_at timestamptz, {COMMON}, UNIQUE (notification_id, document_id) ); CREATE TABLE han_app.client_upload_drafts ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT, context_type varchar(32) NOT NULL CHECK (context_type IN ('notification')), context_id uuid NOT NULL, original_file_name varchar(255) NOT NULL, safe_file_name varchar(255) NOT NULL, mime_type varchar(128) NOT NULL, size_bytes bigint NOT NULL CHECK (size_bytes > 0), checksum_sha256 char(64), scan_status varchar(16) NOT NULL DEFAULT 'pending' CHECK (scan_status IN ('pending','clean','infected','failed')), storage_bucket varchar(255) NOT NULL, object_key varchar(1024) NOT NULL, quarantine_object_key varchar(1024), upload_expires_at timestamptz, completed_at timestamptz, state varchar(16) NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','submitted','discarded')), submission_id uuid, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_client_upload_drafts_context ON han_app.client_upload_drafts(user_id, context_type, context_id) WHERE state='draft'; CREATE INDEX ix_client_upload_drafts_scan ON han_app.client_upload_drafts(scan_status, updated_at); CREATE INDEX ix_client_upload_drafts_created ON han_app.client_upload_drafts(created_at); CREATE TABLE han_app.client_documents ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT, context_type varchar(32) NOT NULL, context_id uuid NOT NULL, submission_id uuid NOT NULL, source_draft_id uuid NOT NULL UNIQUE, original_file_name varchar(255) NOT NULL, safe_file_name varchar(255) NOT NULL, mime_type varchar(128) NOT NULL, size_bytes bigint NOT NULL, checksum_sha256 char(64) NOT NULL, storage_bucket varchar(255) NOT NULL, object_key varchar(1024) NOT NULL, submitted_at timestamptz NOT NULL, {COMMON}, UNIQUE (storage_bucket, object_key) ); CREATE INDEX ix_client_documents_context ON han_app.client_documents(context_type, context_id); CREATE INDEX ix_client_documents_user_submitted ON han_app.client_documents(user_id, submitted_at DESC); """ ) _execute_batch( """ CREATE OR REPLACE FUNCTION han_app.validate_guest_notification() RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = han_app, pg_temp AS $$ DECLARE v_contour varchar(1); v_action varchar(32); v_required varchar(64)[]; BEGIN SELECT nt.contour, nt.cta_action, ca.required_instance_fields INTO v_contour, v_action, v_required FROM han_app.notification_types nt JOIN han_app.notification_cta_actions ca ON ca.code=nt.cta_action WHERE nt.code=NEW.notification_type AND nt.record_status='A'; IF v_contour IS DISTINCT FROM 'G' THEN RAISE EXCEPTION 'notification type must use guest contour'; END IF; IF ('instruction_url'=ANY(v_required)) <> (NEW.instruction_url IS NOT NULL) OR ('chat_message_text'=ANY(v_required)) <> (NEW.chat_message_text IS NOT NULL) THEN RAISE EXCEPTION 'guest notification fields do not match CTA'; END IF; RETURN NEW; END $$; CREATE TRIGGER trg_validate_guest_notification BEFORE INSERT OR UPDATE ON han_app.guest_notifications FOR EACH ROW EXECUTE FUNCTION han_app.validate_guest_notification(); CREATE OR REPLACE FUNCTION han_app.enqueue_client_document_sync() RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = han_app, pg_temp AS $$ BEGIN IF current_setting('han.sync_suppress', true) = 'true' THEN RETURN NEW; 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(), 'document.client_uploaded', 'client_document', NEW.id, 'document.client_uploaded:' || NEW.id::text, jsonb_build_object( 'client_document_id', NEW.id, 'user_id', NEW.user_id, 'context_type', NEW.context_type, 'context_id', NEW.context_id, 'submission_id', NEW.submission_id, 'storage_bucket', NEW.storage_bucket, 'object_key', NEW.object_key, 'original_file_name', NEW.original_file_name, 'mime_type', NEW.mime_type, 'size_bytes', NEW.size_bytes, 'checksum_sha256', NEW.checksum_sha256), 'pending', 0, now(), now(), now()) ON CONFLICT (dedup_key) DO NOTHING; RETURN NEW; END $$; CREATE TRIGGER trg_client_document_sync AFTER INSERT ON han_app.client_documents FOR EACH ROW WHEN (NEW.record_status='A') EXECUTE FUNCTION han_app.enqueue_client_document_sync(); """ ) _seed_catalog(bind) _seed_settings(bind) def _seed_catalog(bind: sa.Connection) -> None: # asyncpg rejects multiple SQL commands in one prepared statement. _execute_batch( """ INSERT INTO han_app.notification_cta_actions (id,code,description,requires_auth,required_instance_fields) VALUES (gen_random_uuid(),'open_detail','Open notification detail',true,ARRAY['details']), (gen_random_uuid(),'open_payment_url','Open payment URL',true,ARRAY['payment_url']), (gen_random_uuid(),'send_chat_message','Send prepared chat message',true, ARRAY['chat_message_text']), (gen_random_uuid(),'start_auth','Start authentication',false,ARRAY[]::varchar[]), (gen_random_uuid(),'install_app_prompt','Install application or open instruction', false,ARRAY['instruction_url']); INSERT INTO han_app.notification_buttons (id,code,label,sets_hidden,applies_hidden_ttl,close_reason,submits_documents) VALUES (gen_random_uuid(),'done','Готово',false,false,'user_done',false), (gen_random_uuid(),'later','Сделаю позже',false,false,NULL,false), (gen_random_uuid(),'gotit','Понятно',true,true,NULL,false), (gen_random_uuid(),'send_docs','Отправить документы',false,false, 'docs_submitted',true); INSERT INTO han_app.notification_color_tokens (id,code,description,sort_order) VALUES (gen_random_uuid(),'critical','Requires immediate attention',1), (gen_random_uuid(),'warning','Waiting for a client action',2), (gen_random_uuid(),'success','Successful result',3), (gen_random_uuid(),'info','Informational notification',4), (gen_random_uuid(),'promo','Marketing offer',5), (gen_random_uuid(),'neutral','Neutral interface hint',6); """ ) types = [ ( "authorize", "G", 1, False, "Гостевой режим", "neutral", "login", "Войти →", "start_auth", False, None, None, None, False, False, [], ), ( "install_app", "G", 2, False, "Приложение", "neutral", "install", "Установить →", "install_app_prompt", False, None, None, None, False, False, [], ), ( "promo_global", "G", 3, False, "Акция", "promo", "promo", "Узнать подробнее →", "send_chat_message", False, None, None, None, False, False, [], ), ( "ads_global", "G", 4, False, "Предложение", "promo", "offer", "Узнать подробнее →", "send_chat_message", False, None, None, None, False, False, [], ), ( "urgent", "P", 1, True, "Срочно", "critical", "urgent", "Подробнее →", "open_detail", False, None, "done", "later", False, False, [], ), ( "payment_pending", "P", 2, True, "Оплата", "warning", "payment", "Оплатить →", "open_payment_url", False, None, None, None, False, False, [], ), ( "docs_required", "P", 2, True, "Требуются документы", "warning", "upload", "Загрузить документы →", "open_detail", False, None, "send_docs", "later", False, False, [], ), ( "docs_ready", "P", 3, True, "Документы готовы", "success", "download", "Скачать →", "open_detail", False, None, "gotit", None, True, True, ["documents"], ), ( "status_changed", "P", 3, True, "Статус", "info", "status", "Подробнее →", "open_detail", False, None, "gotit", None, False, False, [], ), ( "reminder", "P", 3, True, "Напоминание", "info", "reminder", "Подробнее →", "open_detail", False, None, "done", "later", False, False, [], ), ( "news", "P", 4, True, "Новость", "info", "news", "Подробнее →", "open_detail", False, None, "gotit", None, False, False, [], ), ( "promo_personal", "P", 5, True, "Акция", "promo", "promo", "Узнать подробнее →", "send_chat_message", True, "offer_accepted", None, None, False, False, [], ), ( "ads_personal", "P", 5, True, "Предложение", "promo", "offer", "Узнать подробнее →", "send_chat_message", True, "offer_accepted", None, None, False, False, [], ), ] statement = sa.text( """ INSERT INTO han_app.notification_types (id,code,contour,priority,countable,label,color_token,icon_code,cta_text,cta_action, cta_sets_hidden,cta_close_reason,button_primary_code,button_secondary_code, documents_allowed,hide_on_document_download,required_detail_blocks) VALUES (gen_random_uuid(),:code,:contour,:priority,:countable,:label,:color,:icon,:cta_text, :action,:sets_hidden,:close_reason,:primary,:secondary,:documents_allowed, :hide_download,:required) """ ) for row in types: bind.execute( statement, dict( zip( ( "code", "contour", "priority", "countable", "label", "color", "icon", "cta_text", "action", "sets_hidden", "close_reason", "primary", "secondary", "documents_allowed", "hide_download", "required", ), row, strict=True, ) ), ) token = os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST", "") token_hash = ( hashlib.sha256(token.encode()).hexdigest() if token else hashlib.sha256(b"disabled:producer_test").hexdigest() ) bind.execute( sa.text( """ INSERT INTO han_app.notification_sources (id,code,description,token_hash,token_rotated_at) VALUES (gen_random_uuid(),'producer_test','Notification Center smoke producer', :token_hash,now()) """ ), {"token_hash": token_hash}, ) def _seed_settings(bind: sa.Connection) -> None: settings = [ ("notification.home.max_items", "7", "integer", False), ("notification.center.max_items", "15", "integer", False), ("notification.carousel.autoplay_enabled", "false", "boolean", True), ("notification.carousel.autoplay_interval_ms", "5000", "integer", True), ("notification.hidden.default_ttl_days", "3", "integer", False), ("notification.documents.max_files", "10", "integer", False), ("notification.instruction.allowed_hosts", "chat.example.ru", "string_list", False), ("notification.expire_job.run_at", "00:01", "string", False), ("notification.upload_draft.ttl_days", "7", "integer", False), ("rate_limit.notifications_read.per_user", "120/minute", "string", False), ("rate_limit.notifications_action.per_user", "60/minute", "string", False), ("rate_limit.notification_upload.per_user", "20/minute", "string", False), ("rate_limit.notifications_public.per_ip", "60/minute", "string", False), ] statement = sa.text( """ INSERT INTO han_app.app_settings (setting_key,setting_value,value_type,is_public,record_status,updated_at) VALUES (:key,:value,:kind,: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() """ ) for key, value, kind, public in settings: bind.execute(statement, {"key": key, "value": value, "kind": kind, "public": public}) def downgrade() -> None: raise RuntimeError("Notification Center migration is forward-only")