From de9535d35cd769d1e44eb6b08a1475d65d74748f Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Mon, 26 Oct 2026 15:47:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(db):=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF,=20=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=BE=D0=B2,=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=BE=D0=B2=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/preflight/README.md | 52 ++++++++ deploy/preflight/groups-courses-post.sql | 38 ++++++ deploy/preflight/groups-courses.sql | 124 ++++++++++++++++++ .../resources/db/migration/V6__courses.sql | 41 ++++++ .../db/migration/V7__labs_course.sql | 12 ++ .../db/migration/V8__chapters_course.sql | 18 +++ .../migration/V9__article_assets_indexes.sql | 2 + .../main/resources/db/migration/V4__roles.sql | 4 + .../db/migration/V5__groups_cohorts.sql | 62 +++++++++ .../db/migration/V6__student_statuses.sql | 20 +++ .../resources/db/migration/V7__promotion.sql | 43 ++++++ .../db/migration/V8__membership_history.sql | 28 ++++ .../db/migration/V9__account_statuses.sql | 9 ++ .../db/migration/V10__profile_authors.sql | 7 + 14 files changed, 460 insertions(+) create mode 100644 deploy/preflight/README.md create mode 100644 deploy/preflight/groups-courses-post.sql create mode 100644 deploy/preflight/groups-courses.sql create mode 100644 services/academic-service/src/main/resources/db/migration/V6__courses.sql create mode 100644 services/academic-service/src/main/resources/db/migration/V7__labs_course.sql create mode 100644 services/academic-service/src/main/resources/db/migration/V8__chapters_course.sql create mode 100644 services/academic-service/src/main/resources/db/migration/V9__article_assets_indexes.sql create mode 100644 services/identity-service/src/main/resources/db/migration/V4__roles.sql create mode 100644 services/identity-service/src/main/resources/db/migration/V5__groups_cohorts.sql create mode 100644 services/identity-service/src/main/resources/db/migration/V6__student_statuses.sql create mode 100644 services/identity-service/src/main/resources/db/migration/V7__promotion.sql create mode 100644 services/identity-service/src/main/resources/db/migration/V8__membership_history.sql create mode 100644 services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql create mode 100644 services/progress-service/src/main/resources/db/migration/V10__profile_authors.sql diff --git a/deploy/preflight/README.md b/deploy/preflight/README.md new file mode 100644 index 0000000..31564aa --- /dev/null +++ b/deploy/preflight/README.md @@ -0,0 +1,52 @@ +# Preflight перед миграциями групп и курсов + +**Перед любыми миграциями обязателен backup:** `./deploy/backup.sh backup` +(см. [`../BACKUP.md`](../BACKUP.md)). Preflight не заменяет backup. + +Два скрипта только читают данные: + +- `groups-courses.sql` — до миграций: проблемы, которые могут помешать; +- `groups-courses-post.sql` — после миграций и bootstrap курса C: проверка результата. + +## До миграций + +```bash +psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses.sql +``` + +Проверяет: + +- группы, имя которых не разбирается шаблоном; +- одинаковые кортежи `(stream, degree, number, course, enrollment_year)`; +- группы без полного набора полей; +- одинаковые имена групп; +- студентов без текущей группы; +- дубли номеров лабораторных при переносе в один курс; +- дубли slug глав; +- объём материалов и активов. + +## После миграций и bootstrap + +```bash +psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql +``` + +Проверяет: + +- лабы без курса (должно быть `0` перед `SET NOT NULL`); +- главы без курса (должно быть `0`, если все текущие материалы курсовые); +- наличие ровно одного курса C; +- комментарии без автора; +- статусы аккаунтов; +- группы без полного разбора. + +## Что делать с результатами + +1. Группы с нестандартными именами — разобрать вручную и заполнить поля до + перехода на `NOT NULL`. +2. Одинаковые кортежи — исправить номер или год набора. +3. Дубли номеров лаб — перенумеровать или разнести по разным курсам до + включения `UNIQUE (number, course_id)` / `NOT NULL`. +4. Дубли slug — переименовать главы до частичных индексов. +5. После bootstrap курса C проверить, что `labs_without_course = 0` и + `chapters_without_course = 0`. diff --git a/deploy/preflight/groups-courses-post.sql b/deploy/preflight/groups-courses-post.sql new file mode 100644 index 0000000..8a2801d --- /dev/null +++ b/deploy/preflight/groups-courses-post.sql @@ -0,0 +1,38 @@ +-- Пост-миграционные проверки. Запускать после миграций и bootstrap курса C. +\pset pager off + +\echo '=== 1. Лабы без курса (должно быть 0 перед SET NOT NULL) ===' +SELECT count(*) AS labs_without_course +FROM academic.labs +WHERE course_id IS NULL; + +\echo '=== 2. Главы без курса (должно быть 0, если все текущие материалы курсовые) ===' +SELECT count(*) AS chapters_without_course +FROM academic.chapters +WHERE course_id IS NULL; + +\echo '=== 3. Курсы (должен быть ровно один курс C) ===' +SELECT id, course_no, title, owner_account_id, status +FROM academic.courses +ORDER BY course_no; + +\echo '=== 4. Комментарии без автора (старые записи) ===' +SELECT count(*) AS comments_without_author +FROM progress.grade_comments +WHERE author_account_id IS NULL; + +\echo '=== 5. Аккаунты по ролям и статусам ===' +SELECT role, status, count(*) AS accounts +FROM identity.accounts +GROUP BY role, status +ORDER BY role, status; + +\echo '=== 6. Группы без полного разбора ===' +SELECT id, name, stream, course, number, degree, enrollment_year +FROM identity.groups +WHERE stream IS NULL + OR course IS NULL + OR number IS NULL + OR degree IS NULL + OR enrollment_year IS NULL +ORDER BY name; diff --git a/deploy/preflight/groups-courses.sql b/deploy/preflight/groups-courses.sql new file mode 100644 index 0000000..597157d --- /dev/null +++ b/deploy/preflight/groups-courses.sql @@ -0,0 +1,124 @@ +-- Preflight (до миграций) для перехода на группы-когорты, курсы и перенос лаб/материалов. +-- Только чтение. Запускать на копии рабочей БД до применения миграций. +-- Пример: +-- psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses.sql + +\pset pager off +\echo '=== 1. Группы, не подходящие под шаблон ^(.+)-([0-9])([0-9]+)(БО|МО)$ ===' +SELECT id, name +FROM identity.groups +WHERE name !~ '^(.+)-([0-9])([0-9]+)(БО|МО)$' +ORDER BY name; + +\echo '=== 2. Разобранные группы с одинаковым кортежем (stream, degree, number, course, enrollment_year) ===' +WITH params AS ( + SELECT CASE + WHEN EXTRACT(MONTH FROM now()) >= 9 THEN EXTRACT(YEAR FROM now()) + ELSE EXTRACT(YEAR FROM now()) - 1 + END::int AS academic_year_start +), +parsed AS ( + SELECT + g.name, + m[1] AS stream, + m[2]::int AS course, + m[3]::int AS number, + m[4] AS degree, + (SELECT academic_year_start FROM params) - (m[2]::int - 1) AS enrollment_year + FROM identity.groups g + CROSS JOIN LATERAL ( + SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m + ) parsed_name + WHERE m IS NOT NULL +) +SELECT stream, degree, number, course, enrollment_year, + count(*) AS groups, + array_agg(name ORDER BY name) AS names +FROM parsed +GROUP BY stream, degree, number, course, enrollment_year +HAVING count(*) > 1 +ORDER BY stream, degree, course, number; + +\echo '=== 3. Группы, которые не разберутся шаблоном (потребуют ручного разбора) ===' +WITH parsed AS ( + SELECT + g.id, + g.name, + m[1] AS stream, + m[2]::int AS course, + m[3]::int AS number, + m[4] AS degree + FROM identity.groups g + CROSS JOIN LATERAL ( + SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m + ) parsed_name +) +SELECT id, name, stream, course, number, degree +FROM parsed +WHERE stream IS NULL + OR course IS NULL + OR number IS NULL + OR degree IS NULL +ORDER BY name; + +\echo '=== 4. Одинаковые имена групп (после снятия UNIQUE станут допустимы) ===' +SELECT name, count(*) AS groups +FROM identity.groups +GROUP BY name +HAVING count(*) > 1 +ORDER BY name; + +\echo '=== 5. Студенты без текущей группы ===' +SELECT s.student_no, a.login, s.last_name, s.first_name +FROM identity.students s +JOIN identity.accounts a ON a.id = s.account_id +LEFT JOIN identity.enrollments e ON e.student_id = s.account_id +WHERE e.id IS NULL +ORDER BY s.student_no; + +\echo '=== 6. Аккаунты по ролям (до появления статусов) ===' +SELECT role, count(*) AS accounts +FROM identity.accounts +GROUP BY role +ORDER BY role; + +\echo '=== 7. Дубли номеров лабораторных (все лабы переезжают в один курс C) ===' +SELECT number, + count(*) AS labs, + array_agg(id ORDER BY created_at) AS ids, + array_agg(title ORDER BY created_at) AS titles +FROM academic.labs +GROUP BY number +HAVING count(*) > 1 +ORDER BY number; + +\echo '=== 8. Лабораторные по семестрам ===' +SELECT semester_id, number, count(*) AS labs +FROM academic.labs +GROUP BY semester_id, number +HAVING count(*) > 1 +ORDER BY semester_id, number; + +\echo '=== 9. Дубли slug глав (до перехода на частичные индексы) ===' +SELECT slug, count(*) AS chapters +FROM academic.chapters +GROUP BY slug +HAVING count(*) > 1 +ORDER BY slug; + +\echo '=== 10. Объём материалов ===' +SELECT + (SELECT count(*) FROM academic.chapters) AS chapters, + (SELECT count(*) FROM academic.sections) AS sections, + (SELECT count(*) FROM academic.articles) AS articles, + (SELECT count(*) FROM academic.article_assets) AS article_assets; + +\echo '=== 11. Активы без статьи ===' +SELECT count(*) AS assets_without_article +FROM academic.article_assets +WHERE article_id IS NULL; + +\echo '=== 12. Семестры ===' +SELECT id, title, year, active +FROM academic.semesters +ORDER BY year, title; diff --git a/services/academic-service/src/main/resources/db/migration/V6__courses.sql b/services/academic-service/src/main/resources/db/migration/V6__courses.sql new file mode 100644 index 0000000..d161b23 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V6__courses.sql @@ -0,0 +1,41 @@ +-- Курс-дисциплина, назначения преподавателей и привязка групп. +CREATE TABLE courses ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + course_no bigint GENERATED ALWAYS AS IDENTITY UNIQUE NOT NULL, + title varchar(200) NOT NULL, + owner_account_id uuid NOT NULL, + status varchar(16) NOT NULL DEFAULT 'ACTIVE', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT courses_status_check CHECK (status IN ('ACTIVE', 'ARCHIVED')) +); + +CREATE INDEX courses_status_idx ON courses (status); + +CREATE TABLE course_teachers ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + course_id uuid NOT NULL REFERENCES courses (id) ON DELETE CASCADE, + account_id uuid NOT NULL, + assigned_at timestamptz NOT NULL DEFAULT now(), + removed_at timestamptz +); + +CREATE UNIQUE INDEX course_teachers_active_uidx + ON course_teachers (course_id, account_id) + WHERE removed_at IS NULL; +CREATE INDEX course_teachers_account_idx ON course_teachers (account_id) + WHERE removed_at IS NULL; + +CREATE TABLE course_groups ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + course_id uuid NOT NULL REFERENCES courses (id) ON DELETE CASCADE, + group_id uuid NOT NULL, + added_at timestamptz NOT NULL DEFAULT now(), + removed_at timestamptz +); + +CREATE UNIQUE INDEX course_groups_active_uidx + ON course_groups (course_id, group_id) + WHERE removed_at IS NULL; +CREATE INDEX course_groups_group_idx ON course_groups (group_id) + WHERE removed_at IS NULL; diff --git a/services/academic-service/src/main/resources/db/migration/V7__labs_course.sql b/services/academic-service/src/main/resources/db/migration/V7__labs_course.sql new file mode 100644 index 0000000..17a582f --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V7__labs_course.sql @@ -0,0 +1,12 @@ +-- Лабораторные привязываются к курсу. Nullable-фаза: курс C создаёт +-- bootstrap после первого входа админа, затем выполняется post-bootstrap +-- миграция с SET NOT NULL. +ALTER TABLE labs ADD COLUMN course_id uuid REFERENCES courses (id) ON DELETE RESTRICT; + +ALTER TABLE labs DROP CONSTRAINT IF EXISTS labs_number_semester_unique; + +-- Для лаб курса номер уникален в рамках курса. NULL-курс (до bootstrap) +-- не участвует в уникальности, поэтому старые дубли по семестрам не мешают +-- миграции; preflight показывает их до привязки. +CREATE UNIQUE INDEX labs_number_course_uidx ON labs (number, course_id); +CREATE INDEX labs_course_idx ON labs (course_id); diff --git a/services/academic-service/src/main/resources/db/migration/V8__chapters_course.sql b/services/academic-service/src/main/resources/db/migration/V8__chapters_course.sql new file mode 100644 index 0000000..e27f361 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V8__chapters_course.sql @@ -0,0 +1,18 @@ +-- Материалы: глава может принадлежать курсу или быть глобальной с аудиторией. +ALTER TABLE chapters ADD COLUMN course_id uuid REFERENCES courses (id) ON DELETE CASCADE; +ALTER TABLE chapters ADD COLUMN audience varchar(16); + +ALTER TABLE chapters + ADD CONSTRAINT chapters_audience_check CHECK ( + audience IS NULL OR audience IN ('ALL', 'STUDENTS', 'TEACHERS') + ), + ADD CONSTRAINT chapters_course_audience_check CHECK ( + course_id IS NULL OR audience IS NULL + ); + +-- slug уникален глобально только среди глобальных глав и внутри курса — +-- среди курсовых. +ALTER TABLE chapters DROP CONSTRAINT IF EXISTS chapters_slug_key; +CREATE UNIQUE INDEX chapters_slug_global_uidx ON chapters (slug) WHERE course_id IS NULL; +CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHERE course_id IS NOT NULL; +CREATE INDEX chapters_course_idx ON chapters (course_id); diff --git a/services/academic-service/src/main/resources/db/migration/V9__article_assets_indexes.sql b/services/academic-service/src/main/resources/db/migration/V9__article_assets_indexes.sql new file mode 100644 index 0000000..ec4c798 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V9__article_assets_indexes.sql @@ -0,0 +1,2 @@ +-- Активы уже связаны со статьёй, но выборка по статье не индексирована. +CREATE INDEX article_assets_article_idx ON article_assets (article_id); diff --git a/services/identity-service/src/main/resources/db/migration/V4__roles.sql b/services/identity-service/src/main/resources/db/migration/V4__roles.sql new file mode 100644 index 0000000..fdf35c0 --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V4__roles.sql @@ -0,0 +1,4 @@ +-- Добавляем роль преподавателя. Старые ADMIN/STUDENT сохраняются. +ALTER TABLE accounts DROP CONSTRAINT IF EXISTS accounts_role_check; +ALTER TABLE accounts + ADD CONSTRAINT accounts_role_check CHECK (role IN ('ADMIN', 'TEACHER', 'STUDENT')); diff --git a/services/identity-service/src/main/resources/db/migration/V5__groups_cohorts.sql b/services/identity-service/src/main/resources/db/migration/V5__groups_cohorts.sql new file mode 100644 index 0000000..a437762 --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V5__groups_cohorts.sql @@ -0,0 +1,62 @@ +-- Поля группы-когорты. На этой фазе nullable: существующие группы могут не +-- соответствовать шаблону имени и требуют ручного разбора. +ALTER TABLE groups + ADD COLUMN stream varchar(50), + ADD COLUMN course smallint, + ADD COLUMN number smallint, + ADD COLUMN degree varchar(2), + ADD COLUMN enrollment_year smallint, + ADD COLUMN status varchar(16) NOT NULL DEFAULT 'ACTIVE'; + +-- Имя больше не глобально уникально: одноимённые группы разных наборов легальны. +ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_name_key; + +-- Best-effort бэкфилл из имени. Учебный год начинается в сентябре. +WITH params AS ( + SELECT CASE + WHEN EXTRACT(MONTH FROM now()) >= 9 THEN EXTRACT(YEAR FROM now()) + ELSE EXTRACT(YEAR FROM now()) - 1 + END::smallint AS academic_year_start +), +parsed AS ( + SELECT + g.id, + m[1] AS stream, + m[2]::smallint AS course, + m[3]::smallint AS number, + m[4] AS degree + FROM groups g + CROSS JOIN LATERAL ( + SELECT regexp_match(g.name, '^(.+)-([0-9])([0-9]+)(БО|МО)$') AS m + ) parsed_name +) +UPDATE groups g +SET stream = p.stream, + course = p.course, + number = p.number, + degree = p.degree, + enrollment_year = ((SELECT academic_year_start FROM params) - (p.course - 1))::smallint +FROM parsed p +WHERE g.id = p.id + AND p.stream IS NOT NULL; + +ALTER TABLE groups + ADD CONSTRAINT groups_status_check CHECK (status IN ('ACTIVE', 'GRADUATED', 'ARCHIVED')), + ADD CONSTRAINT groups_degree_check CHECK (degree IS NULL OR degree IN ('БО', 'МО')), + ADD CONSTRAINT groups_course_range CHECK ( + (degree IS NULL OR course IS NULL) + OR (degree = 'БО' AND course BETWEEN 1 AND 4) + OR (degree = 'МО' AND course BETWEEN 1 AND 2) + ); + +-- Уникальность когорты для уже разобранных групп. После ручного разбора +-- и подтверждения года набора ограничение станет полным (без WHERE). +CREATE UNIQUE INDEX groups_cohort_unique + ON groups (stream, degree, number, course, enrollment_year) + WHERE stream IS NOT NULL + AND degree IS NOT NULL + AND number IS NOT NULL + AND course IS NOT NULL + AND enrollment_year IS NOT NULL; + +CREATE INDEX groups_status_idx ON groups (status); diff --git a/services/identity-service/src/main/resources/db/migration/V6__student_statuses.sql b/services/identity-service/src/main/resources/db/migration/V6__student_statuses.sql new file mode 100644 index 0000000..be135ca --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V6__student_statuses.sql @@ -0,0 +1,20 @@ +-- Академический статус студента и год набора. +ALTER TABLE students + ADD COLUMN status varchar(16) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN enrollment_year smallint; + +ALTER TABLE students + ADD CONSTRAINT students_status_check CHECK ( + status IN ('ACTIVE', 'ACADEMIC_LEAVE', 'EXPELLED', 'GRADUATED', 'ARCHIVED') + ); + +-- Год набора наследуется от текущей группы; для групп без разобранного года +-- остаётся NULL и требует подтверждения админом. +UPDATE students s +SET enrollment_year = g.enrollment_year +FROM enrollments e +JOIN groups g ON g.id = e.group_id +WHERE e.student_id = s.account_id + AND s.enrollment_year IS NULL; + +CREATE INDEX students_status_idx ON students (status); diff --git a/services/identity-service/src/main/resources/db/migration/V7__promotion.sql b/services/identity-service/src/main/resources/db/migration/V7__promotion.sql new file mode 100644 index 0000000..c1c50d0 --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V7__promotion.sql @@ -0,0 +1,43 @@ +-- Журнал повышения курса: шапка запуска и по-элементный план/результат. +CREATE TABLE promotion_runs ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + year integer NOT NULL, + mode varchar(16) NOT NULL, + actor_account_id uuid REFERENCES accounts (id) ON DELETE SET NULL, + status varchar(16) NOT NULL DEFAULT 'PLANNED', + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, + result jsonb, + CONSTRAINT promotion_runs_mode_check CHECK (mode IN ('DRY_RUN', 'APPLY')), + CONSTRAINT promotion_runs_status_check CHECK ( + status IN ('PLANNED', 'RUNNING', 'COMPLETED', 'FAILED', 'ROLLED_BACK') + ) +); + +-- Один успешный (или выполняющийся) APPLY на год. Неудачный запуск можно +-- повторить; после отката статус ROLLED_BACK также не блокирует новый запуск. +CREATE UNIQUE INDEX promotion_runs_apply_year_uidx + ON promotion_runs (year) + WHERE mode = 'APPLY' AND status IN ('RUNNING', 'COMPLETED'); + +CREATE INDEX promotion_runs_year_idx ON promotion_runs (year, mode); + +CREATE TABLE promotion_run_items ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + run_id uuid NOT NULL REFERENCES promotion_runs (id) ON DELETE CASCADE, + entity_type varchar(16) NOT NULL, + entity_id uuid NOT NULL, + action varchar(32) NOT NULL, + before_state jsonb, + after_state jsonb, + status varchar(16) NOT NULL DEFAULT 'PLANNED', + error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT promotion_run_items_entity_check CHECK (entity_type IN ('GROUP', 'STUDENT')), + CONSTRAINT promotion_run_items_status_check CHECK ( + status IN ('PLANNED', 'APPLIED', 'SKIPPED', 'ERROR', 'ROLLED_BACK') + ) +); + +CREATE INDEX promotion_run_items_run_idx ON promotion_run_items (run_id); +CREATE INDEX promotion_run_items_entity_idx ON promotion_run_items (entity_type, entity_id); diff --git a/services/identity-service/src/main/resources/db/migration/V8__membership_history.sql b/services/identity-service/src/main/resources/db/migration/V8__membership_history.sql new file mode 100644 index 0000000..689fb8c --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V8__membership_history.sql @@ -0,0 +1,28 @@ +-- История членства: переводы, академ, возврат, выпуск, отчисление, повышение. +CREATE TABLE membership_history ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + student_id uuid NOT NULL REFERENCES students (account_id) ON DELETE CASCADE, + from_group_id uuid REFERENCES groups (id) ON DELETE SET NULL, + to_group_id uuid REFERENCES groups (id) ON DELETE SET NULL, + action varchar(32) NOT NULL, + reason text NOT NULL DEFAULT '', + actor_account_id uuid REFERENCES accounts (id) ON DELETE SET NULL, + promotion_run_id uuid REFERENCES promotion_runs (id) ON DELETE SET NULL, + at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT membership_history_action_check CHECK ( + action IN ( + 'ENROLL', 'TRANSFER', 'ACADEMIC', 'RETURN', 'GRADUATE', + 'EXPELL', 'PROMOTE', 'IMPORT', 'ARCHIVE', 'RESTORE' + ) + ) +); + +CREATE INDEX membership_history_student_idx ON membership_history (student_id, at DESC); +CREATE INDEX membership_history_from_group_idx ON membership_history (from_group_id); +CREATE INDEX membership_history_to_group_idx ON membership_history (to_group_id); +CREATE INDEX membership_history_promotion_run_idx ON membership_history (promotion_run_id); + +-- Текущие зачисления становятся первой записью истории. +INSERT INTO membership_history (student_id, to_group_id, action, at) +SELECT student_id, group_id, 'ENROLL', enrolled_at +FROM enrollments; diff --git a/services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql b/services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql new file mode 100644 index 0000000..7fc6177 --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql @@ -0,0 +1,9 @@ +-- Статус аккаунта для всех пользователей: архив закрывает вход. +ALTER TABLE accounts + ADD COLUMN status varchar(16) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN archived_at timestamptz; + +ALTER TABLE accounts + ADD CONSTRAINT accounts_status_check CHECK (status IN ('ACTIVE', 'ARCHIVED')); + +CREATE INDEX accounts_status_idx ON accounts (status); diff --git a/services/progress-service/src/main/resources/db/migration/V10__profile_authors.sql b/services/progress-service/src/main/resources/db/migration/V10__profile_authors.sql new file mode 100644 index 0000000..722fa22 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V10__profile_authors.sql @@ -0,0 +1,7 @@ +-- Автор комментария и последнего изменения оценки — для профиля студента курса. +-- Старые записи остаются без автора и отображаются как «автор не указан». +ALTER TABLE grade_comments ADD COLUMN author_account_id uuid; +ALTER TABLE lab_grades ADD COLUMN updated_by_account_id uuid; + +-- Комментарии уже индексированы grade_comments_grade_idx (student_id, lab_id). +CREATE INDEX lab_grades_lab_idx ON lab_grades (lab_id);