Цей документ фіксує причини рішень, поточний технічний борг і, найголовніше, конкретні контракти, які має дотримуватись клієнт (frontend/native). Публічний README.md лишається коротким фасадом — усі деталі тут.
Кожен домен — окремий самодостатній пакет у корені репозиторію (проєкт свідомо
без пакета app/: measurements/, prescriptions/, reminders/ лежать поруч
з main.py, config.py, db.py). Усередині пакета — той самий шар:
models/— ланцюжок SQLModel-класів:Base(спільні поля й валідація) → клас ізtable=True(додаєid, серверні дефолти, FK) →Create(тіло запиту на створення) →Read(тіло відповіді) →Update(тіло часткового оновлення, лише де редагування взагалі має сенс).crud/— функції доступу до БД, завжди приймаютьsessionіuser_id, завжди скоупляться заuser_id(або за батьківською сутністю, якщо в таблиці немає власногоuser_id, як-отmedication_items).router/— FastAPI-роутер плюс залежністьCurrentUserId, яка резолвиться до реальногоauth.deps.get_current_user_id(декодує Bearer JWT).
Виняток — measurements: це найстаріший модуль, і він досі має пласку
структуру (models.py/crud.py/router.py — по одному файлу, без
під-пакетів), тоді як prescriptions і reminders уже розбиті на пакети,
бо в них по кілька сутностей. Пласка структура measurements — це не
недогляд, а те, що модуль ще не переростав файл на файл; вирівнювати його під
пакет має сенс лише тоді, коли з'явиться друга сутність усередині.
Модуль auth має під-пакет webauthn/ (моделі WebAuthnCredential,
WebAuthnChallenge, CRUD, сервіс, роутер). Решта файлів auth — пласкі
(models.py, security.py, crud.py, deps.py, router.py, service.py).
security.py — чисті функції без FastAPI й без БД (кодування/декодування JWT,
генерація та хешування токенів magic-link і refresh).
- Діапазони
measurements(sys/dia/pulse) перевіряються лише на рівні Pydantic (Field(ge=..., le=...)). CHECK-обмеження на рівні БД відкладені — поки що ніщо, крім самого API, не заважає вставити рядок напряму в базу з некоректними значеннями. - Поля
sourceвmeasurementsнемає навмисно: у старому C#-проєкті воно належало зовнішньому OCR-пайплайну, якого в цьому проєкті поки що немає. - Проєкції на читання ще не побудовані: немає ендпоінта «сьогодні/найближче» для нагадувань і немає окремого ендпоінта історії підтверджень. Схема вже готова під них — жодне поле не видаляється і не обмежується заднім числом, просто самих ендпоінтів ще нема.
- «Двигун курсу» (розмотування
courseна відміну відongoing) свідомо не реалізований — поляcourse_start/course_intakesзберігаються, але нічого їх поки не інтерпретує. get_current_user_id— stateless. Він лише декодує JWT і перевіряє підпис/термін дії; у базу даних не заглядає. Якщо користувача видалили чи токен скомпрометований до завершення терміну дії — запит із валідним токеном усе одно пройде. Перевірка існування користувача в БД на кожен запит — можливе майбутнє доповнення, свідомо не додане зараз (ціна: зайвий SELECT на кожен захищений запит).- Rate limiting (D-003): обмеження частоти запитів відкладене до публічної експозиції (LAN + VPN → жодного анонімного нападника).
Джерело істини для наскрізних правил — docs/conventions.md.
Коротко про час і пояс: клієнт — єдиний, хто знає часовий пояс користувача.
Бек зберігає моменти часу як timestamptz і ніколи не інтерпретує зсув
пояса; класифікація моменту в конкретний слот (Morning/Day/Evening) і
календарну дату — це робота клієнта, а не бекенду.
Виняток — серверний рендеринг CSV-експорту: клієнт передає IANA-ідентифікатор
часового поясу як поле tz у запиті POST /export/csv, і бек використовує
його для форматування дат у файлі. Часовий пояс не зберігається в базі
(D-008).
Усі ендпоінти нижче, крім magic-link request/confirm і WebAuthn
authenticate, вимагають заголовок Authorization: Bearer <access_token>.
Без токена, з протермінованим чи невалідним підписом — 401.
Access-токен — stateless JWT, живе 15 хвилин. Refresh-токен — серверний
(таблиця sessions), ротується при кожному використанні, живе 30 днів.
Пред'явлення вже відкликаного refresh-токена трактується як компрометація
і гасить усі сесії користувача (D-007).
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| POST | /auth/magic-link/request |
{email:EmailStr} |
202, {detail} |
— (завжди 202, захист від перелічування) |
| POST | /auth/magic-link/confirm |
{token:str} |
200, TokenResponse |
401 |
| POST | /auth/refresh |
{refresh_token:str} |
200, TokenResponse |
401 |
| POST | /auth/logout |
{refresh_token:str} |
204 | — |
| POST | /auth/logout-all |
— | 204 | — |
| GET | /auth/sessions |
— | 200, list[SessionRead] |
— |
TokenResponse: {access_token, refresh_token, token_type:"bearer", expires_in}.
SessionRead: {id, created_at, last_used_at, expires_at, user_agent}.
Реєстрації як окремого ендпоінта немає: POST /auth/magic-link/request створює
користувача автоматично, якщо email є у ALLOWED_EMAILS.
Usernameless flow (D-004): клієнт нічого не надсилає для автентифікації,
платформа пропонує наявні passkey, сервер упізнає користувача за credential_id.
| Метод | Шлях | Тіло запиту | Відповідь | Помилки | Auth |
|---|---|---|---|---|---|
| POST | /auth/webauthn/register/options |
— | 200, JSON | 404 | ✓ |
| POST | /auth/webauthn/register/verify |
dict (WebAuthn response) | 201, WebAuthnCredentialRead |
400 | ✓ |
| POST | /auth/webauthn/authenticate/options |
— | 200, JSON | — | ✗ |
| POST | /auth/webauthn/authenticate/verify |
dict (WebAuthn response) | 200, TokenResponse |
401 | ✗ |
| GET | /auth/webauthn/credentials |
— | 200, list[WebAuthnCredentialRead] |
— | ✓ |
| DELETE | /auth/webauthn/credentials/{credential_id} |
— | 204 | 404 | ✓ |
WebAuthnCredentialRead: {id, label, transports, created_at, last_used_at}.
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| GET | /users/me |
— | 200, UserRead |
404 |
| PATCH | /users/me |
{display_name?:str} (max 120, stripped, empty -> null) |
200, UserRead |
404 |
| DELETE | /users/me |
— | 204 | 404 |
UserRead: {id, email, display_name, created_at}.
DELETE /users/me каскадно видаляє всі пов'язані рядки (measurements,
prescriptions, sessions, credentials тощо) через ON DELETE CASCADE.
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| POST | /measurements |
{sys:int, dia:int, pulse:int, recorded_at?:timestamptz} |
201, {id, sys, dia, pulse, recorded_at} |
422 — вихід за діапазон |
| GET | /measurements?days=90 |
— | 200, список, найновіші перші | — |
| GET | /measurements/{id} |
— | 200 | 404 |
| PATCH | /measurements/{id} |
будь-яка підмножина {sys,dia,pulse,recorded_at} |
200 | 404 |
| DELETE | /measurements/{id} |
— | 204 | 404 |
| POST | /measurements/analyze |
multipart/form-data, поле image (файл) |
200, {sys, dia, pulse} |
400, 413, 422, 502 |
Діапазони: sys 40–300, dia 20–200, pulse 30–250; вихід за межі — 422 ще
до звернення до бази. recorded_at — опційне поле; якщо клієнт його не
надішле, бек підставить now(). Відповідь ніколи не містить user_id.
Параметр запиту days у списку — ціле число від 1 до 365, за замовчуванням 90.
POST /measurements/analyze — безстанційне розпізнавання показників тиску з
фото через Gemini API. Приймає файл зображення (image/*, ≤ 10 МБ), повертає
розпізнані sys/dia/pulse. Помилки: 400 — файл відсутній або не
зображення, 413 — перевищення розміру, 422 — не вдалося розпізнати значення,
502 — Gemini API недоступний.
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| POST | /prescriptions |
{doctor:str, prescribed_on:date, is_active?:bool=true} |
201, {id, doctor, prescribed_on, is_active, created_at} |
422 |
| GET | /prescriptions |
— | 200, усі призначення користувача | — |
| GET | /prescriptions/{id} |
— | 200 | 404 |
| PATCH | /prescriptions/{id} |
підмножина {doctor,prescribed_on,is_active} |
200 | 404 |
| DELETE | /prescriptions/{id} |
— | 204 (каскадом видаляє позиції ліків) | 404 |
Важливо: у користувача може бути кілька одночасно активних призначень —
жодного інваріанта «лише одне активне» в цьому проєкті немає (типовий кейс —
кілька лікарів). Відповідь не містить user_id.
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| POST | /prescriptions/{prescription_id}/items |
див. нижче | 201 | 404 — немає такого призначення |
| GET | /prescriptions/{prescription_id}/items |
— | 200, список | 404 |
| GET | /prescriptions/{prescription_id}/items/{item_id} |
— | 200 | 404 |
| PATCH | /prescriptions/{prescription_id}/items/{item_id} |
підмножина полів нижче | 200 | 404 |
| DELETE | /prescriptions/{prescription_id}/items/{item_id} |
— | 204 | 404 |
Тіло створення позиції ліків:
{
medicine: str,
condition?: str,
when_slots: [WhenSlot],
dose_amount: str,
dose_unit?: DoseUnit,
freq_count: int,
freq_period: int,
freq_period_unit: FreqPeriodUnit,
course_type: CourseType = "ongoing",
course_start?: timestamptz,
course_intakes?: int
}
Точні значення enum-ів (рядки, як є, без перекладу):
WhenSlot = Morning | Day | Evening;
DoseUnit = tablet | mg | ml | drop | mcg | IU;
FreqPeriodUnit = h | d | wk;
CourseType = ongoing | course.
Невалідне значення enum-а — 422.
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| GET | /reminders/config |
— | 200, {morning_time, day_time, evening_time, max_reminders, duration_minutes} |
404 — конфіг ще не налаштовано |
| PUT | /reminders/config |
усі п'ять полів вище | 200, upsert | 422 |
Часи серіалізуються як рядок "HH:MM:SS" (напр. "08:00:00"). PUT — це
повне перезаписування, часткового оновлення тут немає: клієнт завжди
надсилає всі п'ять полів. Відповідь не містить user_id.
Це найважливіша частина контракту — саме тут найбільше шансів розійтися з фронтендом, тож поведінку варто зафіксувати явно, до таблиці.
Ключ ідемпотентності — трійка (user_id, period, date): на кожного
користувача, на кожен слот, на кожну дату існує щонайбільше один рядок
(унікальність не глобальна за period+date — вона завжди прив'язана до
конкретного користувача). POST — це upsert, і він завжди повертає 201,
навіть коли насправді перезаписує вже існуючий рядок (свідоме спрощення на
цьому етапі проєкту, а не недогляд).
Поле taken_at у тілі запиту — опційне, і його наявність чи відсутність
означає різну поведінку:
- якщо
taken_atвідсутнє — бек підставляєnow(). Це сценарій підтвердження «просто зараз», того самого дня, незалежно від того, чи клієнт встиг у вікно нагадування, чи ні. - якщо
taken_atприсутнє — бек зберігає його точно так, як прийшло, без жодної інтерпретації. Це два сценарії: перше підтвердження заднім числом за інший день (коли автоматичнийnow()був би неправдою), і редагування вже існуючого запису (повторний виклик того самого(user_id, period, date)— це НЕ помилка, а свідомий перезапис;snapshotпри цьому перебудовується заново з поточних активних призначень).
Важливий наслідок для клієнта. Оскільки taken_at опційне, повторний
POST того самого (user_id, period, date) БЕЗ taken_at перезапише вже наявний
taken_at на поточний now(), а не залишить його без змін. Якщо клієнт
хоче відредагувати рядок з іншої причини (наприклад, лише щоб бек
перебудував snapshot під поточні активні призначення) і йому не важливо
міняти сам момент прийому — він мусить явно повторно надіслати старе
значення taken_at, інакше момент прийому мовчки зміниться на «зараз». Це
свідомо прийнята поведінка на цьому етапі проєкту (не аргумент робити
taken_at обов'язковим при повторному записі), але клієнт має її
враховувати: «редагування без наміру змінити час» і «відсутність
taken_at» — це не те саме, і бек їх не розрізняє.
Клієнт завжди надсилає date і period окремими полями, а не виводить їх
із taken_at — це пряме продовження правила «класифікує клієнт» із
docs/conventions.md: той самий момент часу може
потрапити в різний слот і навіть різну календарну дату залежно від
часового поясу, а це знає лише клієнт.
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| POST | /reminders/intake-reports |
{period:WhenSlot, date:date, taken_at?:timestamptz} |
201, upsert | 422 |
| GET | /reminders/intake-reports |
— | 200, список | — |
| GET | /reminders/intake-reports/{id} |
— | 200 | 404 |
Відповідь: {id, period, date, taken_at, recorded_at, snapshot}.
recorded_at — завжди серверний now() останнього запису (створення або
редагування), клієнт його не надсилає. snapshot — масив
[{medicine, amount, condition}, ...], самодостатня копія того, що
приймалось, зібрана з усіх активних призначень користувача на момент
запису. Відповідь не містить user_id, is_late і prescription_id —
усі три поля свідомо прибрані:
is_lateприбрано, бо «рано/вчасно/пізно» — це проєкція на читання, яку завжди можна порахувати з уже збережених чесних моментів часу; зберігати її саму по собі — означає заморожувати висновок, який може змінитися разом із визначенням «вікна».prescription_idприбрано, боsnapshotзбирається з УСІХ активних призначень користувача для цього слоту, а не з одного конкретного — єдинийprescription_idяк посилання «про яке це призначення» був би нечесним.
DELETE і окремого PATCH для intake_reports немає — єдиний шлях запису це
POST (upsert).
| Метод | Шлях | Тіло запиту | Відповідь | Помилки |
|---|---|---|---|---|
| POST | /export/csv |
{tz:str, date_from?:date, date_to?:date} |
202, {message, email} |
404 — user not found; 422 — невалідний timezone або date_from > date_to; 429 — cooldown active |
tz — обов'язковий IANA-ідентифікатор часового поясу (напр. "Europe/Kyiv").
date_from та date_to — опційні календарні дати (YYYY-MM-DD) для фільтрації періоду експорту.
Бек форматує дати відповідно до цього поясу та надсилає два вкладення на email користувача
через email outbox (асинхронно, з ретраями): CSV (сирі дані) та друкований PDF-звіт для лікаря
(A4 portrait, дати в форматі DD.MM.YYYY). Cooldown між експортами — export_cooldown_minutes
(за замовчуванням 10 хв).
| Модуль | Стан |
|---|---|
| auth | ✓ magic-link + WebAuthn/passkey, JWT access/refresh, сесії, покрито тестами |
| users | ✓ профіль (display_name) + видалення акаунта, покрито тестами |
| measurements | ✓ CRUD + фото-розпізнавання (Gemini), покрито тестами |
| prescriptions | ✓ реалізовано, покрито тестами |
| reminders | ✓ реалізовано, покрито тестами |
| export | ✓ CSV + PDF експорт (з фільтрацією за датами) на email, покрито тестами |
| email_infra | ✓ SMTP + email outbox worker, покрито тестами |
| cleanup | ✓ фоновий очищувач (magic links, challenges, sessions) |
Усі модулі підключені один до одного: measurements, prescriptions,
reminders, export і users вимагають реальний Bearer-токен від auth,
і всі їхні user_id мають FK на users.id з ON DELETE CASCADE.
scripts/import_from_legacy.py — автономний утилітарний інструмент для перенесення даних
з legacy C#-бекенду (Postgres) в нову FastAPI-базу. Скрипт переносить лише дві таблиці:
| Legacy таблиця | Нова таблиця |
|---|---|
Measurements |
measurements |
UserCredentials |
webauthn_credentials |
Усе інше в старій схемі (призначення ліків, шаблони нагадувань, звіти про прийоми тощо) навмисно ігнорується: призначення не мають потрібних осей частоти й тривалості для мапінгу, нагадування містять тестові дані, а історія прийомів не є критичною.
- Читання (legacy): реалізовано напряму через
psycopg(з фабрикою рядківdict_row) без використання SQLAlchemy на стороні джерела. - Запис (target): використовує
AsyncSessionз SQLModel для збереження моделей. Усі preflight-запити на цільовій базі робляться черезsession.connection(), щоб уникнути попереджень про застарілі методи (DeprecationWarning). - Безпека виводу: парсить підключення через
urllib.parse.urlparse, виводячи в таблицю префлайту лише хост, порт і базу без витоку паролів. - Контроль оператора: перед записом обов'язково запитує підтвердження
через
typer.confirm(за винятком dry-run).
Скрипт повністю автономний — не залежить від .env чи config.py. CLI-інтерфейс побудовано
на typer. Запуск здійснюється за допомогою uv:
uv run python scripts/import_from_legacy.py \
--legacy-url postgresql://user:pass@host:port/legacy_db \
--target-url postgresql://user:pass@host:port/new_db \
--user-id <uuid-користувача-в-новій-базі> \
[--dry-run]--legacy-url— рядок підключення до legacy БД (джерело).--target-url— рядок підключення до нової БД (ціль).--user-id— UUID користувача в новій базі (валідується парсером якUUIDавтоматично).--dry-run— прочитати дані, показати таблицю результатів, але нічого не записувати в цільову базу.
Скрипт перевіряє наявність записів в measurements та webauthn_credentials
для user_id у цільовій базі. Якщо записи вже є, він зупиняє роботу
й виводить SQL-команди DELETE для ручного очищення. Скрипт ніколи
не видаляє дані й не перезаписує їх автоматично для уникнення дублів.