Superpowers Brainstorming

Connected

Multi-Geography / Product / Group Architecture

Status: draft for owner review · 2026-05-28 · ветка docs/multi-geo-architecture (worktree от main) · коммит 5a544d2
Supersedes: 2026-05-28-multi-city-admin-architecture.md (удалён с feature/marketing-hub в c96e2dc; валидные части вложены сюда).
Как родился этот документ: брейншторм с owner + фидбек команды. Исходная спека предполагала Teams-style single-tenant свитчер; фидбек переформатировал в multi-geography операционную консоль с иерархией География → Продукт → Дата → Группа и product_type как осью аналитики. Номера строк из старой спеки намеренно убраны — грепать по символам на этапе имплементации.

§0 · TL;DR

Convive идёт в мультирынок. Сегодня админка single-tenant во всём, кроме названия: таблица cities и колонки city_id есть, но большинство запросов читают все строки по всем городам, и два латентных бага портят данные в момент запуска второго рынка (§7). Спека определяет:

  • Иерархию: География → Продукт → Дата(оккуренция) → Группа. Маркетинговые гипотезы = продукты; «столы»/cohorts = группы; events = датированные оккуренции. product_type (dinner, hiking…) — ось для роллапов, не сущность.
  • UI-модель: multi-geography операционная консоль — каждая scoped-страница по умолчанию «Все географии» + per-page (sticky) фильтр + city-бейджи в строках, плюс опц. топбар «focus-geography». Заменяет Teams-модель.
  • Атрибуцию и боты (Phase 0): per-city боты в prod (идентичность бота = география), один общий бот в dev; чиним save_registration (пишет city_id + scope dedup SELECT) и матч событий по строке-дате. Обязательно до открута Алматы.

Деньги: цена переезжает с географии на продукт → выручка/LTV/ROAS/окупаемость считаются от product.price, не cities.price_amount.

§1 · Почему изменение (фидбек команды)

Первый дизайн предлагал Teams/Slack-style глобальный свитчер: выбрал город — вся админка под него. Фидбек: на сколько-нибудь значимом числе городов «заходить в каждый» больно — хотим «все ужины недели» / «все коммуникации» в одном месте, с возможностью на каждой странице переключать all vs конкретный город.

Это верно для продукта из-за того, кто такой админ:

  • Teams «один воркспейс за раз» — для конечного пользователя одного тенанта. Переключение редкое.
  • Операционная консоль (all-by-default + per-view фильтр) — для оператора многих тенантов, которому нужны сквозные срезы. Так работают Datadog, Sentry («All Projects»), Cloudflare (список зон), Vercel, консоли управления сетями.

Админ Convive — оператор (HQ), а не житель города. На 10–100 географиях основная работа сквозная → побеждает консоль. DECIDED 2026-05-28

§2 · Иерархия (хребет модели данных)

ГЕОГРАФИЯ (рынок)        привязки: валюта · часовой пояс · язык · эквайринг · legal (оферта, юрлицо)
  └── ПРОДУКТ  [product_type: dinner | hiking | …]   ≈ нынешняя «гипотеза»; несёт ЦЕНУ
        └── ДАТА / ОККУРЕНЦИЯ                          ≈ нынешний «event»
              └── ГРУППА (по аудитории)                ≈ нынешний «cohort» / «стол»

§2.1 Уровни — сущность vs ось

КонцептТипТаблицаИзменение
Географиясущностьcities+language, +legal/offer; −price_amount (на продукт). валюта/tz/эквайринг уже есть.
Продуктсущностьhypotheses+product_type (значение оси) +price. Продукт == гипотеза пока (вар.3).
product_typeоськолонка на hypotheses (денорм. на registrations, ad_spend)НОВОЕ. Метка для GROUP BY, не таблица.
Дата/оккуренциясущностьevents+ссылка на продукт; нести product_type для аналитики.
Группасущностьcohortsпереименовать концепт «стол» → «группа»; таблица та же.

Сущность vs ось: сущность = своя таблица/PK, свои атрибуты и жизненный цикл, ссылаются по FK (география, продукт, дата, группа, user, registration). Ось = значение в колонке, по которому GROUP BY/фильтр, без своей жизни (product_type, source, campaign, creative, период). product_type сейчас — ось; повышаем до сущности (product_types) только когда у «dinner» появятся свои атрибуты. Это и есть граница YAGNI.

