← всі звіти · lunar-hubble-v2-architecture-2026-05-02.md

Lunar Hubble v2 — Архітектурний аудит та план MVP v2

Дата: 2026-05-02 Аудитор: VPS Claude (Opus 4.7) Замовник: Сергій (Digital Lead Deltamedical) Поточний live: https://lunar-hubble-105435845108.europe-west1.run.app/ Поточне репо: /srv/projects/lunar-hubble/


TL;DR

Lunar Hubble v1 — функціональний MVP (91 person, 144 relationships, 1 story, 1 photo у БД), але має 3 фундаментальні проблеми: (1) RLS Supabase = USING (true) — anon-key з публічного коду має повний CRUD до родинного архіву; (2) "extended fields" зберігаються JSON-в-notes замість колонок — кожна правка в коді тягне парсинг у трьох місцях; (3) PIN-театр + 25+ debug-скриптів у корені роблять супровід нестерпним. План v2: перейти на Next.js 15 + Postgres (Supabase) з нормалізованою схемою + одна graph-бібліотека (React Flow) + aiogram-бот на VPS systemd із faster-whisper + Gemini для voice→entity-tagging→approve→DB. Roadmap — 3 спринти по 1 тижню.


1. Аудит проблем v1 (свіже сканування 2026-05-02)

1.1. Що рендериться в проді

1.2. Стан БД (read-only probe через anon-key VITE_SUPABASE_KEY)

Таблиця Записів Доступ для anon
persons 91 SELECT 200 OK
relationships 144 SELECT 200 OK
stories 1 SELECT 200 OK
photos 1 SELECT 200 OK
sources 0 SELECT 200 OK

WRITE-canary не виконував (потрібна окрема авторизація для модифікації прод-даних). Згідно supabase_migrations.sql:48-82 — policies = FOR ALL USING (true), отже технічно і WRITE відкритий для anon. Якщо хочеш підтвердження факту — дай дозвіл на canary INSERT/DELETE або сам перевір SQL editor:

SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname='public';
SELECT * FROM pg_policies WHERE schemaname='public';

1.3. Конкретні проблеми (з номерами рядків)

Безпека

  1. RLS = "all access" для всіх таблиць. supabase_migrations.sql:48-82 — для stories, story_participants, sources, source_citations, photos, photo_participants створено CREATE POLICY "Allow public all-access" ON ... FOR ALL USING (true). Таблиці persons та relationships у міграціях відсутні — невідомо, чи RLS взагалі ввімкнено. Anon-key = ключ адміна.
  2. PIN-театр у клієнтському JS. src/App.tsx:280-283: if (pinInput === '1234') setAuthRole('admin'). Будь-хто бачить PIN у view-source або в зоні Network/JS. Підказка з PIN надрукована на формі (App.tsx:316).
  3. Invite-token статичний рядок. App.tsx:272: params.get('token') === 'family-tree-invite-24'. Зашит у білдованому JS, після першої публічної ланки = знає весь Інтернет.
  4. anon-key захардкоджено у 9 debug-скриптах. check-error.js:3, check-error2.js, check-persons.js, check-schema.js, get-schema.js, guess-cols.js, import-data.js:5, importData.js:5, remove-orphans.js. Хоча ключ "publishable" — він всеодно adminbroker без RLS.
  5. Google Drive фото = 'type': 'anyone', 'role': 'reader' (tree_bot/gdrive.py:39-42) — кожне фото метричної книги після завантаження стає публічним за прямим лінком. Приватний родинний архів = публічний інтернет.
  6. Telegram бот тримає Supabase anon-key, а не service-role (tree_bot/db.py:11: os.environ.get("VITE_SUPABASE_KEY")). Серверний компонент не повинен ділити ключ із SPA — мав би використовувати service-role.
  7. Попередній аудит 2026-04-18 виявив 4 закомічені секрети в git: GitHub PAT, Gemini API key, Telegram bot token, Google SA private key. Файли tree_bot/.env та tree_bot/credentials.json досі лежать у /srv/projects/lunar-hubble/ (а отже, ймовірно, у git історії). Перевір git log --all -- tree_bot/.env tree_bot/credentials.json та чи ротовано ключі.

