# Спецификация: `POST /api/v1/sync` (единица 3) > Формат пакета — module_reqs разд. 4 (копия tech-requirements 2.1–2.2). ## Запрос JSON-массив записей (пакет ≤ 50): ```json [ { "client_guid": "uuid-v4", "entity": "workout | workout_set | exercise | program_version | profile | daily_log | break", "op": "upsert", "data": { } } ] ``` ## Ответы | Код | Когда | Тело | |---|---|---| | 200 | все записи пакета записаны | `{ "accepted": ["guid1", ...] }` — GUID пакета в исходном порядке | | 400 | пакет > 50; неизвестная `entity`; `op` ≠ `upsert`; невалидный GUID; нарушение схемы/диапазонов; `break` c `source = "auto"`; невалидный JSON | `{ "error": "<текст>" }` | | 409 | конфликт версии программы (`UNIQUE(program_id, version)`) | `{ "error": "program_version_conflict" }` | | 401 | нет/чужой/заблокирован ключ (middleware) | `{ "error": "unauthorized" }` | ## Логика 1. Десериализация и валидация пакета (все записи до начала записи); любая ошибка схемы → `400`, в БД ничего не пишется. 2. Одна транзакция на пакет (🛡 атомарность «все или ничего») — `TransactionScope`; Kit.Helpers открывает соединение на каждый вызов, Npgsql подключает их к ambient-транзакции. Все ЧТЕНИЯ (проверки 409, группы, существование workout'ов) выполняются до открытия транзакции — select-функции Kit открывают собственную транзакцию (вложенная запрещена); внутри — только записи: - записи применяются в порядке массива; - `athlete_id` для всех записей — из middleware (🛡, из тела не берётся). 3. Маппинг (репозитории m01): - `workout` → `WorkoutRepository.Insert` (идемпотентен по uuid; если в `data` есть `ended_at`/`status`/`avg_hr` сверх вставки — следом `Update` для этих полей); - `workout_set` → `WorkoutSetRepository.Insert` (идемпотентен); `workout_id` должен принадлежать атлету — иначе исключение из функции БД → пакет отклоняется (`400`, текст ошибки функции); - `exercise` → `ExerciseRepository.Insert` (идемпотентен; `group` → `group_id` по коду через `ExerciseGroupRepository`); - `program_version` → при отсутствии программы атлета сначала `ProgramRepository.Insert` (`title` = «Программа»), затем `ProgramVersionRepository.Insert` (`body` — как есть); 409-проверка — в пре-проходе (п. 2): `based_on_version` из записи ≠ актуальная максимальная версия → `409` (телефон перечитывает программу); UNIQUE-конфликт в `program_version_insert` — тоже `409`; - `profile` → `AthleteProfileRepository.Upsert`; - `daily_log` → `DailyLogRepository.Upsert` (по `UNIQUE(athlete_id, date)`); - `break` → `BreakRepository.Insert` (`source` — только `manual`). 4. 🛡 Идемпотентность: PK uuid сущностей = `client_guid`; повтор пакета — insert-функции возвращают существующие id, дублей нет; ответ — тот же `accepted` (для этого `program_version_insert` дополнен идемпотентностью по `p_id` — errata m01, 2026-10-18). 5. Ошибка БД внутри транзакции → rollback → `400` (или `409` для конфликта версии программы), текст ошибки — в лог. ## Тесты | # | Проверка | Ожидание | |---|---|---| | 1 | Валидный пакет (по одной записи каждой сущности) | 200, `accepted` = все GUID, строки в БД | | 2 | Повтор того же пакета | 200, тот же `accepted`, дублей в БД нет | | 3 | Пакет из 51 записи | 400 | | 4 | Неизвестная `entity` | 400 | | 5 | `break` с `source = "auto"` | 400 | | 6 | `rpe = 11` | 400, в БД ничего из пакета не записано | | 7 | Вторая версия программы с тем же `version` (перезапись uuid) | 409 | | 8 | `workout_set` с чужим `workout_id` (другой атлет) | 400, пакет откатан | | 9 | Пакет из 50 записей | 200 | | 10 | `program_version` при отсутствии программы атлета | 200; созданы `program` и `program_version` | ## Пример вызова ```bash curl -X POST https://host/api/v1/sync -H "X-Api-Key: ivan-key-1" -H "Content-Type: application/json" \ -d '[{"client_guid":"0b8f...","entity":"daily_log","op":"upsert", "data":{"date":"2026-10-17","bp_sys":128,"bp_dias":82,"weight_kg":84.5,"hr_resting":58,"wellbeing":4}}]' # → {"accepted":["0b8f..."]} ```