Files
learning/migrations/001_init.up.sql
Ilya 62519081e7 init: learning-service skeleton
Микросервис обучения портала: тесты, курсы, видео-уроки, доступы,
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>
2026-05-25 22:43:37 +03:00

291 lines
18 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- learning-service: первая миграция. Создаёт схему MVP:
-- тесты + вопросы + ответы + попытки прохождения
-- курсы + уроки (видео + markdown + опциональный тест в конце)
-- access_grants — гранулярные доступы (user / role / department / position)
-- public_tokens — одноразовые ссылки для кандидатов с email-проверкой
--
-- Cross-service ссылки (subject_id, candidate_id, owner_user_id) хранятся
-- как UUID без FK — portal/candidates живут в других БД. Целостность
-- гарантируется бизнес-логикой (валидация при создании).
--
-- 1 файл вместо разнесения по фичам — на старте проще читать как единое
-- целое; начнётся эволюция — разнесём.
BEGIN;
-- ============================================================
-- TESTS
-- ============================================================
-- Тест — самостоятельная единица для прохождения. Может быть прикреплён
-- к уроку курса (lessons.test_id) или существовать отдельно (для public-
-- ссылок кандидатам, ad-hoc проверок и т.п.).
CREATE TABLE tests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
-- passing_score: процент правильных от max, 0..100. 70 = по умолчанию
-- классическая «3+». Если null — авто-зачёт не рассчитываем,
-- HR смотрит результат вручную.
passing_score INT NULL CHECK (passing_score IS NULL OR (passing_score >= 0 AND passing_score <= 100)),
-- max_attempts: 0 = без лимита, N > 0 — попытки ограничены.
max_attempts INT NOT NULL DEFAULT 0 CHECK (max_attempts >= 0),
-- time_limit_sec: 0 = без таймера; иначе UI ставит обратный отсчёт.
time_limit_sec INT NOT NULL DEFAULT 0 CHECK (time_limit_sec >= 0),
-- shuffle_questions: если true, UI каждой попытке отдаёт вопросы в
-- случайном порядке. Серверный select по test_questions.position
-- остаётся; шафлинг на клиенте.
shuffle_questions BOOLEAN NOT NULL DEFAULT FALSE,
-- show_correct_after: показывать правильные ответы по завершении
-- (учебные тесты — да; аттестационные — обычно нет).
show_correct_after BOOLEAN NOT NULL DEFAULT TRUE,
-- is_published — черновики не видны никому кроме owner; published
-- видны всем, кому есть access_grant.
is_published BOOLEAN NOT NULL DEFAULT FALSE,
owner_user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tests_owner ON tests(owner_user_id);
CREATE INDEX idx_tests_published ON tests(is_published) WHERE is_published = TRUE;
-- Вопрос теста. kind определяет UI-рендер и логику авто-оценки.
-- single — radio-buttons, ровно один is_correct;
-- multi — checkboxes, может быть >=1 is_correct;
-- text — свободный ввод, оценка вручную HR'ом (auto_grade=false).
CREATE TABLE test_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
-- position: явный порядок (drag&drop в UI выставит). Уникальность
-- НЕ обеспечиваем (двигание через UPDATE батчем — было бы много
-- констрейнт-flip'ов; на дубли клиент не натолкнётся в норме).
position INT NOT NULL DEFAULT 0,
kind TEXT NOT NULL DEFAULT 'single'
CHECK (kind IN ('single','multi','text')),
-- text — текст вопроса; markdown допустим, фронт рендерит.
text TEXT NOT NULL,
-- points: вклад в общий счёт. 1 по умолчанию; для «бонусных» можно 2-3.
points INT NOT NULL DEFAULT 1 CHECK (points > 0),
-- explanation: показывается после ответа (если show_correct_after).
explanation TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_test_questions_test ON test_questions(test_id, position);
-- Вариант ответа. Для kind=text — не используется (NULL=correct + sample_answer
-- в самом вопросе). Для single/multi — варианты с is_correct-флагом.
CREATE TABLE test_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
position INT NOT NULL DEFAULT 0,
text TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_test_answers_question ON test_answers(question_id, position);
-- Попытка прохождения. Один из {user_id, public_token_id} обязателен —
-- либо это сотрудник под портальным логином, либо кандидат по public-токену.
-- candidate_id заполняется когда токен был выпущен под конкретного кандидата
-- (см. public_tokens.candidate_id); удобно для join'ов с candidates-сервисом.
CREATE TABLE test_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
user_id UUID NULL,
public_token_id UUID NULL,
candidate_id BIGINT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','submitted','graded','expired')),
-- score: суммарные баллы за правильные ответы; max_score = sum points
-- по test_questions на момент сабмита (фиксируем для read-only результата).
score INT NULL,
max_score INT NULL,
-- passed: рассчитывается при сабмите если у теста passing_score != NULL.
passed BOOLEAN NULL,
-- Денормализованный snapshot ФИО респондента для public-ссылок:
-- кандидат заполняет в форме перед стартом, мы сохраняем, чтобы HR видел
-- кто проходил (без join'а с candidates).
respondent_name TEXT NOT NULL DEFAULT '',
respondent_email TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_at TIMESTAMPTZ NULL,
graded_at TIMESTAMPTZ NULL,
-- Метаданные клиента — для аудита подозрительных попыток (один токен,
-- разные IP'шники → возможно ссылку перешарили).
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
-- Гарантируем что заполнен хотя бы один из user_id / public_token_id.
CONSTRAINT test_attempts_subject_required CHECK (
user_id IS NOT NULL OR public_token_id IS NOT NULL
)
);
CREATE INDEX idx_test_attempts_test ON test_attempts(test_id, started_at DESC);
CREATE INDEX idx_test_attempts_user ON test_attempts(user_id, started_at DESC)
WHERE user_id IS NOT NULL;
CREATE INDEX idx_test_attempts_candidate ON test_attempts(candidate_id, started_at DESC)
WHERE candidate_id IS NOT NULL;
CREATE INDEX idx_test_attempts_token ON test_attempts(public_token_id, started_at DESC)
WHERE public_token_id IS NOT NULL;
-- Ответ пользователя на конкретный вопрос в попытке. Хранится JSONB,
-- т.к. формат зависит от kind:
-- single → {"answer_id": "uuid"}
-- multi → {"answer_ids": ["uuid", "uuid"]}
-- text → {"text": "...свободный ответ..."}
-- При сабмите attempts-handler сверяется с test_answers.is_correct и
-- считает score; для text — оставляет null до ручной оценки HR'ом.
CREATE TABLE test_attempt_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
attempt_id UUID NOT NULL REFERENCES test_attempts(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
payload JSONB NOT NULL,
-- correct: NULL для text (ждёт ручной оценки), true/false для auto-graded.
correct BOOLEAN NULL,
-- score: 0..question.points. Заполняется при сабмите (auto) или вручную HR'ом.
score INT NULL CHECK (score IS NULL OR score >= 0),
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (attempt_id, question_id)
);
-- ============================================================
-- COURSES
-- ============================================================
-- Курс = упорядоченный список уроков. Уроки могут содержать видео + текст
-- и ОПЦИОНАЛЬНО прикреплённый тест (test_id NULL — урок без проверки).
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
-- slug: короткий человекочитаемый идентификатор для URL'ей
-- (/learning/c/onboarding-2025). Уникален среди опубликованных.
slug TEXT NOT NULL,
-- cover_image_key: опциональная картинка-обложка (MinIO key).
cover_image_key TEXT NOT NULL DEFAULT '',
is_published BOOLEAN NOT NULL DEFAULT FALSE,
owner_user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (slug)
);
CREATE INDEX idx_courses_owner ON courses(owner_user_id);
CREATE INDEX idx_courses_published ON courses(is_published) WHERE is_published = TRUE;
-- Урок. Внутри — markdown-тело (всегда есть, может быть пустым) + видео
-- (опционально, через MinIO key) + тест в конце (опционально, FK на tests).
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
position INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
-- markdown: основной контент. Можно использовать сам по себе (без видео).
markdown TEXT NOT NULL DEFAULT '',
-- video_key: ключ объекта в MinIO bucket (см. config.MinIOBucket).
-- При удалении урока физический файл НЕ удаляется — отдельный сборщик
-- мусора по «висящим» ключам, чтобы случайный delete не уничтожил видео.
video_key TEXT NOT NULL DEFAULT '',
video_duration_sec INT NOT NULL DEFAULT 0,
-- test_id: связь с тестом. На уровне урока — для проверки усвоения.
test_id UUID NULL REFERENCES tests(id) ON DELETE SET NULL,
-- is_required: курс не считается пройденным пока этот урок не закрыт.
-- В MVP «закрыт» = выставлен flag в progress-таблице (см. ниже).
is_required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_lessons_course ON lessons(course_id, position);
-- Прогресс по урокам. Один ряд = один пользователь × один урок.
-- viewed_at = просмотрел видео/прочитал markdown; completed_at = плюс
-- сдал прикреплённый тест (если он был). Для уроков без теста completed_at
-- ставится одновременно с viewed_at (handler сам отметит).
CREATE TABLE lesson_progress (
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
viewed_at TIMESTAMPTZ NULL,
completed_at TIMESTAMPTZ NULL,
PRIMARY KEY (lesson_id, user_id)
);
CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id, completed_at DESC);
-- ============================================================
-- ACCESS GRANTS
-- ============================================================
-- Гранулярный доступ к ресурсу. Один ряд = «кому что разрешено».
--
-- resource_type: 'test' | 'course'
-- subject_type: 'user' | 'role' | 'department' | 'position' | 'public'
-- - user: subject_id = portal user_id (UUID)
-- - role: subject_id = portal role_id (UUID) — все носители роли получают доступ
-- - department: subject_id = portal department_id (UUID) — все из отдела
-- - position: subject_id = portal position_id (UUID) — все на этой должности
-- - public: subject_id = NULL — anyone с действующим public-токеном
--
-- can_manage: если true — subject может редактировать ресурс (owner + admins
-- получают это автоматически без grant'а; здесь — для «соавторов»).
CREATE TABLE access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')),
resource_id UUID NOT NULL,
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','role','department','position','public')),
subject_id UUID NULL,
can_manage BOOLEAN NOT NULL DEFAULT FALSE,
granted_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- public: subject_id обязательно NULL; для остальных типов — обязательно заполнено.
CONSTRAINT access_grants_subject_id_match CHECK (
(subject_type = 'public' AND subject_id IS NULL) OR
(subject_type <> 'public' AND subject_id IS NOT NULL)
),
-- Уникальность: не можем выдать два одинаковых грантa.
UNIQUE (resource_type, resource_id, subject_type, subject_id)
);
CREATE INDEX idx_access_resource ON access_grants(resource_type, resource_id);
CREATE INDEX idx_access_subject ON access_grants(subject_type, subject_id)
WHERE subject_id IS NOT NULL;
-- ============================================================
-- PUBLIC TOKENS
-- ============================================================
-- Одноразовая ссылка для кандидата. HR создаёт токен на конкретный тест
-- или курс, привязывает к email + опционально candidate_id, отправляет
-- ссылку <PUBLIC_BASE>/public/learning/<resource_type>/<token>.
--
-- При открытии:
-- 1. Фронт спрашивает email
-- 2. Бэк сверяет с intended_email (case-insensitive). Не совпало → 403.
-- 3. Создаём test_attempt с public_token_id, привязываем opened_at.
-- 4. Юзер проходит, отправляет — used_at заполняется.
--
-- max_attempts > 1 разрешает несколько прохождений по одной ссылке (например,
-- для tutorial-теста). По умолчанию 1.
CREATE TABLE public_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- token: длинная случайная строка; ходит в URL. Делаем поле UNIQUE,
-- чтобы повторно открыть ссылку и проверить, что она ещё действует.
token TEXT NOT NULL UNIQUE,
resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')),
resource_id UUID NOT NULL,
intended_email TEXT NOT NULL,
-- candidate_id: если HR создавал ссылку из карточки candidate'а
-- сохраняем для join'а результатов с candidates-сервисом.
candidate_id BIGINT NULL,
max_attempts INT NOT NULL DEFAULT 1 CHECK (max_attempts > 0),
used_attempts INT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ NULL,
-- opened_at: первый раз когда токен был «активирован» (открыт +
-- прошёл email-проверку). Для аудита.
opened_at TIMESTAMPTZ NULL,
-- used_at: первый submitted attempt. После этого, если max_attempts=1,
-- ссылка не открывается заново.
used_at TIMESTAMPTZ NULL,
-- revoked_at: HR может вручную отозвать ссылку до использования.
revoked_at TIMESTAMPTZ NULL,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_public_tokens_resource ON public_tokens(resource_type, resource_id);
CREATE INDEX idx_public_tokens_candidate ON public_tokens(candidate_id)
WHERE candidate_id IS NOT NULL;
CREATE INDEX idx_public_tokens_email ON public_tokens(LOWER(intended_email));
COMMIT;