Архітектурний борг

  1. "Extended fields" зберігаються JSON-в-notes. App.tsx:107-113: if (p.notes.startsWith('{')) { const ext = JSON.parse(p.notes); Object.assign(fullPerson, ext); }. Те саме у D3Graph.tsx:111-119 (читає x_pos/y_pos з notes), gedcom.ts:69-75, NodeDrawer.tsx:33-40, D3Graph.tsx:239. Один person має дві правди: колонка birth_date І JSON.parse(notes).birth_date. Кожна нова фіча = ще один JSON.parse у коді.
  2. Дві бібліотеки візуалізації одночасно. package.json тримає d3@7.9, reactflow@11.11, dagre@0.8, react-force-graph-2d@1.29, react-force-graph-3d@1.29. Реально використовується тільки React Flow + dagre (D3Graph.tsx:1-15). d3 і react-force-graph-* — мертвий вантаж у бандлі (~+700KB).
  3. tau-prolog@0.3.4 у dependencies — не використовується ніде в src/. Або ідея "логічних запитів родинних зв'язків" не реалізована, або забута.
  4. Два дубльованих імпортери. import-data.js (читає import.json, формат P_0/P_1/...) і importData.js (читає inferred_tree.json, такий самий формат + guess gender). Обидва TRUNCATE перед insert (.delete().neq('id', '00000000...')) — будь-хто з anon-key може ламнути всю БД 144 relationships + 91 persons. Без --dry-run, без --confirm.
  5. Третій формат даних, f3data_dump.json, не імпортується — це дамп з family-chart library (Miro IDs всередині), використовувався в старій версії. Невідомо, чи треба його зберігати.
  6. 25+ debug-файлів у корені. check-error.js (4 версії), check-persons.js, check-schema.js, get-schema.js, guess-cols.js, dump_debug.cjs, dump_svg.cjs, screenshot.cjs, console_dump.txt, console_dump_2.txt, boxes_dump.json ([]), f3data_dump.json (9KB), graph_dump.html (97KB), svg_dump.html, my_screenshot.png (117KB), vite.config.ts.timestamp-1775571671298-03edb53fc6823.mjs, query.py (порожній), remove-orphans.js. Antigravity-MVP-бруд — половина репозиторію.
  7. Один SQL файл міграцій без версіонування. supabase_migrations.sql — це init-snapshot, що описує лише stories/sources/photos/+participants. CREATE TABLE для persons/relationships нема. Зміни схеми робляться руками в Supabase Dashboard.
  8. relationship_type — рядок без enum/CHECK. 'parent_child', 'couple' — два значення в коді, але БД це не підтверджує. Немає полів start_date/end_date для шлюбів-розлучень, is_adopted/is_step для нерідних.

UX-розриви для батька 60+

  1. PIN-логін замість Telegram-Login — батько вже звик до Telegram, у нього ALLOWED_USERS=312194208 у боті (tree_bot/bot.py:29), але на сайт доводиться вводити цифри.
  2. Адмінка веб-UI = кнопка "Редагувати" + 12 input-полів (NodeDrawer.tsx:264-308). Для 60+ це багато: birth_date/birth_place/baptism_date/church/death_date/death_place/burial_place/profession/education/military_service/residence — батько навряд буде заповнювати руками. Потрібна voice-only альтернатива.
  3. Бот працює, але без onboarding. bot.py:68-78 — system prompt "ти асистент генеалога", але немає /start handler з інструкцією, немає /help, немає inline-кнопок підтвердження. Зараз батько має писати "Так" текстом — для voice-flow це дискомфорт.
  4. Telegram-бот не дає preview перед записом у БД. Gemini сам вирішує, кого згадано (entity linking), показує текстом "Ви маєте на увазі Х? Записати?", але без inline-кнопок [✅ Так / ❌ Ні / ✏️ Виправити]. У 60+ помилки на текстовому "Так" неминучі.
  5. Voice → text обробляє Gemini (multimodal), але без проміжного транскрипту, який можна показати батьку перед записом ("Я почув: ... Записати?"). Це найголовніший UX-промах.