§2.2 Цена — на продукте

cities.price_amountproduct.price (т.е. hypotheses.price), в валюте географии. «Женский ужин», обычный «ужин», «хайкинг» могут стоить по-разному в одном рынке. География держит валюту, но не сумму. Миграция: бэкфилл цены каждого ташкентского продукта из legacy cities.price_amount. Per-date override цены — отложено (nullable event.price_override позже, если появится кейс).

§2.3 Продукт == гипотеза (вариант 3, пока)

Добавляем product_type + price колонками на hypotheses, трактуем гипотезу как продукт. Отдельную таблицу products пока НЕ вводим — эволюционируем, когда появится не-ужин (хайкинг). «Женский ужин» — не отдельный product_type: это гипотеза типа dinner, считается отдельно на уровне гипотезы, роллапится с прочими ужинами на уровне product_type.

§3 · UI — операционная консоль DECIDED

  • Дефолт = «Все географии» на каждой scoped-странице (первоклассный агрегат).
  • Per-page фильтр города, контрол на каждой странице, выбор запоминается per-page (localStorage['convive.cityFilter.<tab>']) — можно смотреть all-ужины и фильтровать messages по 1 городу одновременно.
  • City-бейдж в строке когда вид >1 города; в фильтре одного города — скрыт.
  • Опц. топбар «focus-geography» (B+): задаёт дефолт для страниц, где фильтр не трогали (онбординг города). Не лочит страницы со своим фильтром. Можно после core.

Scoped vs global: GLOBAL — cities, settings*, releases, files, users**, user-detail. SCOPED — events, event-detail, crm, messages, chains, fb/p2p-journal, analytics, marketing, marketing-hub.
* settings глобальны в v1; per-geo контент (язык/legal) — открытый вопрос §8. ** users глобальны; user-detail получает разбивку «участие по географиям».

Плумбинг: api() авто-добавляет ?city_id= для whitelist scoped-префиксов; all → без параметра. Бэкенд — общий Depends(city_scope) (как уже зашипленные venues?city_id= / marketing/cities/{id}/). Безопасность «не тот город»: бейдж в строке + город в окнах подтверждения действий.

§4 · Атрибуция и топология ботов (Phase 0)

Боты: prod — один user-бот на географию (cities.go_bot_token), токен = география → авторитетный city_id. Dev — один общий бот. Режим CONVIVE_BOT_MODE = per_city | single. Рефактор poll_go_bot → poll_city_bots (реестр из cities WHERE go_bot_token IS NOT NULL, per-bot offset-курсоры).

Резолв города: 1) биндинг входящего бота (авторитет) → 2) city-токен в payload → 3) campaign→hypothesis→city → 4) default (prod=1, dev=настраиваемый). Single-bot dev пропускает шаг 1.

Два бага (чинить до открута):

  • Баг #1save_registration не пишет city_id → дефолт 1. Dedup-SELECT #97 (telegram+date, без города) усугубляет: cross-city клэш даты схлопывает заявку Алматы в ташкентскую. Чиним INSERT + dedup SELECT вместе.
  • Баг #2 — события матчатся по строке-дате (find_event_by_date + много WHERE date=? AND status…, расширено reschedule #84) без города → два рынка с «10 июня» сливаются. Заскоупить всё по городу.
  • Ужесточить _chain_reg_scope_ok: chain.city_id == reg.city_id когда город чейна задан.

§5 · Маркетинговая аналитика в иерархии

ГЕОГРАФИЯ ⊇ product_type (dinner) ⊇ ГИПОТЕЗА (обычный/женский ужин) ⊇ ДАТА ⊇ ГРУППА
 (city_id)    (ось, GROUP BY)         (hypothesis_id, считаем отдельно)
  • Per-гипотеза разбивка уже работает (marketing-hub); у каждой свои funnel/CAC/LTV и своя price.
  • Per-product_type — новый GROUP BY product_type поверх гипотез. Cross-geo сумма по типу — дименшн заложен, сам вид отложен (YAGNI).
  • Деньги от product.price, не city: LTV, выручка, ROAS, окупаемость пересчитываются. Бэкфилл истории Ташкента из legacy city-цены.
  • Денормализуем product_type на registrations + ad_spend → дешёвый GROUP BY.

