LessonRepository.ListVideos: SELECT с INNER JOIN courses, фильтр
video_key != '' + (course.is_published OR course.owner_user_id = viewer).
Возвращает LessonWithCourse — урок + denorm course_{title,slug,
is_published,owner_user_id} чтобы фронт сгруппировал по курсу
без N+1.
LessonHandler.ListVideos: GET /lessons?has_video=true. Гейт уже на
SQL-уровне, в коде только X-User-Id из headers.
Route регистрируется ДО /lessons/{id}, иначе chi бы заматчил
{id}="lessons".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Базовая работа с курсами (без уроков — добавятся в следующей итерации).
CourseRepository:
- List с тем же паттерном что TestRepository: ownerFilter +
onlyPublished + visibleIDs (для будущего access_grants).
- Get / GetBySlug — slug нужен для public-страниц.
- Create — slugify(title) если slug не задан; collision retry до 5 раз
(UNIQUE constraint courses_slug_key).
- Update / Delete с CASCADE на lessons.
- courseCols + lessons_count subquery, UI получает бейдж без отдельного
запроса.
CourseHandler — стандартный набор. Гейтит owner для write/delete;
read доступен всем (внутри сервиса), portal проксирует под
service.learning.access.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
UpdateQuestion (PUT /tests/{id}/questions/{questionId}):
- транзакционно: UPDATE test_questions SET ... + DELETE test_answers +
INSERT новых ответов. question_id стабилен — test_attempt_answers
(FK CASCADE) остаются. Снимок ответа в payload — для будущего
показа истории, в MVP не реализовано;
- семантика full-replace: проще на клиенте (один POST вместо
per-answer патчей), атомарно на сервере.
ReorderQuestions (POST /tests/{id}/questions/reorder):
- батч-апдейт position через UNNEST(uuid[], int[]) — один запрос
вместо N UPDATE'ов; идемпотентно;
- /reorder регистрируется ДО /{questionId} чтобы chi не заматчил
его как questionId="reorder".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AttemptRepository:
- Start: проверка max_attempts (учитывает уже использованные с этого
user_id или public_token_id), вставка in_progress'а;
- Get/ListByUser/ListByTest: чтение с per-attempt scope;
- SubmitAndGrade: транзакционно сохраняет ответы в attempt_answers
(JSONB payload + correct + score), считает итог:
single — 1 правильный → points за вопрос, иначе 0;
multi — set ответов == set is_correct=TRUE → points, иначе 0
(частичные баллы не делаем в MVP);
text — correct=NULL и score=NULL, ждут ручной оценки HR'ом.
max_score = SUM(points) по всем вопросам (не только отвеченным).
passed = NULL если у теста нет passing_score; иначе процент vs порог.
status: graded если все автогрейд'ятся; submitted если есть text.
AttemptHandler:
- POST /tests/{id}/attempts — Start (X-User-Id из portal-gateway).
Не-владелец стартует только если is_published=true.
- GET /attempts/{id} — Get с проверкой «я респондент / я владелец теста».
- POST /attempts/{id}/submit — Submit (только свою попытку).
- GET /attempts — ListMine.
- GET /tests/{id}/attempts — ListByTest (только для владельца).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Микросервис обучения портала: тесты, курсы, видео-уроки, доступы,
public-ссылки для кандидатов с email-валидацией.
В этой итерации:
- Skeleton (config, migrate, main, health) по паттерну tasks/candidates
- Migration 001_init: 10 таблиц (tests/questions/answers/attempts/
attempt_answers + courses/lessons/lesson_progress + access_grants +
public_tokens) с подробными комментариями why
- Tests: полный CRUD + вопросы/ответы; non-owner'у is_correct и
explanation скрываются в выдаче
- Заглушки 501 для attempts / courses / lessons / video-stream /
access / public-tokens — следующие итерации
- k8s: namespace, configmap, secrets, postgres, deployment с HPA,
service с portal-discovery annotations
- Dockerfile, Makefile, .gitignore
См. README.md для полного списка отложенного и инструкций запуска.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>