Operational

  1. Немає CI/CD. Жодних .github/workflows/, cloudbuild.yaml. Deploy = ручний gcloud run deploy з ноутбука Сергія через Antigravity.
  2. Backup тільки manual. tree_bot/backup.py дамп у Google Drive — викликається руками, не в cron. 1 скрипт + 0 monitoring.
  3. Немає observability. bot.py тільки print(), фронт — alert('Помилка: ' + err.message). Cloud Run logs є by default, але ніяких alert-каналів.

2. Архітектура v2 — пропозиція

2.1. Компонентна діаграма

┌──────────────────────────────────────────────────────────────────────┐
│                         РОДИЧІ (read-only)                            │
│                       https://family.lunar.lol                        │
└─────────────────────────────────┬────────────────────────────────────┘
                                  │ HTTPS
                                  ▼
┌──────────────────────────────────────────────────────────────────────┐
│         Next.js 15 App Router (Vercel або Cloud Run)                  │
│  ┌─────────────────────────────────────────────────────────────────┐ │
│  │ /              public tree (SSR + ISR cache 1h)                 │ │
│  │ /person/[id]   public profile (SSR)                             │ │
│  │ /admin         protected (Supabase Auth — magic link / TG)      │ │
│  │ /admin/[id]    edit form (server actions)                       │ │
│  │ /api/...       route handlers (server-only Supabase calls)      │ │
│  └─────────────────────────────────────────────────────────────────┘ │
│  Visualisation: React Flow + dagre (одна бібліотека)                 │
└──────────────┬─────────────────────────────────┬─────────────────────┘
               │                                  │
               │ Supabase JS (anon, read-only)    │ Supabase JS
               │                                  │ (server, service-role)
               ▼                                  ▼
┌──────────────────────────────────────────────────────────────────────┐
│                     Supabase (Postgres + Auth + Storage)              │
│  ┌──────────────────┬───────────────────┬─────────────────────────┐  │
│  │ persons          │ relationships     │ memories (text+voice)   │  │
│  │ memory_persons   │ memory_photos     │ sources (metric books)  │  │
│  │ source_citations │ photos            │ photo_persons           │  │
│  └──────────────────┴───────────────────┴─────────────────────────┘  │
│  RLS: SELECT public; INSERT/UPDATE/DELETE only authenticated admins  │
│  Auth: magic_link (Сергій) + Telegram Login Widget (батько)          │
└─────────────────────────────────────────▲────────────────────────────┘
                                          │
                                          │ service-role key
                                          │ (Postgres direct via PostgREST)
                                          │
┌─────────────────────────────────────────┴────────────────────────────┐
│              Telegram Bot (aiogram 3 на VPS systemd)                 │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │ 1. Voice msg ────► faster-whisper (small/medium-uk) ──► text   │  │
│  │ 2. Text ────► Gemini 2.5 Flash + tool calling                  │  │
│  │       tools: search_persons, propose_memory, save_memory       │  │
│  │ 3. Inline keyboard: [✅ Зберегти] [✏️ Виправити] [❌ Скасувати] │  │
│  │ 4. Approval ────► Postgres INSERT (memories + memory_persons)  │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────┬───────────────────────────┘
                                           │
                                           ▼
┌──────────────────────────────────────────────────────────────────────┐
│   Google Drive (рідне сховище Сергія) — фото метричних книг          │
│   Структура: /LunarHubble/persons/{person_id}/{photo_id}.jpg         │
│   Доступ: Service Account (drive.file scope), permissions = private  │
│   Frontend читає через signed URLs через Next.js /api/photo/[id]     │
└──────────────────────────────────────────────────────────────────────┘

2.2. Frontend — рекомендація: Next.js 15 (App Router)

Критерій Vite + React (як зараз) Next.js 15 (App Router) Рекомендація
Routing React Router 7 вбудований Next
SSR/SEO для публічного дерева немає вбудований Next
Auth flow (magic link, Telegram Widget) manual next-auth / Supabase Auth helpers Next
Server-side write (RLS bypass з service-role) нема (потрібен окремий сервіс) route handlers Next
Deploy Cloud Run (як зараз) Vercel (або Cloud Run) Vercel — простіше
Image optimization (фото метрик) manual <Image> вбудований Next