§6 · Миграции и обратная совместимость

  • Схема (additive): hypotheses +product_type +price; cities +language +legal, deprecate price_amount; registrations/ad_spend +product_type; events — связь с продуктом.
  • Бэкфиллы (идемпотентно через _meta): цена продукта ← legacy city; product_type='dinner' везде; city_id истории уже =1.
  • Индексы: idx_regs_city + композиты registrations(city_id,date), registrations(city_id,hypothesis_id), ad_spend(city_id,hypothesis_id), bot_events(city_id,event_type).
  • Не ломать Ташкент: каждый API city_id опционален (отсутствует = без фильтра = текущие all-up цифры); NULL-чейны системные; регресс-тест на неизменность счётчиков.

§7 · Риски

  1. Default-1 leak (Баг #1) — до Phase 0 все заявки Алматы = город 1. Non-negotiable до открута.
  2. Date-string matching (Баг #2) — тихий cross-city merge; в том же PR, что events read-path.
  3. Релокация цены — трогает все денежные метрики + payment flow (сумма к оплате = цена продукта). Бэкфилл до переключения чтений.
  4. Single dev bot — у входящих нет города; payload→hypothesis→default; ок для dev.
  5. Перф all-режима — композитные индексы + пагинация.
  6. Контеншн ветки — feature/marketing-hub активно правит параллельная сессия; спека на своей ветке.

§8 · Открытые вопросы (на ревью)

  1. Хранение гео-конфига: где живут язык/legal/роутинг нотификаций и как применяется язык к текстам бота — колонки в cities vs таблица geography_overrides? (Леню к колонкам для v1; per-language копия бота — реальный открытый кусок.)
  2. Жизненный цикл географии: draft→active→archived; когда появляется в фильтре; landing-домены; что видно до запуска (Алматы сейчас draft).
  3. Атрибуция сообщений при одном боте + cross-city юзер: «по последнему городу заявки»? Низкие ставки, подтвердить.
  4. Роутинг админ-нотификаций per-geography (cities.admin_chat_ids есть) — скрины оплат/новые заявки Алматы → админам Алматы. В Phase 0 или fast-follow?

§9 · Фазы

  • Phase 0 — Атрибуция + боты (до трафика Алматы): save_registration city_id + dedup SELECT; date-collision; poll_city_bots + CONVIVE_BOT_MODE; индексы; _chain_reg_scope_ok; роутинг нотификаций (или fast-follow).
  • Phase 1 — Иерархия + read-path: product_type+price на hypotheses, релокация цены + бэкфилл, денормализация; city-scope events (+фикс счётчика), crm, analytics, marketing; консоль-фильтр + api() авто-параметр + per-page persistence + бейджи.
  • Phase 2 — Comms: messages, chains UI, fb/p2p-journal.
  • Phase 3 — Polish: опц. focus-geography, участие по географиям в user-detail, lifecycle UI, per-geo settings/язык, cross-geo product_type роллап-вид.

§10 · Критичные файлы (грепать символы на имплементации)

  • db.py — save_registration (city_id + dedup SELECT), find_event_by_date + все WHERE date=?, get_registrations/get_events(+count)/get_messages/get_all_chat_users/get_chains/аналитика (funnel_*, cac_*, ltv_*, retention_*) + city_id (+ опц. hypothesis_id/product_type); миграции product_type/price/индексы/бэкфиллы.
  • bot_server.py — poll_go_bot → poll_city_bots + CONVIVE_BOT_MODE; per-bot offset; city_scope рядом с require_admin; _chain_reg_scope_ok; scoped endpoints; роутинг нотификаций.
  • admin.html — консоль-фильтр per scoped tab, api() авто-параметр + persistence, city-badge, product_type/product колонки, убрать глобальный Teams-свитчер.
  • routes/marketing/hypotheses.py — эталон city-scoped роута; +product_type +price.

— конец спеки —

Click an option above, then return to the terminal