Перейти к содержанию

Проектирование БД MVP

Статус: согласованный проект; foundation-срез реализован в Prisma schema и первой миграции.

Целевая СУБД — PostgreSQL 18, доступ из приложения — Prisma 7. Модель покрывает только backend MVP из утверждённого scope. Админ-панель, admin role, чат, Telegram, календарь и видеосвязь в схему не включены.

1. Главные решения

  1. Один PostgreSQL database и schema public для модульного монолита. Владение таблицами разделено по бизнес-модулям, а не физическими PostgreSQL schemas.
  2. Основные идентификаторы — UUID. Внешние токены, OTP и refresh tokens никогда не хранятся открытым текстом: сохраняется только digest/HMAC.
  3. Все даты событий — TIMESTAMPTZ в UTC. Локальная дата/время рождения хранится отдельно вместе с IANA timezone и фактически применённым UTC offset.
  4. Деньги — BIGINT в минимальных единицах валюты. Для RUB это копейки. FLOAT/DOUBLE PRECISION запрещены.
  5. Успешные расчёты, AI-результаты, финансовые факты, согласия и audit events неизменяемы. Исправление создаёт новую версию или компенсирующую запись.
  6. JSONB используется только для версионированных payload/result snapshots. Поля, по которым нужны связи, права, уникальность, состояния или поиск, остаются реляционными колонками.
  7. Временные методики v0 не влияют на структуру: версия engine, schema, resource package и hash сохраняются с каждым артефактом.
  8. Конкретная AI-модель не является справочником домена. В генерации сохраняется строковый provider_key + model_snapshot.
  9. Каталог допускает несколько продуктов и цен. Ограничение «один активный тариф MVP» задаётся seed-данными, а не ограничением схемы.
  10. Удаление пользователя — управляемая анонимизация. Финансовые и обязательные audit-записи не удаляются каскадом.

2. Конвенции PostgreSQL и Prisma

Область Решение
Имена БД snake_case во множественном числе
Имена Prisma PascalCase; физическое имя задаётся через @@map/@map
Primary key id UUID DEFAULT gen_random_uuid(); Prisma использует dbgenerated
Время TIMESTAMPTZ(3); локальное время — DATE + TIME(0)
Hash CHAR(64) для SHA-256 hex либо BYTEA для HMAC/digest
Деньги amount_minor BIGINT + currency CHAR(3); AI cost telemetry — cost_micros BIGINT
Координаты NUMERIC(9,6) latitude и NUMERIC(9,6) longitude
Оптимистическая блокировка state_version INTEGER NOT NULL DEFAULT 0 на изменяемых state machine records
JSON JSONB + обязательные schema_version/payload_version
Soft delete только там, где нужен жизненный цикл; универсального deleted_at у каждой таблицы нет
Enum PostgreSQL/Prisma enum для закрытых состояний; новое значение добавляется миграцией

Общие created_at и updated_at ниже не повторяются в каждом описании. updated_at есть только у изменяемых агрегатов; immutable-таблицам достаточно created_at.

В MVP используются отдельные database roles: migration owner с DDL, runtime role только с нужным DML и backup/monitoring role без прикладных write-прав. PostgreSQL RLS на первом этапе не включается: object-level authorization выполняют service use cases, а integration tests проверяют доступ каждого актора. Перед включением RLS нужно отдельно проверить его взаимодействие с Prisma connection pooling и background workers.

3. Владение таблицами

Модуль Таблицы
auth users, user_roles, otp_challenges, auth_rate_buckets, sessions
users legal_document_versions, consent_acceptances, onboarding_drafts, place_snapshots, birth_profiles
calculations calculation_engine_releases, calculation_sets, calculation_set_items, calculation_artifacts, evidence_facts
interpretations content_releases, forecast_inputs, interpretation_requests, interpretation_sources, ai_generation_attempts, interpretation_artifacts
billing products, product_features, prices, orders, order_items, subscriptions, subscription_invoices, payment_method_references, payment_attempts, provider_events, entitlements, refund_requests, refunds, ledger_transactions, ledger_entries, practitioner_settlements
goals goals, goal_check_ins
practitioners practitioner_profiles, specialties, practitioner_specialties, practitioner_services, practitioner_documents, practitioner_cases
consultations consultations, consultation_time_options, consultation_transitions, consultation_orders
notifications notification_preferences, notification_deliveries, notification_attempts
analytics anonymous_visitors, attribution_touches, analytics_events
operations idempotency_records, outbox_events, jobs, domain_audit_events, privacy_requests

4. Identity, onboarding и данные рождения

