Multi-Geography / Product / Group Architecture
Как родился этот документ: брейншторм с 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_amount → product.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.
Плумбинг: 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.
Два бага (чинить до открута):
- Баг #1 —
save_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 · Риски
- Default-1 leak (Баг #1) — до Phase 0 все заявки Алматы = город 1. Non-negotiable до открута.
- Date-string matching (Баг #2) — тихий cross-city merge; в том же PR, что events read-path.
- Релокация цены — трогает все денежные метрики + payment flow (сумма к оплате = цена продукта). Бэкфилл до переключения чтений.
- Single dev bot — у входящих нет города; payload→hypothesis→default; ок для dev.
- Перф all-режима — композитные индексы + пагинация.
- Контеншн ветки — feature/marketing-hub активно правит параллельная сессия; спека на своей ветке.
§8 · Открытые вопросы (на ревью)
- Хранение гео-конфига: где живут язык/legal/роутинг нотификаций и как применяется язык к текстам бота — колонки в
citiesvs таблицаgeography_overrides? (Леню к колонкам для v1; per-language копия бота — реальный открытый кусок.) - Жизненный цикл географии: draft→active→archived; когда появляется в фильтре; landing-домены; что видно до запуска (Алматы сейчас draft).
- Атрибуция сообщений при одном боте + cross-city юзер: «по последнему городу заявки»? Низкие ставки, подтвердить.
- Роутинг админ-нотификаций 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.