Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,182 @@
"""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.privacy_policy.document_url": (
"https://www.han0107.ru/privacy",
"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.document_url": (
"https://www.han0107.ru/privacy/ads-agree",
"string",
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(public.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")
@@ -0,0 +1,62 @@
"""Qualify pgcrypto digest in the contact sync trigger.
Revision ID: 0002_pgcrypto_digest
Revises: 0001_initial
Create Date: 2026-07-16
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0002_pgcrypto_digest"
down_revision: str | None = "0001_initial"
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
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(public.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;
$$
"""
)
def downgrade() -> None:
raise RuntimeError("Contact sync trigger migration is forward-only")
@@ -0,0 +1,33 @@
"""Store consent device snapshots and remove audit IP.
Revision ID: 0003_consent_audit
Revises: 0002_pgcrypto_digest
Create Date: 2026-07-16
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0003_consent_audit"
down_revision: str | None = "0002_pgcrypto_digest"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE han_app.user_consents
ADD COLUMN IF NOT EXISTS device_json jsonb NOT NULL DEFAULT '{}'::jsonb
"""
)
op.execute(
"""
ALTER TABLE han_app.audit_events
DROP COLUMN IF EXISTS ip
"""
)
def downgrade() -> None:
raise RuntimeError("Consent and audit context migration is forward-only")
@@ -0,0 +1,40 @@
"""Store raw device identifiers and add OTP verification limit.
Revision ID: 0004_device_otp
Revises: 0003_consent_audit
Create Date: 2026-07-21
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0004_device_otp"
down_revision: str | None = "0003_consent_audit"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE han_app.ux_sessions
ADD COLUMN IF NOT EXISTS device_id varchar(255)
"""
)
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('otp.phone.max_verify_attempts', '5', 'integer', false,
'Maximum failed verification attempts for one OTP challenge',
'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("Device and OTP limits migration is forward-only")
@@ -0,0 +1,37 @@
"""Seed runtime OTP settings.
Revision ID: 0005_otp_settings
Revises: 0004_device_otp
Create Date: 2026-07-22
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0005_otp_settings"
down_revision: str | None = "0004_device_otp"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('otp.phone.code_length', '6', 'integer', false,
'Length of the numeric phone OTP', 'A', now()),
('otp.phone.ttl_seconds', '60', 'integer', false,
'Phone OTP lifetime from durable order time', 'A', now()),
('otp.phone.sms_order_timeout_ms', '3000', 'integer', false,
'Keycloak timeout for durable SMS order creation', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("OTP runtime settings migration is forward-only")
@@ -0,0 +1,34 @@
"""Seed consent.privacy_policy.document_url for personal_data consent UI.
Revision ID: 0006_privacy_policy
Revises: 0005_otp_settings
Create Date: 2026-07-23
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0006_privacy_policy"
down_revision: str | None = "0005_otp_settings"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('consent.privacy_policy.document_url',
'https://www.han0107.ru/privacy', 'string', true,
'Privacy policy URL shown next to personal_data consent', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("Privacy policy consent URL migration is forward-only")
@@ -0,0 +1,34 @@
"""Seed consent.marketing.document_url for marketing consent UI link.
Revision ID: 0007_marketing_doc
Revises: 0006_privacy_policy
Create Date: 2026-07-23
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0007_marketing_doc"
down_revision: str | None = "0006_privacy_policy"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('consent.marketing.document_url',
'https://www.han0107.ru/privacy/ads-agree', 'string', true,
'Marketing communications consent document URL', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("Marketing consent document URL migration is forward-only")
@@ -0,0 +1,651 @@
"""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")
@@ -0,0 +1,33 @@
"""Seed configurable maximum chat message length.
Revision ID: 0009_chat_message_max
Revises: 0008_notifications_v1
Create Date: 2026-07-29
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0009_chat_message_max"
down_revision: str | None = "0008_notifications_v1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('chat.message.max_length', '4000', 'integer', true,
'Maximum normalized client chat message length', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("Chat message maximum length migration is forward-only")
@@ -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")
@@ -0,0 +1,188 @@
"""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")
@@ -0,0 +1,74 @@
"""Persist Message Safety v2 evidence and recovery locations.
Revision ID: 0012_safety_v2_checkpoint
Revises: 0011_module07_contract
Create Date: 2026-08-06
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0012_safety_v2_checkpoint"
down_revision: str | None = "0011_module07_contract"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE han_app.messages
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
ALTER TABLE han_app.message_attachments
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024);
ALTER TABLE han_app.message_attachments
DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check;
ALTER TABLE han_app.message_attachments
ADD CONSTRAINT message_attachments_scan_status_check
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
ALTER TABLE han_app.message_attachments
VALIDATE CONSTRAINT message_attachments_scan_status_check;
ALTER TABLE han_app.safety_tasks
ADD COLUMN IF NOT EXISTS poll_location varchar(1024),
ADD COLUMN IF NOT EXISTS processing_mode varchar(16),
ADD COLUMN IF NOT EXISTS config_version bigint,
ADD COLUMN IF NOT EXISTS rules_version varchar(128),
ADD COLUMN IF NOT EXISTS expires_at timestamptz;
UPDATE han_app.safety_tasks
SET poll_location = '/internal/safety/v2/messages/tasks/' || task_id,
expires_at = deadline_at
WHERE poll_location IS NULL OR expires_at IS NULL;
ALTER TABLE han_app.safety_tasks
ALTER COLUMN poll_location SET NOT NULL,
ALTER COLUMN expires_at SET NOT NULL;
"""
)
# Notification uploads use the same safety contract.
op.execute(
"""
ALTER TABLE han_app.client_upload_drafts
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024),
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
ALTER TABLE han_app.client_upload_drafts
DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check;
ALTER TABLE han_app.client_upload_drafts
ADD CONSTRAINT client_upload_drafts_scan_status_check
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
ALTER TABLE han_app.client_upload_drafts
VALIDATE CONSTRAINT client_upload_drafts_scan_status_check;
"""
)
def downgrade() -> None:
raise RuntimeError("Safety v2 checkpoint migration is forward-only")