erDiagram
    USERS ||--o{ USER_ROLES : has
    USERS ||--o{ SESSIONS : owns
    USERS ||--o{ BIRTH_PROFILES : owns
    USERS ||--o{ CONSENT_ACCEPTANCES : accepts
    ONBOARDING_DRAFTS ||--o| BIRTH_PROFILES : creates
    ONBOARDING_DRAFTS ||--o{ CONSENT_ACCEPTANCES : records
    ONBOARDING_DRAFTS }o--o| USERS : claimed_by
    PLACE_SNAPSHOTS ||--o{ BIRTH_PROFILES : used_by
    LEGAL_DOCUMENT_VERSIONS ||--o{ CONSENT_ACCEPTANCES : accepted_as

users

Поле Тип/правило
id UUID PK
email_normalized VARCHAR(320) NOT NULL UNIQUE; lower/trim нормализация в application service
status ACTIVE \| BLOCKED \| ANONYMIZED
locale VARCHAR(10) NOT NULL DEFAULT 'ru'
anonymized_at nullable; обязателен при ANONYMIZED

E-mail меняется только отдельным подтверждённым flow, которого пока нет в публичном API. При анонимизации поле заменяется уникальным техническим адресом вида deleted+<uuid>@invalid.local.

user_roles

Поля: user_id FK, role CLIENT|PRACTITIONER, granted_at. Primary key — (user_id, role). Admin-значения нет. Роль практика выдаёт закрытый seed/операционный процесс после создания practitioner_profiles.

otp_challenges

Поля: id, purpose LOGIN, email_normalized, code_digest BYTEA, expires_at, resend_available_at, attempt_count, max_attempts DEFAULT 5, consumed_at, blocked_at, request_ip_hash, user_agent_hash.

Ограничения и индексы:

  • expires_at > created_at, attempt_count BETWEEN 0 AND max_attempts;
  • индекс (email_normalized, created_at DESC) для cooldown и поиска активного challenge;
  • частичный индекс по expires_at для очистки неиспользованных записей;
  • в БД нет исходного кода OTP и полного IP.

auth_rate_buckets

PostgreSQL-реализация anti-abuse без Redis: scope, subject_hash, window_started_at, counter, blocked_until. Primary key (scope, subject_hash, window_started_at). Увеличение выполняется атомарным UPSERT. Scope: OTP_EMAIL, OTP_IP, OTP_DEVICE, LOGIN_EMAIL.

sessions

Поля: id, user_id, token_digest BYTEA UNIQUE, family_id UUID, rotated_from_id self FK, expires_at, last_seen_at, revoked_at, revoke_reason, reuse_detected_at, ip_hash, user_agent_hash.

Refresh rotation создаёт новую строку и отзывает старую в одной транзакции. Повтор уже использованного токена отзывает всю family_id.

Поля: id, document_type TERMS|PRIVACY|PERSONAL_DATA|RECURRING_PAYMENT|DISCLAIMER, version, locale, content_hash, public_url, effective_from, retired_at. Unique: (document_type, version, locale).

Immutable-поля: id, document_version_id, user_id nullable, onboarding_draft_id nullable, accepted_at, ip_hash, user_agent_hash, source.

SQL CHECK требует хотя бы одного владельца. До регистрации заполнен draft; при claim добавляется user_id, но accepted_at и версия документа не меняются. Unique отдельно для (user_id, document_version_id) и (onboarding_draft_id, document_version_id).

onboarding_drafts

Поля: id, public_token_digest BYTEA UNIQUE, status OPEN|CLAIMED|EXPIRED, expires_at, claimed_by_user_id, claimed_at, attribution_visitor_id, request_ip_hash.

Публичный token показывается один раз. Claim возможен только для OPEN и неистёкшей записи под row lock. В draft не дублируются дата рождения и результаты — они находятся в его versioned birth_profiles и calculation sets.

place_snapshots

Immutable-поля: id, input_label, resolved_label, country_code, latitude, longitude, timezone_id, provider_key, provider_place_id, provider_version, tzdata_version, resolved_at.

CHECK: latitude [-90,90], longitude [-180,180], непустой IANA timezone_id. Обновление ответа провайдера создаёт новый snapshot.

birth_profiles

Поле Назначение
id UUID PK
user_id nullable для гостевого draft, FK users
source_draft_id nullable FK onboarding_drafts
revision положительный integer
previous_profile_id nullable self FK
is_current текущая версия профиля пользователя
birth_name исходная строка имени для нумерологии
local_birth_date DATE NOT NULL
local_birth_time nullable TIME(0)
time_accuracy EXACT \| UNKNOWN
place_snapshot_id FK place_snapshots
resolved_utc_instant nullable TIMESTAMPTZ
resolved_utc_offset_seconds nullable integer
normalization_version версия time/place normalization

CHECK:

  • при EXACT заполнены local time, UTC instant и offset;
  • при UNKNOWN эти три поля пусты;
  • заполнен хотя бы один owner: user_id или source_draft_id;
  • partial unique (user_id) WHERE is_current = true AND user_id IS NOT NULL;
  • partial unique (source_draft_id) WHERE is_current = true AND source_draft_id IS NOT NULL;
  • unique (user_id, revision) и (source_draft_id, revision).

Редактирование даты, времени, имени или места создаёт новую строку и переключает is_current в одной транзакции — как для draft, так и после claim.

5. Детерминированные расчёты

erDiagram
    BIRTH_PROFILES ||--o{ CALCULATION_SETS : calculated_as
    CALCULATION_SETS ||--|{ CALCULATION_SET_ITEMS : contains
    CALCULATION_ENGINE_RELEASES ||--o{ CALCULATION_SET_ITEMS : requested_with
    CALCULATION_ENGINE_RELEASES ||--o{ CALCULATION_ARTIFACTS : produced_by
    CALCULATION_SET_ITEMS }o--o| CALCULATION_ARTIFACTS : resolves_to
    CALCULATION_ARTIFACTS ||--o{ EVIDENCE_FACTS : exposes

calculation_engine_releases

Справочник доступных реализаций: id, system ASTROLOGY|NUMEROLOGY|HUMAN_DESIGN|DESTINY_MATRIX, engine_key, methodology_version, output_schema_version, code_version, status PROVISIONAL|APPROVED|RETIRED, resource_manifest JSONB, resource_hash, activated_at, retired_at.

Unique: (system, methodology_version, output_schema_version, resource_hash). Для natal-v1 resource manifest содержит Swiss version, ephemeris dataset и flags; для v0 — hash таблиц букв/gates/arcanes.

calculation_sets

Поля: id, birth_profile_id, orchestration_version, normalized_input_hash, status, completed_at, state_version.

Статусы: QUEUED|RUNNING|SUCCEEDED|PARTIALLY_SUCCEEDED|FAILED. Unique: (birth_profile_id, orchestration_version, normalized_input_hash). Aggregate status вычисляется из items, но материализуется для дешёвого API-чтения.

calculation_set_items

Поля: id, calculation_set_id, system, engine_release_id, status QUEUED|RUNNING|SUCCEEDED|FAILED|BLOCKED, artifact_id nullable, blocked_reason, last_error_code, last_error_safe_message, started_at, completed_at, state_version.

Unique (calculation_set_id, system). Для неизвестного времени astrology и HD создаются в BLOCKED с причиной EXACT_TIME_REQUIRED; нумерология и Матрица выполняются.

calculation_artifacts

Только успешный immutable-результат: id, engine_release_id, normalized_input_hash, input_schema_version, result_json JSONB, result_hash, computed_at, duration_ms.

Unique (engine_release_id, normalized_input_hash) реализует content-addressed cache. Artifact не имеет прямого user_id: доступ проверяется только через calculation_set_items → calculation_sets → birth_profiles. При удалении последней ссылки artifact может быть очищен retention job.

evidence_facts

Поля: id, artifact_id, fact_id, fact_type, extractor_version, value_json JSONB, value_hash. Unique (artifact_id, fact_id, extractor_version). Оси, веса и совместимость сюда не записываются: они принадлежат versioned evidence mapping в content_releases.

6. AI-интерпретации и прогнозы

erDiagram
    USERS ||--o{ INTERPRETATION_REQUESTS : requests
    CALCULATION_SETS ||--o{ INTERPRETATION_REQUESTS : interprets
    FORECAST_INPUTS }o--|| BIRTH_PROFILES : for
    INTERPRETATION_REQUESTS ||--o{ INTERPRETATION_SOURCES : freezes
    CALCULATION_ARTIFACTS ||--o{ INTERPRETATION_SOURCES : source
    INTERPRETATION_REQUESTS ||--o{ AI_GENERATION_ATTEMPTS : attempts
    INTERPRETATION_REQUESTS }o--o| INTERPRETATION_ARTIFACTS : publishes
    CONTENT_RELEASES ||--o{ INTERPRETATION_REQUESTS : configures

content_releases

Versioned seeded content без admin CRUD: id, kind EVIDENCE_MAPPING|PROMPT|SAFETY_POLICY|DISCLAIMER|EMAIL_TEMPLATE, key, version, locale, status DRAFT|ACTIVE|RETIRED, body_json JSONB, content_hash, activated_at, retired_at. Unique (kind, key, version, locale).

Partial unique (kind, key, locale) WHERE status = ACTIVE запрещает две одновременно активные редакции одного контента.

Одновременно активная версия не означает перезапись старой: interpretation/notification всегда ссылаются на точный release ID.

forecast_inputs

Immutable детерминированный слой: id, user_id, birth_profile_id, period_kind DAY|WEEK|MONTH, period_start, period_end, algorithm_version, input_hash, result_json, result_hash. Unique (birth_profile_id, period_kind, period_start, algorithm_version, input_hash).

interpretation_requests

Поля: id, user_id, calculation_set_id, forecast_input_id nullable, kind CORE_READING|ROUTE|FORECAST, period_start/end nullable, locale, status QUEUED|RUNNING|READY|FAILED|REJECTED, provider_key_snapshot, model_snapshot, context_schema_version, context_snapshot_json, context_hash, generation_key, evidence_mapping_release_id, prompt_release_id, safety_policy_release_id, artifact_id nullable, last_error_code, completed_at, state_version.

CHECK: период и forecast_input_id обязательны только для FORECAST. Unique (user_id, generation_key) делает запрос пользователя идемпотентным, а глобально уникальный artifact cache остаётся в interpretation_artifacts. Generation key включает hashes calculation/forecast sources, context_hash, kind/period, releases, provider/model snapshot и locale. Versioned context snapshot фиксирует только разрешённый контекст целей пользователя, помеченный как untrusted data; изменение goal после запуска не меняет уже созданную интерпретацию.

interpretation_sources

Join-table с полями interpretation_request_id, calculation_artifact_id, source_role, artifact_hash_snapshot. Primary key (interpretation_request_id, calculation_artifact_id, source_role). Она фиксирует точные входы и не даёт подменить их новым текущим calculation set.

ai_generation_attempts

Поля: id, interpretation_request_id, attempt_no, provider_key, model_snapshot, provider_request_id, status STARTED|SUCCEEDED|FAILED|REJECTED, input_tokens, output_tokens, cost_micros nullable, cost_currency nullable, latency_ms, finish_reason, error_code, moderation_result JSONB, started_at, finished_at.

Unique (interpretation_request_id, attempt_no) и частичный unique (provider_key, provider_request_id). Полный prompt и незаредактированный provider response не дублируются в этой таблице.

interpretation_artifacts

Успешный immutable output: id, generation_key UNIQUE, schema_version, input_hash, output_json JSONB, output_hash, provider_key, model_snapshot, provider_request_id, IDs mapping/prompt/policy releases, usage_json, moderation_snapshot, published_at.

API выдаёт artifact только через принадлежащий пользователю interpretation_requests. Смена provider/model создаёт новый generation key и artifact.

7. Каталог, подписка и платежи

erDiagram
    PRODUCTS ||--o{ PRODUCT_FEATURES : grants
    PRODUCTS ||--o{ PRICES : priced_as
    USERS ||--o{ ORDERS : places
    ORDERS ||--|{ ORDER_ITEMS : contains
    USERS ||--o{ SUBSCRIPTIONS : owns
    PRICES ||--o{ SUBSCRIPTIONS : selected_price
    SUBSCRIPTIONS ||--o{ SUBSCRIPTION_INVOICES : billed_by
    ORDERS ||--o| SUBSCRIPTION_INVOICES : represents
    ORDERS ||--o{ PAYMENT_ATTEMPTS : paid_by
    PAYMENT_ATTEMPTS ||--o{ REFUNDS : refunded_by
    USERS ||--o{ ENTITLEMENTS : receives
    ORDERS ||--o{ REFUND_REQUESTS : may_have

Каталог

products: id, code UNIQUE, kind SUBSCRIPTION|CONSULTATION, name, status DRAFT|ACTIVE|RETIRED, metadata_version.

product_features: product_id, feature_code, limits_json. Primary key (product_id, feature_code). Примеры features: CORE_READING, ROUTE, FORECAST_DAY, FORECAST_WEEK, FORECAST_MONTH, GOALS.

prices: id, product_id, version, amount_minor, currency, billing_type ONE_TIME|RECURRING, interval_unit DAY|WEEK|MONTH|YEAR nullable, interval_count nullable, active_from, active_to, status DRAFT|ACTIVE|RETIRED, receipt_item_snapshot JSONB. Unique (product_id, version); CHECK amount > 0 и трёхбуквенная валюта.

Для ONE_TIME interval-поля пусты; для RECURRING они обязательны и interval_count > 0. Partial unique (product_id, currency) WHERE status = ACTIVE оставляет одну продаваемую цену продукта; разные будущие тарифы являются разными products и могут быть активны одновременно.

В MVP seed активирует один subscription product с одной месячной RUB-ценой. Другие тарифы добавляются новыми products/product_features/prices.

Заказы

orders: id, user_id, status CREATED|PAYMENT_PENDING|PAID|CANCELED|EXPIRED, currency, subtotal_minor, total_minor, policy_version, idempotency_key, paid_at, expires_at, state_version. Unique (user_id, idempotency_key).

order_items: id, order_id, product_id, price_id nullable, practitioner_service_id nullable, quantity, unit_amount_minor, total_amount_minor, name_snapshot, receipt_snapshot JSONB.

SQL CHECK требует ровно один источник цены: catalog price_id либо practitioner_service_id. После перехода заказа в PAYMENT_PENDING snapshots и суммы immutable. Promo/discount в MVP нет, поэтому order.total = sum(items.total).

Подписки и entitlement

subscriptions: id, user_id, product_id, price_id, status PENDING|ACTIVE|PAST_DUE|CANCELED|EXPIRED, current_period_start/end, cancel_at_period_end, cancel_requested_at, ended_at, payment_method_reference_id, grace_ends_at, state_version.

Partial unique запрещает две одновременно действующие подписки одного user + product в PENDING|ACTIVE|PAST_DUE.

subscription_invoices: id, subscription_id, order_id UNIQUE, period_start, period_end, sequence_no, status OPEN|PAID|VOID|UNCOLLECTIBLE. Unique (subscription_id, period_start) и (subscription_id, sequence_no) гарантирует один invoice за период.

entitlements: id, user_id, feature_code, grant_kind PAID|GRACE|ONE_TIME, source_invoice_id nullable, source_order_item_id nullable, source_subscription_id nullable, starts_at, ends_at, revoked_at, revoke_reason, limits_snapshot JSONB.

CHECK требует ровно один source. PAID ссылается на оплаченный invoice, ONE_TIME — на order item, GRACE — на subscription и её versioned policy. Unique по source + feature + интервалу запрещает повторную выдачу. Доступ проверяется по времени и revoked_at IS NULL, а не по одному subscription.status.

YooKassa

payment_method_references: id, user_id, provider YOOKASSA, provider_method_id, status ACTIVE|DISABLED, reusable, title_masked, saved_at, disabled_at. Unique (provider, provider_method_id). Реквизитов карты нет.

payment_attempts: id, order_id, provider, provider_payment_id nullable, idempotency_key UNIQUE, status CREATED|PENDING|WAITING_FOR_CAPTURE|SUCCEEDED|CANCELED, amount_minor, currency, confirmation_url nullable, payment_method_reference_id nullable, provider_snapshot JSONB, failure_code, succeeded_at, state_version.

Provider payment ID уникален, когда заполнен. Сумма/currency должны совпадать с order. confirmation_url очищается после истечения операционной необходимости.

provider_events: id, provider, event_type, provider_object_type, provider_object_id, payload_hash, payload_redacted JSONB, status RECEIVED|VERIFYING|PROCESSED|IGNORED|FAILED, received_at, processed_at, last_error_code. Unique (provider, event_type, provider_object_id, payload_hash).

Возвраты и ledger

refund_requests: id, order_id, requested_by_user_id, amount_minor nullable, reason_code, comment, status REQUESTED|ACCEPTED|REJECTED|WITHDRAWN, policy_version, resolved_at.

refunds: id, payment_attempt_id, refund_request_id nullable, provider_refund_id, idempotency_key UNIQUE, amount_minor, currency, status CREATED|PENDING|SUCCEEDED|CANCELED, reason_code, provider_snapshot JSONB, succeeded_at. Provider refund ID unique, когда заполнен.

Payment/order не меняются на искусственный статус «refunded»: возвращённая сумма агрегируется из успешных refunds.

ledger_transactions: immutable header id, operation_type, payment_attempt_id nullable, refund_id nullable, settlement_id nullable, idempotency_key UNIQUE, occurred_at, description_code. SQL CHECK требует ровно один FK-источник и не допускает финансовую ссылку только через пару source_type/source_id.

ledger_entries: id, transaction_id, account_code, direction DEBIT|CREDIT, amount_minor, currency, user_id nullable, practitioner_id nullable. Для каждого transaction/currency сумма debit равна credit; проверка выполняется deferred constraint trigger и тестами.

practitioner_settlements: id, practitioner_id, period_start/end, gross_minor, platform_fee_minor, provider_fee_minor, refund_minor, payable_minor, currency, status DRAFT|READY|PAID|CANCELED, provider_transfer_id nullable, paid_at. До pre-pilot используется только ручной settlement через allowlisted операционную команду с audit event; admin endpoint отсутствует.

8. Цели, практики и консультации

Цели

goals: id, user_id, kind GOAL|REQUEST, area, text, target_at, status ACTIVE|COMPLETED|ARCHIVED|CANCELED, completed_at, state_version.

goal_check_ins: immutable id, goal_id, result ACHIEVED|PARTIAL|NOT_ACHIEVED|SKIPPED, note, occurred_at. Индекс (goal_id, occurred_at DESC). Check-in не переписывает историю; текущая статистика строится агрегацией.

Практики

practitioner_profiles: id, user_id nullable UNIQUE, public_slug UNIQUE, accepting_requests, status DRAFT|APPROVED|SUSPENDED|RETIRED, display_name, bio, experience_years, verification_note, approved_at. В MVP строки создаются seed/controlled script.

Профиль без user допустим как заготовка каталога, но accepting_requests = true требует связанного user с ролью PRACTITIONER и статусом профиля APPROVED.

specialties: id, code UNIQUE, name, active.

practitioner_specialties: primary key (practitioner_id, specialty_id).

practitioner_services: id, practitioner_id, code, name, description, duration_minutes, amount_minor, currency, status ACTIVE|RETIRED, version. Unique (practitioner_id, code, version). Изменение цены создаёт новую version.

practitioner_documents: id, practitioner_id, document_type, storage_key, content_hash, review_status PENDING|VERIFIED|REJECTED, reviewed_at. Содержимое хранится не в PostgreSQL, а в закрытом object storage. Публичного download endpoint нет.

practitioner_cases: id, practitioner_id, title, body, status DRAFT|PUBLISHED|RETIRED, published_at. В MVP publication выполняется seed/controlled content process.

Консультации

erDiagram
    USERS ||--o{ CONSULTATIONS : client
    PRACTITIONER_PROFILES ||--o{ CONSULTATIONS : practitioner
    PRACTITIONER_SERVICES ||--o{ CONSULTATIONS : selected_service
    BIRTH_PROFILES ||--o{ CONSULTATIONS : shared_profile
    CONSULTATIONS ||--o{ CONSULTATION_TIME_OPTIONS : proposes
    CONSULTATIONS ||--o{ CONSULTATION_TRANSITIONS : records
    CONSULTATIONS ||--o{ CONSULTATION_ORDERS : paid_by
    ORDERS ||--o| CONSULTATION_ORDERS : links

consultations: id, client_user_id, practitioner_id, service_id, birth_profile_id nullable, request_text, status REQUESTED|ACCEPTED|DECLINED|SCHEDULING|AWAITING_PAYMENT|CONFIRMED|COMPLETED|NO_SHOW|CANCELED_BY_CLIENT|CANCELED_BY_PRACTITIONER|DISPUTED|CLOSED, policy_version, scheduled_start_at nullable, scheduled_end_at nullable, client_timezone_id, completed_at, state_version.

consultation_time_options: id, consultation_id, proposed_by_user_id, starts_at, ends_at, timezone_id, status PROPOSED|ACCEPTED|WITHDRAWN. Один вариант может быть accepted; принятие атомарно заполняет scheduled interval консультации. Это структурные поля согласования, не календарная интеграция и не чат.

Partial unique (consultation_id) WHERE status = ACCEPTED обеспечивает один принятый интервал.

consultation_transitions: immutable id, consultation_id, from_status, to_status, actor_user_id nullable, actor_type USER|SYSTEM, reason_code, policy_version, request_id, occurred_at.

consultation_orders: id, consultation_id, order_id UNIQUE, is_current, linked_at. Partial unique оставляет один current order на консультацию, но после canceled/expired order позволяет создать новый. Оплата консультации не создаёт subscription entitlement.

Отдельной таблицы messages или chat в MVP нет. Доступ практика к birth_profile разрешён service-слоем только для допустимых активных состояний и фиксируется domain_audit_events.

9. Уведомления, аналитика и операции

E-mail

notification_preferences: user_id, topic, enabled, timezone_id, quiet_hours_start/end nullable. Primary key (user_id, topic). Transactional security/payment e-mail не отключаются marketing preference.

notification_deliveries: id, user_id nullable, otp_challenge_id nullable, topic, template_release_id, destination_hash, payload_version, payload_redacted JSONB, status QUEUED|SENDING|SENT|DELIVERED|BOUNCED|COMPLAINED|FAILED|SUPPRESSED, next_attempt_at, sent_at, delivered_at. OTP code не попадает в persisted payload.

notification_attempts: id, delivery_id, attempt_no, provider RESEND, provider_message_id, status STARTED|ACCEPTED|FAILED, error_code, started_at, finished_at. Unique (delivery_id, attempt_no) и provider message ID.

OTP-письмо — особый синхронный flow: challenge с digest фиксируется транзакцией, после commit исходный код из памяти передаётся Resend и сохраняется только результат попытки. Неуспешная отправка не ретраится из persisted payload: следующий request создаёт новый challenge и новый код. Обычные транзакционные письма отправляются асинхронно.

Attribution и продуктовые события

anonymous_visitors: id, public_id_digest UNIQUE, first_seen_at, claimed_by_user_id nullable, claimed_at, expires_at.

attribution_touches: id, visitor_id, user_id nullable, touch_type FIRST|LAST|OTHER, utm_source, utm_medium, utm_campaign, utm_content, utm_term, referrer_host, landing_path, captured_at.

analytics_events: id, event_name, event_version, visitor_id nullable, user_id nullable, occurred_at, request_id, properties JSONB. Event name и properties валидируются allowlist/schema registry в коде. Birth data, e-mail, goal/consultation text и AI output запрещены.

Надёжные операции

idempotency_records: id, scope, actor_key, idempotency_key, request_hash, status IN_PROGRESS|COMPLETED|FAILED, response_status nullable, response_json_redacted nullable, locked_until, expires_at. Unique (scope, actor_key, idempotency_key). Повтор с другим request hash получает conflict.

outbox_events: id, event_type, event_version, aggregate_type, aggregate_id, payload JSONB, idempotency_key UNIQUE, status PENDING|PUBLISHED|FAILED, available_at, published_at, attempt_count.

jobs: id, job_type, payload_version, payload JSONB, idempotency_key UNIQUE, status QUEUED|RUNNING|RETRY_WAIT|SUCCEEDED|DEAD|CANCELED, priority, attempt_count, max_attempts, next_run_at, lease_owner, lease_expires_at, last_error_code, finished_at.

Worker забирает jobs индексом (status, next_run_at, priority) через FOR UPDATE SKIP LOCKED; отдельный индекс (status, lease_expires_at) возвращает зависшие leases. Payload содержит ID, а не копии чувствительных объектов.

domain_audit_events: immutable id, actor_type USER|SYSTEM|OPERATOR_SCRIPT, actor_user_id nullable, action, target_type, target_id, request_id, reason_code, metadata_redacted JSONB, occurred_at. Таблица фиксирует security/financial/access transitions, но не является admin audit log.

privacy_requests: id, user_id, kind EXPORT|DELETE, status REQUESTED|PROCESSING|COMPLETED|REJECTED, requested_at, completed_at, result_storage_key nullable, expires_at, failure_code.

10. State machines

  • Calculation item: QUEUED → RUNNING → SUCCEEDED/FAILED; FAILED → QUEUED только безопасным retry; BLOCKED терминален для текущего input.
  • Interpretation request: QUEUED → RUNNING → READY/FAILED/REJECTED; retry создаёт новый attempt, не стирая старый.
  • Order: CREATED → PAYMENT_PENDING → PAID; из CREATED/PAYMENT_PENDING допустимы CANCELED или EXPIRED.
  • Payment attempt: CREATED → PENDING/WAITING_FOR_CAPTURE → SUCCEEDED/CANCELED; terminal status не откатывается.
  • Subscription: PENDING → ACTIVE → PAST_DUE → ACTIVE/EXPIRED; ACTIVE → CANCELED происходит только на границе оплаченного периода.
  • Refund: CREATED → PENDING → SUCCEEDED/CANCELED.
  • Consultation: REQUESTED → ACCEPTED/DECLINED/CANCELED_BY_CLIENT; затем ACCEPTED → SCHEDULING → AWAITING_PAYMENT → CONFIRMED → COMPLETED/NO_SHOW. Отмена практика/клиента, dispute и close разрешаются только versioned policy.
  • Job: QUEUED → RUNNING → SUCCEEDED/RETRY_WAIT/DEAD; истёкший lease возвращает работу в retry.

Каждый transition:

  1. выполняется use case-ом под row lock или с условием state_version = expected;
  2. проверяет текущий статус, роль и policy version;
  3. увеличивает state_version;
  4. в той же транзакции создаёт domain audit/outbox event;
  5. не запускает сетевой вызов внутри открытой DB-транзакции.

11. Критические транзакции

Claim onboarding draft после OTP

  1. Lock draft и проверить OPEN + expires_at.
  2. Найти/создать users по e-mail, выдать CLIENT.
  3. Привязать birth profile и consent acceptances к user, назначить revision/current profile.
  4. Пометить draft CLAIMED и visitor claimed_by_user_id.
  5. Commit. Повтор возвращает тот же результат либо conflict при другом user.

Завершение расчёта

  1. Worker получает item lease через jobs.
  2. Вне транзакции запускает engine.
  3. В транзакции делает insert-or-select artifact по unique cache key, связывает item, создаёт evidence facts.
  4. Пересчитывает aggregate status calculation set и создаёт outbox event.

Подтверждение платежа

  1. Webhook durable-сохраняется в provider_events и быстро отвечает после commit.
  2. Worker делает authenticated GET в YooKassa вне транзакции.
  3. В транзакции lock-ает payment/order, сверяет shop/object/amount/currency/metadata и выполняет monotonic transition.
  4. Один раз создаёт/обновляет subscription invoice, entitlement, ledger transaction и notification outbox.
  5. Помечает provider event processed. Unique constraints превращают повтор webhook в no-op.

Продление

  1. Unique (subscription_id, period_start) резервирует invoice.
  2. Payment attempt создаётся со стабильным idempotency key до сетевого запроса.
  3. При успехе период сдвигается от старой границы, а не от времени callback.
  4. При отказе обновляются PAST_DUE/grace и следующий job; двойной invoice невозможен.

AI generation

  1. Interpretation request фиксирует sources и releases.
  2. Unique generation key возвращает существующий artifact либо создаёт job.
  3. Provider вызывается вне транзакции.
  4. После schema/evidence/safety validation одной транзакцией сохраняются attempt, artifact, request READY и outbox.

12. Foreign keys и удаление

  • CASCADE допустим только для чистых дочерних записей, не имеющих самостоятельного юридического/аудитного смысла: user roles, specialties join, calculation set items до появления artifact.
  • RESTRICT применяется к prices, orders, payments, invoices, refunds, ledger, consent/document versions, engine/content releases и опубликованным artifacts.
  • SET NULL допустим для actor-ссылок audit/transition после анонимизации, но target ID и redacted metadata остаются.
  • User API не выполняет физический DELETE users. Workflow очищает/псевдонимизирует профильные поля, отзывает sessions, удаляет неподлежащие хранению тексты и ставит tombstone.
  • Общие cached artifacts удаляются только когда на них нет source/item links и истёк retention period.
  • Practitioner documents удаляются из object storage отдельной подтверждаемой задачей; строка хранит tombstone/hash согласно утверждённой policy.

13. Индексы

Обязательные индексы первой миграции:

  • users(email_normalized) UNIQUE;
  • otp_challenges(email_normalized, created_at DESC) и otp_challenges(expires_at);
  • sessions(token_digest) UNIQUE, sessions(user_id, expires_at DESC), sessions(family_id);
  • partial unique current birth_profiles(user_id);
  • partial unique current birth_profiles(source_draft_id);
  • calculation_sets(birth_profile_id, created_at DESC);
  • calculation_set_items(status, created_at) для worker/recovery;
  • unique cache calculation_artifacts(engine_release_id, normalized_input_hash);
  • partial unique active content_releases(kind, key, locale);
  • interpretation_requests(user_id, created_at DESC) и unique (user_id, generation_key);
  • global unique interpretation_artifacts(generation_key);
  • orders(user_id, created_at DESC);
  • partial unique active prices(product_id, currency);
  • unique provider IDs/idempotency keys payment/refund;
  • subscriptions(user_id, status) и subscriptions(current_period_end);
  • entitlements(user_id, feature_code, starts_at, ends_at) WHERE revoked_at IS NULL;
  • consultations(client_user_id, created_at DESC) и (practitioner_id, status, scheduled_start_at);
  • partial unique accepted consultation_time_options(consultation_id);
  • partial unique current consultation_orders(consultation_id);
  • jobs(status, next_run_at, priority) и jobs(status, lease_expires_at);
  • outbox_events(status, available_at);
  • notification_deliveries(status, next_attempt_at);
  • BRIN analytics_events(occurred_at), domain_audit_events(occurred_at), provider_events(received_at).

GIN на JSONB не создаётся «на всякий случай». Он добавляется только под подтверждённый query plan. На объёме пилота партиционирование не нужно; первыми кандидатами после роста являются analytics, audit и provider events по месяцу.

14. Временные правила хранения

До утверждения pre-pilot privacy gate используются конфигурируемые internal defaults:

Данные Internal default
OTP challenges удалять через 24 часа после expiry/consumption
Rate buckets 7 дней
Unclaimed onboarding drafts 30 дней либо раньше по expires_at
Revoked/expired sessions 30 дней
Успешные job/outbox payload 30 дней; DEAD — 90 дней
Notification payload/attempt metadata 90 дней, без OTP и полного e-mail
Raw analytics events 90 дней для internal
User calculations/interpretations/goals пока существует аккаунт или до privacy workflow
Финансы, consent, audit не удалять общим cleanup; срок определяет утверждённая legal policy

Значения задаются конфигурацией cleanup jobs, а не hardcode в таблицах. Временные defaults нельзя автоматически переносить на внешний пилот.

15. Что намеренно отсутствует

  • admins, admin roles, admin sessions, permissions matrix и /admin audit;
  • messages/chat, video rooms, external calendar events и Telegram identities;
  • promo codes, referrals, loyalty, tips, goods/courses;
  • универсальные polymorphic links без FK для финансовых источников;
  • plaintext OTP/refresh/payment credentials;
  • возможность вручную изменить calculation/AI artifact или поставить payment SUCCEEDED;
  • hardcoded ID единственного тарифа.

Будущий admin module проектируется отдельной миграцией поверх доменных use cases после MVP.

16. Порядок реализации Prisma schema

  1. Базовые enums, users/auth, anonymous visitors/attribution, legal documents и onboarding/birth profiles.
  2. Engine releases, calculation sets/items/artifacts и evidence facts.
  3. Content releases, forecast/interpretation records и AI attempts.
  4. Goals, practitioner catalog/services/documents и subscription product/price/features.
  5. Order/payment/subscription/entitlement/refund/ledger, затем consultations и structured time options.
  6. Notifications, analytics events, idempotency, jobs/outbox, audit и privacy requests.
  7. Raw SQL migration для CHECK, partial unique, BRIN и deferred ledger constraints, которые Prisma schema не выражает.
  8. Seed: legal/content versions, четыре engine releases, specialties, один subscription product/price/features и тестовые practitioners.

Для каждой миграции нужны forward migration, проверка на пустой и наполненной БД, rollback/runbook и prisma validate.

17. Критерии согласования перед кодом

  • каждая таблица имеет владельца-модуль и понятный lifecycle;
  • все API из чернового контракта имеют источник данных;
  • неизвестное время не допускает скрытого astrology/HD artifact;
  • смена методики, content mapping или AI provider не переписывает историю;
  • один webhook/retry не создаёт второй платёж, invoice, entitlement или ledger transaction;
  • добавление второго тарифа не требует изменения схемы;
  • консультация работает без chat/calendar/video и имеет отдельный payment flow;
  • удаление пользователя не разрушает финансовую целостность;
  • ни одна MVP-таблица не требует admin-панели для штатной работы.