-- 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/learning//. -- -- При открытии: -- 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;