Дата: 2026-05-02
Аудитор: VPS Claude (Opus 4.7)
Замовник: Сергій (Digital Lead Deltamedical)
Поточний live: https://lunar-hubble-105435845108.europe-west1.run.app/
Поточне репо: /srv/projects/lunar-hubble/
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 тижню.
GET https://lunar-hubble-105435845108.europe-west1.run.app/ → HTTP 200, <title>Family Tree</title>, SPA-bundle assets/index-Dh7dXim6.js. Server: nginx-on-Cloud-Run.src/App.tsx:315-317).| Таблиця | Записів | Доступ для 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';
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 = ключ адміна.src/App.tsx:280-283: if (pinInput === '1234') setAuthRole('admin'). Будь-хто бачить PIN у view-source або в зоні Network/JS. Підказка з PIN надрукована на формі (App.tsx:316).App.tsx:272: params.get('token') === 'family-tree-invite-24'. Зашит у білдованому JS, після першої публічної ланки = знає весь Інтернет.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.'type': 'anyone', 'role': 'reader' (tree_bot/gdrive.py:39-42) — кожне фото метричної книги після завантаження стає публічним за прямим лінком. Приватний родинний архів = публічний інтернет.tree_bot/db.py:11: os.environ.get("VITE_SUPABASE_KEY")). Серверний компонент не повинен ділити ключ із SPA — мав би використовувати service-role.tree_bot/.env та tree_bot/credentials.json досі лежать у /srv/projects/lunar-hubble/ (а отже, ймовірно, у git історії). Перевір git log --all -- tree_bot/.env tree_bot/credentials.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 у коді.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).tau-prolog@0.3.4 у dependencies — не використовується ніде в src/. Або ідея "логічних запитів родинних зв'язків" не реалізована, або забута.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.f3data_dump.json, не імпортується — це дамп з family-chart library (Miro IDs всередині), використовувався в старій версії. Невідомо, чи треба його зберігати.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-бруд — половина репозиторію.supabase_migrations.sql — це init-snapshot, що описує лише stories/sources/photos/+participants. CREATE TABLE для persons/relationships нема. Зміни схеми робляться руками в Supabase Dashboard.relationship_type — рядок без enum/CHECK. 'parent_child', 'couple' — два значення в коді, але БД це не підтверджує. Немає полів start_date/end_date для шлюбів-розлучень, is_adopted/is_step для нерідних.tree_bot/bot.py:29), але на сайт доводиться вводити цифри.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 альтернатива.bot.py:68-78 — system prompt "ти асистент генеалога", але немає /start handler з інструкцією, немає /help, немає inline-кнопок підтвердження. Зараз батько має писати "Так" текстом — для voice-flow це дискомфорт..github/workflows/, cloudbuild.yaml. Deploy = ручний gcloud run deploy з ноутбука Сергія через Antigravity.tree_bot/backup.py дамп у Google Drive — викликається руками, не в cron. 1 скрипт + 0 monitoring.bot.py тільки print(), фронт — alert('Помилка: ' + err.message). Cloud Run logs є by default, але ніяких alert-каналів.┌──────────────────────────────────────────────────────────────────────┐
│ РОДИЧІ (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] │
└──────────────────────────────────────────────────────────────────────┘
| Критерій | 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 фото → у нуль витрат.
reactflow + dagre — вже працює в D3Graph.tsx, лейаутить 91 ноду без проблем.d3, react-force-graph-2d, react-force-graph-3d — bundle -700KB.reactflow custom layout, не окрему бібліотеку.SELECT через anon-ключ при правильних RLS./api/* route handlers, які тримають SUPABASE_SERVICE_ROLE_KEY у env vars (Vercel/Cloud Run secret).Альтернативи розглянуті:
Сергій → /admin → "Введіть email" → Magic link на пошту → JWT cookie → /admin
Батько → Telegram bot /login → Telegram Login Widget → callback → JWT → /admin
admin_users — список Telegram user_id'ів та emails з адмін-правами.auth.uid() IN (admin_users) → write доступ.Стек: 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"
Ключові деталі:
/skip команда — якщо батько хоче просто додати голосовий файл до існуючої людини без транскрипції/AI.Сергій просив Google Drive — поважимо. Робимо так:
tree-bot@gen-lang-...iam.gserviceaccount.com (новий ключ після ротації).1uLZRMypfAgpmAfh3nlE1j40eHb0GkbQp (або нова).LunarHubble/persons/{person_id}/{photo_id}.jpg, LunarHubble/voice/{memory_id}.ogg, LunarHubble/sources/{source_id}.pdf.'type': 'anyone'. Нащо приватний архів робити публічним?/api/photo/[photo_id] — Next.js route handler, що:
gdrive_file_id з photos.gdrive_file_id-- Усі таблиці: 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
-- =========================================================================
-- 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));
| День | Задача | Час | 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.
| День | Задача | Час | 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 за хвилину).
| День | Задача | Час | 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 чи піддомен існуючого).
| 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 |
git log --all -p -- tree_bot/.env tree_bot/credentials.json. Якщо ні — спочатку зробити це, потім решту.f3data_dump.json Miro-айдішники? Якщо це бекап з family-chart візуалізації Miro, можна викинути. Якщо не впевнений — окрема таблиця legacy_miro_ids.family_members.pending_memories, де Сергій approves перед публікацією?lib/gedcom.ts тільки export. Чи треба також import (наприклад, з MyHeritage / Ancestry)? Якщо так — Sprint 4.family.lunar.lol? verescak.family? Чи лишаємо Cloud Run/Vercel default?is_super_admin permissions і логіку модерації від батька.pg_dump v1 БД у бекап.Файли, які я аналізував (абсолютні шляхи):
/srv/projects/lunar-hubble/package.json/srv/projects/lunar-hubble/Dockerfile/srv/projects/lunar-hubble/nginx.conf/srv/projects/lunar-hubble/index.html/srv/projects/lunar-hubble/.env/srv/projects/lunar-hubble/supabase_migrations.sql/srv/projects/lunar-hubble/src/App.tsx/srv/projects/lunar-hubble/src/components/D3Graph.tsx/srv/projects/lunar-hubble/src/components/NodeDrawer.tsx/srv/projects/lunar-hubble/src/components/RelationalManagers.tsx/srv/projects/lunar-hubble/src/lib/supabase.ts/srv/projects/lunar-hubble/src/lib/gedcom.ts/srv/projects/lunar-hubble/src/store/useStore.ts/srv/projects/lunar-hubble/tree_bot/bot.py/srv/projects/lunar-hubble/tree_bot/db.py/srv/projects/lunar-hubble/tree_bot/gdrive.py/srv/projects/lunar-hubble/import-data.js/srv/projects/lunar-hubble/importData.js/srv/projects/lunar-hubble/check-error.js/srv/projects/lunar-hubble/f3data_dump.json (перші 120 рядків)/srv/projects/lunar-hubble/import.json (перші 80 рядків)/srv/research/2026-04-18/lunar-hubble-audit.md (попередній аудит — підгрунтя)