Рішення: мігруємо на Next.js 15 з App Router + Server Actions для адмінки. Vercel free tier для проекту з 91 person + 144 relationships + ~50-200 фото → у нуль витрат.

2.3. Граф-бібліотека — рекомендація: тільки React Flow + dagre

2.4. Tau-Prolog — рекомендація: викинути

2.5. Backend — рекомендація: Supabase + Next.js Route Handlers

Альтернативи розглянуті:

2.6. Auth — рекомендація: Supabase Auth (magic link для Сергія) + Telegram Login Widget (для батька)

Сергій → /admin → "Введіть email" → Magic link на пошту → JWT cookie → /admin
Батько → Telegram bot /login → Telegram Login Widget → callback → JWT → /admin

2.7. Telegram-бот для voice — деталі flow

Стек: Python 3.12 + aiogram 3 + faster-whisper (small-uk model, ~500MB RAM, runs on CPU) + google-genai (Gemini 2.5 Flash).

Хостинг: VPS systemd (як arteggia-bot), polling-режим (а не webhook — бо faster-whisper потребує persistent process). Caddy reverse-proxy не потрібен (polling не приймає вхідні).

Flow діаграма:

[Батько] → 🎤 voice (15с) ──► бот.handle_voice
                                  │
                                  ▼
                         download_voice(file_id) → /tmp/v_{id}.ogg
                                  │
                                  ▼
                         faster-whisper transcribe (uk)
                                  │  text = "Михайло Верещак воював у Першій Світовій під Перемишлем"
                                  ▼
                         Gemini 2.5 Flash з tools=[search_persons]
                                  │
                                  ▼  (виклик search_persons("Михайло Верещак"))
                                  │
                                  ▼  Gemini returns proposal:
                         {
                           memory_text: "Воював у Першій Світовій під Перемишлем",
                           tagged_persons: [
                             {id: "f73eb38a-...", name: "Михайло Верещак", confidence: 0.95}
                           ],
                           year_hint: 1914-1918,
                           source_hint: null
                         }
                                  │
                                  ▼
[Бот → Батько] ◄── повідомлення:
   "📜 Я почув: «Воював у Першій Світовій під Перемишлем»
   👤 Прив'язую до: Михайло Верещак (☑)
   📅 Період: 1914-1918
   Зберігаю?"
   [✅ Так, зберегти] [✏️ Виправити людину] [📝 Виправити текст] [❌ Скасувати]
                                  │
                                  ▼
[Батько] → tap [✅ Так, зберегти]
                                  │
                                  ▼
                         INSERT INTO memories (text, voice_url, year_from, year_to)
                         INSERT INTO memory_persons (memory_id, person_id) ...
                         (+ optional GDrive upload of voice file)
                                  │
                                  ▼
[Бот → Батько] ◄── "✅ Збережено. Поточних спогадів про Михайла: 5"

Ключові деталі:

2.8. Сховище фото — Google Drive vs Supabase Storage

Сергій просив Google Drive — поважимо. Робимо так:

2.9. RLS policies — мінімально достатній набір

-- Усі таблиці: read для всіх (publicly visible — родинне дерево)
CREATE POLICY "public_read" ON persons FOR SELECT USING (true);
CREATE POLICY "public_read" ON relationships FOR SELECT USING (true);
CREATE POLICY "public_read" ON memories FOR SELECT USING (true);
CREATE POLICY "public_read" ON sources FOR SELECT USING (true);
CREATE POLICY "public_read" ON photos FOR SELECT USING (is_private = false);

-- Write: тільки для адмінів (Сергій + батько)
CREATE TABLE admin_users (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id),
  telegram_user_id BIGINT UNIQUE,
  display_name TEXT,
  added_at TIMESTAMPTZ DEFAULT now()
);

CREATE POLICY "admin_write" ON persons FOR INSERT TO authenticated
  WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));
CREATE POLICY "admin_update" ON persons FOR UPDATE TO authenticated
  USING (auth.uid() IN (SELECT user_id FROM admin_users));
CREATE POLICY "admin_delete" ON persons FOR DELETE TO authenticated
  USING (auth.uid() IN (SELECT user_id FROM admin_users));
-- ... те саме для relationships, memories, sources, photos

3. Схема БД — SQL DDL для головних таблиць

-- =========================================================================
-- 0. Helpers
-- =========================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- =========================================================================
-- 1. Persons (нормалізовано — без JSON-в-notes)
-- =========================================================================
CREATE TABLE persons (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

  -- Імена
  first_name TEXT NOT NULL,
  last_name TEXT,
  maiden_name TEXT,
  patronymic TEXT,                  -- по-батькові

  -- Стать / статус
  gender TEXT CHECK (gender IN ('M', 'F', 'unknown')) DEFAULT 'unknown',
  is_living BOOLEAN DEFAULT TRUE,

  -- Народження
  birth_date DATE,
  birth_date_text TEXT,             -- для нечітких дат типу "близько 1880"
  birth_place TEXT,
  baptism_date DATE,
  church TEXT,

  -- Смерть
  death_date DATE,
  death_date_text TEXT,
  death_place TEXT,
  burial_place TEXT,

  -- Біографія
  profession TEXT,
  education TEXT,
  military_service TEXT,
  residence TEXT,
  notes TEXT,                        -- ВІЛЬНИЙ ТЕКСТ, не JSON

  -- Графічна позиція (для збереженого layout у дереві)
  graph_x NUMERIC,
  graph_y NUMERIC,

  -- Метадані
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now(),
  created_by UUID REFERENCES auth.users(id)
);

CREATE INDEX persons_last_name_idx ON persons (last_name);
CREATE INDEX persons_search_idx ON persons USING gin (
  to_tsvector('simple', coalesce(first_name,'') || ' ' || coalesce(last_name,'') || ' ' || coalesce(maiden_name,''))
);

-- =========================================================================
-- 2. Relationships (з типами + датами)
-- =========================================================================
CREATE TYPE relationship_type AS ENUM (
  'parent_child',
  'spouse',
  'sibling',          -- якщо хочемо явні зв'язки братів-сестер (опційно)
  'adopted_parent',   -- усиновлення
  'step_parent'       -- мачуха/вітчим
);

CREATE TABLE relationships (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  person_1_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
  person_2_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
  rel_type relationship_type NOT NULL,

  -- Для шлюбів — дати
  start_date DATE,
  end_date DATE,        -- дата розлучення / смерті партнера

  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),

  CONSTRAINT no_self_relation CHECK (person_1_id != person_2_id),
  CONSTRAINT unique_rel UNIQUE (person_1_id, person_2_id, rel_type)
);

CREATE INDEX rel_p1_idx ON relationships (person_1_id);
CREATE INDEX rel_p2_idx ON relationships (person_2_id);

-- =========================================================================
-- 3. Memories (текстові спогади + voice)
-- =========================================================================
CREATE TABLE memories (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  text TEXT NOT NULL,                      -- транскрипт або ручний текст

  -- Оригінал voice (опційно)
  voice_gdrive_file_id TEXT,
  voice_duration_sec INT,
  voice_transcribed_by TEXT DEFAULT 'faster-whisper-small-uk',

  -- Контекст
  year_from INT,
  year_to INT,
  location TEXT,

  -- Метадані
  created_at TIMESTAMPTZ DEFAULT now(),
  created_by UUID REFERENCES auth.users(id)
);

-- Зв'язок: один спогад → багато людей (теги)
CREATE TABLE memory_persons (
  memory_id UUID REFERENCES memories(id) ON DELETE CASCADE,
  person_id UUID REFERENCES persons(id) ON DELETE CASCADE,
  role TEXT,                               -- "subject"/"witness"/"mentioned"
  PRIMARY KEY (memory_id, person_id)
);

-- =========================================================================
-- 4. Sources (метричні книги, архівні документи)
-- =========================================================================
CREATE TYPE source_type AS ENUM (
  'metric_book', 'civil_registry', 'census',
  'military_record', 'church_book', 'oral_testimony',
  'photo_back_inscription', 'other'
);

CREATE TYPE certainty_level AS ENUM ('primary', 'secondary', 'estimated');

CREATE TABLE sources (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  title TEXT NOT NULL,
  source_type source_type NOT NULL,
  certainty certainty_level NOT NULL,
  repository TEXT,                       -- "ЦДІАК", "Облдержархів Львів"
  fond_opis TEXT,                        -- фонд/опис/справа/аркуш
  url TEXT,                              -- зовнішній URL якщо є
  gdrive_file_id TEXT,                   -- скан/фото метрики
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE source_citations (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  source_id UUID REFERENCES sources(id) ON DELETE CASCADE,
  person_id UUID REFERENCES persons(id) ON DELETE CASCADE,
  comment TEXT,                          -- "запис про хрещення на 47 аркуші"
  UNIQUE (source_id, person_id)
);

-- =========================================================================
-- 5. Photos (на Google Drive)
-- =========================================================================
CREATE TABLE photos (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  gdrive_file_id TEXT NOT NULL,
  caption TEXT,
  taken_year INT,
  taken_place TEXT,
  is_private BOOLEAN DEFAULT FALSE,      -- true = доступ тільки адмінам
  source_id UUID REFERENCES sources(id), -- якщо це скан метрики
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE photo_persons (
  photo_id UUID REFERENCES photos(id) ON DELETE CASCADE,
  person_id UUID REFERENCES persons(id) ON DELETE CASCADE,
  PRIMARY KEY (photo_id, person_id)
);

-- =========================================================================
-- 6. Admin users
-- =========================================================================
CREATE TABLE admin_users (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  telegram_user_id BIGINT UNIQUE,
  display_name TEXT,
  is_super_admin BOOLEAN DEFAULT FALSE,
  added_at TIMESTAMPTZ DEFAULT now()
);

-- =========================================================================
-- 7. RLS (вмикаємо для всіх + правильні policies)
-- =========================================================================
ALTER TABLE persons ENABLE ROW LEVEL SECURITY;
ALTER TABLE relationships ENABLE ROW LEVEL SECURITY;
ALTER TABLE memories ENABLE ROW LEVEL SECURITY;
ALTER TABLE memory_persons ENABLE ROW LEVEL SECURITY;
ALTER TABLE sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE source_citations ENABLE ROW LEVEL SECURITY;
ALTER TABLE photos ENABLE ROW LEVEL SECURITY;
ALTER TABLE photo_persons ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;

-- Public read
CREATE POLICY "public_read" ON persons FOR SELECT USING (true);
CREATE POLICY "public_read" ON relationships FOR SELECT USING (true);
CREATE POLICY "public_read" ON memories FOR SELECT USING (true);
CREATE POLICY "public_read" ON memory_persons FOR SELECT USING (true);
CREATE POLICY "public_read" ON sources FOR SELECT USING (true);
CREATE POLICY "public_read" ON source_citations FOR SELECT USING (true);
CREATE POLICY "public_read_photos" ON photos FOR SELECT USING (is_private = false);
CREATE POLICY "public_read" ON photo_persons FOR SELECT USING (true);

-- Admin write (приклад для persons; повторити для всіх таблиць)
CREATE POLICY "admin_all" ON persons FOR ALL TO authenticated
  USING (auth.uid() IN (SELECT user_id FROM admin_users))
  WITH CHECK (auth.uid() IN (SELECT user_id FROM admin_users));

4. Roadmap MVP v2 (3 спринти × 1 тиждень)

Sprint 1 — Foundation (тиждень 1, ~25-35 годин)

День Задача Час DoD
1 Ротувати всі секрети з аудиту 04-18 (якщо ще не зроблено): GitHub PAT, Gemini, Telegram bot, GCP SA, Supabase anon. 2 год Усі ключі у /srv/passepartout/lunar-hubble/, .meta файли
1 Створити новий приватний репо lunar-hubble-v2 на GitHub (SSH-only). Init Next.js 15 з TS+Tailwind. 2 год pnpm dev показує пустий /
2 Налаштувати Vercel project, env vars (Supabase URL + anon + service-role). Перевірити автодеплой з main. 2 год Push → preview URL працює
2 Написати міграцію 0001_init.sql (DDL з §3) у supabase/migrations/. Apply через supabase db push. 3 год \dt в SQL editor показує всі таблиці
3 Скрипт scripts/migrate-v1.ts: бере 91 person + 144 relationships з v1 Supabase → парсить JSON-в-notes → пише у нову схему (одна транзакція + dry-run flag). 4 год --dry-run показує diff, --commit мігрує. Перевірка: 91+144 у новій БД
4 Написати app/page.tsx (публічне дерево, SSR з getServerData) + базовий React Flow component на нових даних. 5 год Sergii відкриває preview URL, бачить 91 person графом
5 RLS policies (з §3 повного DDL). Перевірити, що anon може тільки SELECT, не може INSERT (canary-тест). 2 год curl -X POST з anon-key повертає 401/403
5-6 Очистити v1 репо: git rm 25+ debug-файлів, git rm обидва дубль-імпортери, оновити README з посиланням на v2. 2 год Репо чистий, тільки src/ + pkg.json
6 Налаштувати GitHub Actions: lint.yml + migrate-check.yml (dry-run schema diff на PR). 3 год PR блокується якщо lint fail
7 Buffer / fix issues. до 5 год

Risk: scripts/migrate-v1.ts — якщо є дані, які v1 зберігає тільки в JSON-в-notes (графічні x/y, документи), треба переконатися, що нічого не загублено. Action: перед міграцією — pg_dump v1 БД у /srv/backups/lunar-hubble-v1-pre-migration.sql.

Sprint 2 — Telegram-bot (тиждень 2, ~25-30 годин)

День Задача Час DoD
1 Розгорнути aiogram 3 skeleton на VPS (як arteggia-bot, systemd unit). Polling-режим. /start показує onboarding для батька. 3 год Бот живий, /start повертає привітання
2 Інтегрувати faster-whisper (small-uk model). Voice handler → transcribe → echo текст. 4 год 🎤 voice → бот відповідає транскриптом
3 Gemini 2.5 Flash з tool calling: search_persons (Postgres FTS на persons), propose_memory. System prompt + few-shot. 5 год Voice "Михайло Верещак воював у ПСВ" → бот відповідає proposal-карткою
4 Inline keyboard flow: [✅ Зберегти] [✏️ Виправити] [❌ Скасувати]. State зберігається у Redis (вже є на VPS). 4 год Кнопки працюють, save → запис у memories + memory_persons
5 Google Drive upload: voice .ogg → LunarHubble/voice/{memory_id}.ogg, photo → LunarHubble/photos/{photo_id}.jpg. SA з обмеженими правами (drive.file only). 4 год Voice-файли видно в Drive, public link не працює (як треба)
6 Rate limit (Redis: 30 voice/h per user), error handling, sentry-like логування. 3 год Спам 50 voice → бот блокує з 31-го
7 Buffer. Налаштувати monitoring (healthcheck endpoint + cron alert у Telegram-канал Сергія). до 5 год Якщо systemd service down → alert у 5 хв

Risk: faster-whisper на VPS може бути повільний (10-15 сек на 30-сек voice). Якщо незручно — fallback: Gemini multimodal audio API (платно, ~$0.003 за хвилину).

Sprint 3 — Web Admin (тиждень 3, ~25-35 годин)

День Задача Час DoD
1 Supabase Auth: magic link сторінка /admin/login. Сергій логіниться email'ом. 3 год Magic link з пошти → /admin працює
1 Telegram Login Widget на тій самій сторінці (опція "увійти через Telegram"). Виклик батьком: тапає в Telegram → редірект на /admin з JWT. 3 год Батько логіниться через TG Login
2 /admin головний — список persons з пошуком + кнопкою "Додати особу". 4 год Можна знайти "Михайло Верещак" → клік → /admin/[id]
3 /admin/[id] — форма редагування з нормалізованих колонок (без JSON-в-notes). Server Actions для save. 5 год Редагувати/зберегти → перерендер дерева на /
4 "Батько-mode": спрощена UI на /admin для adminів з is_super_admin = false. 3 великі кнопки: "🎤 Додати спогад" (TG bot link), "👀 Переглянути дерево" (/), "✍️ Редагувати свою сторінку". 4 год Батько бачить тільки 3 кнопки
5 Memory feed на сторінці person'a: список спогадів з тегами + posibility unlink. 3 год На /person/[id] видно всі прив'язані memories
6 Mobile responsive review (Сергій + батько обидва тестують з телефону). Великий шрифт, контраст. 3 год Lighthouse Mobile ≥ 90
7 Public landing з invite-link для родичів. ОG-теги, sitemap, manifest для PWA. 3 год Шарить / у Viber → preview-картка

Risk: Telegram Login Widget потребує домен + Bot username налаштований у @BotFather. Action: заздалегідь зарезервувати домен (family.lunar.lol чи піддомен існуючого).


5. Risks & Open Questions

Risks

Risk Severity Mitigation
Дані з v1 загубляться при міграції (JSON-в-notes має поля без колонок у v2) High Pre-migration pg_dump, dry-run, ручний review перших 10 records
faster-whisper на VPS повільний для 60+ сек voice Medium Fallback на Gemini Audio API (платно, але швидко)
Батько не сприйме Inline-кнопки (звик до текстового спілкування) Medium Тестувати на 1-2 голосових перед запуском, можна додати text-fallback "Так/Ні" поряд
Google Drive SA впаде (квота, біллінг) → фото не відкриваються Medium Backup-storage Supabase Storage; швидкий switch у /api/photo/[id]
Vercel free tier обмежений (100GB bandwidth) Low Вистачить для приватного сімейного сайту; в крайньому разі — Cloud Run за $1/міс
Сергій кодить через Antigravity → знов нестабільність Medium v2 робимо в Cursor/VS Code + Claude Code (на VPS), а не Antigravity

Open Questions для Сергія

  1. Чи всі секрети з аудиту 18.04 ротовано? Перевір git log --all -p -- tree_bot/.env tree_bot/credentials.json. Якщо ні — спочатку зробити це, потім решту.
  2. Чи потрібно зберегти f3data_dump.json Miro-айдішники? Якщо це бекап з family-chart візуалізації Miro, можна викинути. Якщо не впевнений — окрема таблиця legacy_miro_ids.
  3. Який рівень "приватності" для родичів? Варіанти:
    • A. Все публічно, кожен може гуглити Дід Михайло Верещак — норм.
    • B. Дерево public, але точні дати+місця — тільки залогіненим (через invite token).
    • C. Все приватне, тільки залогіненим (через TG-Login widget кожному родичу). Для B/C треба окремий RLS-flow і таблиця family_members.
  4. Хто веде модерацію спогадів від батька? Якщо батько одразу зберігає в БД через TG-бот — правки після цього робить Сергій вебом. Чи треба staging-таблиця pending_memories, де Сергій approves перед публікацією?
  5. Чи цікавить Telegram-bot для родичів (read-only бот, що відповідає на "Хто син Михайла?", "Покажи дерево Іванових")? Якщо так — це Sprint 4.
  6. Хостинг бота — VPS systemd (як arteggia)? Підтвердити, що local model faster-whisper не вб'є інші сервіси на тому ж VPS (RAM 500-800MB постійно).
  7. Чи мігруємо дані v1 одразу, чи v1 залишається паралельно як read-only архів до повного запуску v2? (Рекомендую: v1 заморозити, v2 з мігрованими даними; v1 URL → 301 redirect на v2 після Sprint 3).
  8. GEDCOM імпорт — у lib/gedcom.ts тільки export. Чи треба також import (наприклад, з MyHeritage / Ancestry)? Якщо так — Sprint 4.
  9. Custom domain. Який домен для v2? family.lunar.lol? verescak.family? Чи лишаємо Cloud Run/Vercel default?
  10. Чи має бути voice-flow і для Сергія, чи тільки для батька? Якщо для обох, додаємо is_super_admin permissions і логіку модерації від батька.

6. Висновки


Файли, які я аналізував (абсолютні шляхи):