From 008781edc11d451212dab0129d6bad8971526a3e Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sun, 12 Jul 2026 02:38:16 +0200 Subject: [PATCH 1/3] feat(course): Add the courses, lessons, and enrollments schema Three Liquibase changesets create the tables, aligned with the domain lifecycles documented in CONTRIBUTING.md: courses and lessons carry a DRAFT/PUBLISHED/ARCHIVED status (removal is modeled as ARCHIVED), and enrollments persists the student progress as ENROLLED/IN_PROGRESS/COMPLETED/DROPPED with one row per (user, course) pair as the composite primary key. Index choices follow the composite-index leading-column rule: the UNIQUE (course_id, position) constraint on lessons doubles as its foreign-key index, while enrollments needs an explicit index on course_id (its PK leads with user_id) and courses one on instructor_id. The instructor FK is ON DELETE RESTRICT (a teaching user cannot vanish); course deletion cascades to lessons and enrollments. Update the Merise MCD source with the Course and Lesson entities and the Teaches, Contains, and Enrolls associations, regenerate the MCD and MLD diagrams, and regenerate the MPD from a migrated PostgreSQL 17. The new .tbls.yml keeps Liquibase's bookkeeping tables out of the MPD, matching the committed convention. --- .tbls.yml | 5 + docs/database/merise/learn-dev.mcd | 15 +- docs/database/merise/learn-dev.svg | 299 +++-- docs/database/merise/learn-dev_mld.svg | 273 +++-- docs/database/merise/mpd/README.md | 9 +- .../database/merise/mpd/public.audit_logs.svg | 177 +-- docs/database/merise/mpd/public.courses.md | 37 + docs/database/merise/mpd/public.courses.svg | 173 +++ .../merise/mpd/public.email_tokens.svg | 135 +-- .../database/merise/mpd/public.enrollments.md | 36 + .../merise/mpd/public.enrollments.svg | 133 ++ docs/database/merise/mpd/public.lessons.md | 38 + docs/database/merise/mpd/public.lessons.svg | 87 ++ .../merise/mpd/public.reset_tokens.svg | 141 +-- docs/database/merise/mpd/public.roles.svg | 7 +- .../database/merise/mpd/public.user_roles.svg | 179 ++- docs/database/merise/mpd/public.users.md | 2 +- docs/database/merise/mpd/public.users.svg | 397 +++--- docs/database/merise/mpd/schema.json | 1068 +++++++++++++++++ docs/database/merise/mpd/schema.svg | 487 +++++--- .../V20260711230000-create-courses-table.sql | 19 + .../V20260711230001-create-lessons-table.sql | 20 + ...0260711230002-create-enrollments-table.sql | 20 + ...30003-create-idx-courses-instructor-id.sql | 6 + ...30004-create-idx-enrollments-course-id.sql | 7 + 25 files changed, 2896 insertions(+), 874 deletions(-) create mode 100644 .tbls.yml create mode 100644 docs/database/merise/mpd/public.courses.md create mode 100644 docs/database/merise/mpd/public.courses.svg create mode 100644 docs/database/merise/mpd/public.enrollments.md create mode 100644 docs/database/merise/mpd/public.enrollments.svg create mode 100644 docs/database/merise/mpd/public.lessons.md create mode 100644 docs/database/merise/mpd/public.lessons.svg create mode 100644 docs/database/merise/mpd/schema.json create mode 100644 src/main/resources/db/changelog/changes/V20260711230000-create-courses-table.sql create mode 100644 src/main/resources/db/changelog/changes/V20260711230001-create-lessons-table.sql create mode 100644 src/main/resources/db/changelog/changes/V20260711230002-create-enrollments-table.sql create mode 100644 src/main/resources/db/changelog/changes/V20260711230003-create-idx-courses-instructor-id.sql create mode 100644 src/main/resources/db/changelog/changes/V20260711230004-create-idx-enrollments-course-id.sql diff --git a/.tbls.yml b/.tbls.yml new file mode 100644 index 0000000..8235838 --- /dev/null +++ b/.tbls.yml @@ -0,0 +1,5 @@ +# tbls configuration (used by `make mpd`): document the application schema +# only, not Liquibase's bookkeeping tables. +exclude: + - databasechangelog + - databasechangeloglock diff --git a/docs/database/merise/learn-dev.mcd b/docs/database/merise/learn-dev.mcd index e2f7bc8..30564cf 100644 --- a/docs/database/merise/learn-dev.mcd +++ b/docs/database/merise/learn-dev.mcd @@ -20,4 +20,17 @@ Performs, 01 Audit Log, 0N User Requests, 11 Reset Token, 0N User User: user_id, username, email, password, first_name, last_name, is_active, is_verified, is_locked, failed_login_attempts, last_login_at, password_changed_at Holds, 0N User, 0N Role : assigned_at, assigned_by -Role: role_id, role_name, description, is_active \ No newline at end of file +Role: role_id, role_name, description, is_active +: + +% Course/lesson domain. Teaches materializes as courses.instructor_id in the +% MLD/MPD; Enrolls (many-to-many with attributes) becomes the enrollments table. +: +Teaches, 0N User, 11 Course +Enrolls, 0N User, 0N Course : status, enrolled_at, completed_at, dropped_at +: + +Lesson: lesson_id, title, content_markdown, position, status, created_at, updated_at +Contains, 0N Course, 11 Lesson +Course: course_id, title, description, status, published_at, created_at, updated_at +: \ No newline at end of file diff --git a/docs/database/merise/learn-dev.svg b/docs/database/merise/learn-dev.svg index 7e3c097..6816073 100644 --- a/docs/database/merise/learn-dev.svg +++ b/docs/database/merise/learn-dev.svg @@ -1,163 +1,250 @@ - - + + - - + + - - - - - Receives + + + + + Receives - 1,1 - 0,N + 1,1 + 0,N - - + + - - - - - Performs + + + + + Performs - 0,1 - 0,N + 0,1 + 0,N - - + + - - - - - Requests + + + + + Requests - 1,1 - 0,N + 1,1 + 0,N - - + + - - - - - Holds - assigned_at - assigned_by + + + + + Holds + assigned_at + assigned_by - 0,N - 0,N + 0,N + 0,N + + + + + + + + + + + + Teaches + + 0,N + 1,1 + + + + + + + + + + + + Enrolls + status + enrolled_at + completed_at + dropped_at + + 0,N + 0,N + + + + + + + + + + + + Contains + + 0,N + 1,1 - - - - + + + + - Email Token - token_id - - token - expires_at - used_at + Email Token + token_id + + token + expires_at + used_at - - - - + + + + - Audit Log - log_id - - action_type - entity_type - entity_id - description - ip_address - user_agent - was_successful - error_message - metadata - created_at + Audit Log + log_id + + action_type + entity_type + entity_id + description + ip_address + user_agent + was_successful + error_message + metadata + created_at - - - - + + + + - Reset Token - token_id - - token - expires_at - used_at - ip_address + Reset Token + token_id + + token + expires_at + used_at + ip_address - - - - + + + + - User - user_id - - username - email - password - first_name - last_name - is_active - is_verified - is_locked - failed_login_attempts - last_login_at - password_changed_at + User + user_id + + username + email + password + first_name + last_name + is_active + is_verified + is_locked + failed_login_attempts + last_login_at + password_changed_at - - - - + + + + + + Role + role_id + + role_name + description + is_active + + + + + + + + + + + Lesson + lesson_id + + title + content_markdown + position + status + created_at + updated_at + + + + + + + + + - Role - role_id - - role_name - description - is_active + Course + course_id + + title + description + status + published_at + created_at + updated_at \ No newline at end of file diff --git a/docs/database/merise/learn-dev_mld.svg b/docs/database/merise/learn-dev_mld.svg index 7e20c93..fb32159 100644 --- a/docs/database/merise/learn-dev_mld.svg +++ b/docs/database/merise/learn-dev_mld.svg @@ -1,146 +1,225 @@ - - + + - - - - + + + + - Email Token - token_id - - token - expires_at - used_at - #user_id + Email Token + token_id + + token + expires_at + used_at + #user_id - - - - + + + + - Audit Log - log_id - - action_type - entity_type - entity_id - description - ip_address - user_agent - was_successful - error_message - metadata - created_at - #user_id + Audit Log + log_id + + action_type + entity_type + entity_id + description + ip_address + user_agent + was_successful + error_message + metadata + created_at + #user_id - - - - + + + + - Reset Token - token_id - - token - expires_at - used_at - ip_address - #user_id + Reset Token + token_id + + token + expires_at + used_at + ip_address + #user_id - - - - + + + + - User - user_id - - username - email - password - first_name - last_name - is_active - is_verified - is_locked - failed_login_attempts - last_login_at - password_changed_at + User + user_id + + username + email + password + first_name + last_name + is_active + is_verified + is_locked + failed_login_attempts + last_login_at + password_changed_at - - - - + + + + - Holds - #user_id - - #role_id - - assigned_at - assigned_by + Holds + #user_id + + #role_id + + assigned_at + assigned_by - - - - + + + + - Role - role_id - - role_name - description - is_active + Role + role_id + + role_name + description + is_active + + + + + + + + + + + Enrolls + #user_id + + #course_id + + status + enrolled_at + completed_at + dropped_at + + + + + + + + + + + Lesson + lesson_id + + title + content_markdown + position + status + created_at + updated_at + #course_id + + + + + + + + + + + Course + course_id + + title + description + status + published_at + created_at + updated_at + #user_id - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/database/merise/mpd/README.md b/docs/database/merise/mpd/README.md index 6b7473e..b3220e7 100644 --- a/docs/database/merise/mpd/README.md +++ b/docs/database/merise/mpd/README.md @@ -1,8 +1,4 @@ -# learn-dev - -## Description - -Learn-dev MPD (Physical Data Model) +# learndev ## Tables @@ -14,6 +10,9 @@ Learn-dev MPD (Physical Data Model) | [public.email_tokens](public.email_tokens.md) | 5 | | BASE TABLE | | [public.reset_tokens](public.reset_tokens.md) | 6 | | BASE TABLE | | [public.audit_logs](public.audit_logs.md) | 12 | | BASE TABLE | +| [public.courses](public.courses.md) | 8 | | BASE TABLE | +| [public.lessons](public.lessons.md) | 8 | | BASE TABLE | +| [public.enrollments](public.enrollments.md) | 6 | | BASE TABLE | ## Relations diff --git a/docs/database/merise/mpd/public.audit_logs.svg b/docs/database/merise/mpd/public.audit_logs.svg index 93ee9ab..af3f2fb 100644 --- a/docs/database/merise/mpd/public.audit_logs.svg +++ b/docs/database/merise/mpd/public.audit_logs.svg @@ -4,107 +4,108 @@ - + public.audit_logs - + public.audit_logs - - -public.audit_logs -     -[BASE TABLE] - -log_id -[bigint] - -action_type -[varchar(100)] - -entity_type -[varchar(100)] - -entity_id -[varchar(64)] - -description -[text] - -ip_address -[inet] - -user_agent -[text] - -was_successful -[boolean] - -error_message -[text] - -metadata -[jsonb] - -created_at -[timestamp with time zone] - -user_id -[uuid] - + + +public.audit_logs +     +[BASE TABLE] + +log_id +[bigint] + +action_type +[varchar(100)] + +entity_type +[varchar(100)] + +entity_id +[varchar(64)] + +description +[text] + +ip_address +[inet] + +user_agent +[text] + +was_successful +[boolean] + +error_message +[text] + +metadata +[jsonb] + +created_at +[timestamp with time zone] + +user_id +[uuid] + public.users - - -public.users -     -[BASE TABLE] - -user_id -[uuid] - -username -[varchar(50)] - -email -[varchar(255)] - -password -[varchar(255)] - -first_name -[varchar(100)] - -last_name -[varchar(100)] - -is_active -[boolean] - -is_verified -[boolean] - -is_locked -[boolean] - -failed_login_attempts -[integer] - -last_login_at -[timestamp with time zone] - -password_changed_at -[timestamp with time zone] + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] public.audit_logs:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL diff --git a/docs/database/merise/mpd/public.courses.md b/docs/database/merise/mpd/public.courses.md new file mode 100644 index 0000000..eac723f --- /dev/null +++ b/docs/database/merise/mpd/public.courses.md @@ -0,0 +1,37 @@ +# public.courses + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| course_id | bigint | | false | [public.lessons](public.lessons.md) [public.enrollments](public.enrollments.md) | | | +| title | varchar(255) | | false | | | | +| description | text | | true | | | | +| status | varchar(20) | 'DRAFT'::character varying | false | | | | +| published_at | timestamp with time zone | | true | | | | +| created_at | timestamp with time zone | now() | false | | | | +| updated_at | timestamp with time zone | now() | false | | | | +| instructor_id | uuid | | false | | [public.users](public.users.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| chk_courses_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['DRAFT'::character varying, 'PUBLISHED'::character varying, 'ARCHIVED'::character varying])::text[]))) | +| courses_instructor_id_fkey | FOREIGN KEY | FOREIGN KEY (instructor_id) REFERENCES users(user_id) ON DELETE RESTRICT | +| courses_pkey | PRIMARY KEY | PRIMARY KEY (course_id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| courses_pkey | CREATE UNIQUE INDEX courses_pkey ON public.courses USING btree (course_id) | +| idx_courses_instructor_id | CREATE INDEX idx_courses_instructor_id ON public.courses USING btree (instructor_id) | + +## Relations + +![er](public.courses.svg) + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/database/merise/mpd/public.courses.svg b/docs/database/merise/mpd/public.courses.svg new file mode 100644 index 0000000..7dce4e3 --- /dev/null +++ b/docs/database/merise/mpd/public.courses.svg @@ -0,0 +1,173 @@ + + + + + + +public.courses + + + +public.courses + + +public.courses +     +[BASE TABLE] + +course_id +[bigint] + +title +[varchar(255)] + +description +[text] + +status +[varchar(20)] + +published_at +[timestamp with time zone] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +instructor_id +[uuid] + + + + +public.users + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] + + + +public.courses:instructor_id->public.users:user_id + + +FOREIGN KEY (instructor_id) REFERENCES users(user_id) ON DELETE RESTRICT + + + +public.lessons + + +public.lessons +     +[BASE TABLE] + +lesson_id +[bigint] + +title +[varchar(255)] + +content_markdown +[text] + +position +[integer] + +status +[varchar(20)] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +course_id +[bigint] + + + +public.lessons:course_id->public.courses:course_id + + +FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE + + + +public.enrollments + + +public.enrollments +     +[BASE TABLE] + +user_id +[uuid] + +course_id +[bigint] + +status +[varchar(20)] + +enrolled_at +[timestamp with time zone] + +completed_at +[timestamp with time zone] + +dropped_at +[timestamp with time zone] + + + +public.enrollments:course_id->public.courses:course_id + + +FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE + + + diff --git a/docs/database/merise/mpd/public.email_tokens.svg b/docs/database/merise/mpd/public.email_tokens.svg index af01e3b..09794e1 100644 --- a/docs/database/merise/mpd/public.email_tokens.svg +++ b/docs/database/merise/mpd/public.email_tokens.svg @@ -4,86 +4,87 @@ - + public.email_tokens - + public.email_tokens - - -public.email_tokens -     -[BASE TABLE] - -token_id -[bigint] - -token -[varchar(255)] - -expires_at -[timestamp with time zone] - -used_at -[timestamp with time zone] - -user_id -[uuid] - + + +public.email_tokens +     +[BASE TABLE] + +token_id +[bigint] + +token +[varchar(255)] + +expires_at +[timestamp with time zone] + +used_at +[timestamp with time zone] + +user_id +[uuid] + public.users - - -public.users -     -[BASE TABLE] - -user_id -[uuid] - -username -[varchar(50)] - -email -[varchar(255)] - -password -[varchar(255)] - -first_name -[varchar(100)] - -last_name -[varchar(100)] - -is_active -[boolean] - -is_verified -[boolean] - -is_locked -[boolean] - -failed_login_attempts -[integer] - -last_login_at -[timestamp with time zone] - -password_changed_at -[timestamp with time zone] + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] public.email_tokens:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE diff --git a/docs/database/merise/mpd/public.enrollments.md b/docs/database/merise/mpd/public.enrollments.md new file mode 100644 index 0000000..0516a7b --- /dev/null +++ b/docs/database/merise/mpd/public.enrollments.md @@ -0,0 +1,36 @@ +# public.enrollments + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| user_id | uuid | | false | | [public.users](public.users.md) | | +| course_id | bigint | | false | | [public.courses](public.courses.md) | | +| status | varchar(20) | 'ENROLLED'::character varying | false | | | | +| enrolled_at | timestamp with time zone | now() | false | | | | +| completed_at | timestamp with time zone | | true | | | | +| dropped_at | timestamp with time zone | | true | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| chk_enrollments_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['ENROLLED'::character varying, 'IN_PROGRESS'::character varying, 'COMPLETED'::character varying, 'DROPPED'::character varying])::text[]))) | +| enrollments_user_id_fkey | FOREIGN KEY | FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE | +| enrollments_course_id_fkey | FOREIGN KEY | FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE | +| enrollments_pkey | PRIMARY KEY | PRIMARY KEY (user_id, course_id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| enrollments_pkey | CREATE UNIQUE INDEX enrollments_pkey ON public.enrollments USING btree (user_id, course_id) | +| idx_enrollments_course_id | CREATE INDEX idx_enrollments_course_id ON public.enrollments USING btree (course_id) | + +## Relations + +![er](public.enrollments.svg) + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/database/merise/mpd/public.enrollments.svg b/docs/database/merise/mpd/public.enrollments.svg new file mode 100644 index 0000000..a5ddb36 --- /dev/null +++ b/docs/database/merise/mpd/public.enrollments.svg @@ -0,0 +1,133 @@ + + + + + + +public.enrollments + + + +public.enrollments + + +public.enrollments +     +[BASE TABLE] + +user_id +[uuid] + +course_id +[bigint] + +status +[varchar(20)] + +enrolled_at +[timestamp with time zone] + +completed_at +[timestamp with time zone] + +dropped_at +[timestamp with time zone] + + + + +public.users + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] + + + +public.enrollments:user_id->public.users:user_id + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + + + +public.courses + + +public.courses +     +[BASE TABLE] + +course_id +[bigint] + +title +[varchar(255)] + +description +[text] + +status +[varchar(20)] + +published_at +[timestamp with time zone] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +instructor_id +[uuid] + + + +public.enrollments:course_id->public.courses:course_id + + +FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE + + + diff --git a/docs/database/merise/mpd/public.lessons.md b/docs/database/merise/mpd/public.lessons.md new file mode 100644 index 0000000..da8099c --- /dev/null +++ b/docs/database/merise/mpd/public.lessons.md @@ -0,0 +1,38 @@ +# public.lessons + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| lesson_id | bigint | | false | | | | +| title | varchar(255) | | false | | | | +| content_markdown | text | ''::text | false | | | | +| position | integer | | false | | | | +| status | varchar(20) | 'DRAFT'::character varying | false | | | | +| created_at | timestamp with time zone | now() | false | | | | +| updated_at | timestamp with time zone | now() | false | | | | +| course_id | bigint | | false | | [public.courses](public.courses.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| chk_lessons_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['DRAFT'::character varying, 'PUBLISHED'::character varying, 'ARCHIVED'::character varying])::text[]))) | +| lessons_course_id_fkey | FOREIGN KEY | FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE | +| lessons_pkey | PRIMARY KEY | PRIMARY KEY (lesson_id) | +| uq_lessons_course_position | UNIQUE | UNIQUE (course_id, "position") | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| lessons_pkey | CREATE UNIQUE INDEX lessons_pkey ON public.lessons USING btree (lesson_id) | +| uq_lessons_course_position | CREATE UNIQUE INDEX uq_lessons_course_position ON public.lessons USING btree (course_id, "position") | + +## Relations + +![er](public.lessons.svg) + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/database/merise/mpd/public.lessons.svg b/docs/database/merise/mpd/public.lessons.svg new file mode 100644 index 0000000..47a0632 --- /dev/null +++ b/docs/database/merise/mpd/public.lessons.svg @@ -0,0 +1,87 @@ + + + + + + +public.lessons + + + +public.lessons + + +public.lessons +     +[BASE TABLE] + +lesson_id +[bigint] + +title +[varchar(255)] + +content_markdown +[text] + +position +[integer] + +status +[varchar(20)] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +course_id +[bigint] + + + + +public.courses + + +public.courses +     +[BASE TABLE] + +course_id +[bigint] + +title +[varchar(255)] + +description +[text] + +status +[varchar(20)] + +published_at +[timestamp with time zone] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +instructor_id +[uuid] + + + +public.lessons:course_id->public.courses:course_id + + +FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE + + + diff --git a/docs/database/merise/mpd/public.reset_tokens.svg b/docs/database/merise/mpd/public.reset_tokens.svg index cb0a6b4..792a758 100644 --- a/docs/database/merise/mpd/public.reset_tokens.svg +++ b/docs/database/merise/mpd/public.reset_tokens.svg @@ -4,89 +4,90 @@ - + public.reset_tokens - + public.reset_tokens - - -public.reset_tokens -     -[BASE TABLE] - -token_id -[bigint] - -token -[varchar(255)] - -expires_at -[timestamp with time zone] - -used_at -[timestamp with time zone] - -ip_address -[inet] - -user_id -[uuid] - + + +public.reset_tokens +     +[BASE TABLE] + +token_id +[bigint] + +token +[varchar(255)] + +expires_at +[timestamp with time zone] + +used_at +[timestamp with time zone] + +ip_address +[inet] + +user_id +[uuid] + public.users - - -public.users -     -[BASE TABLE] - -user_id -[uuid] - -username -[varchar(50)] - -email -[varchar(255)] - -password -[varchar(255)] - -first_name -[varchar(100)] - -last_name -[varchar(100)] - -is_active -[boolean] - -is_verified -[boolean] - -is_locked -[boolean] - -failed_login_attempts -[integer] - -last_login_at -[timestamp with time zone] - -password_changed_at -[timestamp with time zone] + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] public.reset_tokens:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE diff --git a/docs/database/merise/mpd/public.roles.svg b/docs/database/merise/mpd/public.roles.svg index 148bcf7..06611bf 100644 --- a/docs/database/merise/mpd/public.roles.svg +++ b/docs/database/merise/mpd/public.roles.svg @@ -4,11 +4,11 @@ - + public.roles - + public.roles @@ -57,6 +57,7 @@ public.user_roles:role_id->public.roles:role_id +FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE diff --git a/docs/database/merise/mpd/public.user_roles.svg b/docs/database/merise/mpd/public.user_roles.svg index f373d99..f016770 100644 --- a/docs/database/merise/mpd/public.user_roles.svg +++ b/docs/database/merise/mpd/public.user_roles.svg @@ -4,122 +4,119 @@ - + public.user_roles - + public.user_roles - - -public.user_roles -     -[BASE TABLE] - -user_id -[uuid] - -role_id -[bigint] - -assigned_at -[timestamp with time zone] - -assigned_by -[uuid] - + + +public.user_roles +     +[BASE TABLE] + +user_id +[uuid] + +role_id +[bigint] + +assigned_at +[timestamp with time zone] + +assigned_by +[uuid] + public.users - - -public.users -     -[BASE TABLE] - -user_id -[uuid] - -username -[varchar(50)] - -email -[varchar(255)] - -password -[varchar(255)] - -first_name -[varchar(100)] - -last_name -[varchar(100)] - -is_active -[boolean] - -is_verified -[boolean] - -is_locked -[boolean] - -failed_login_attempts -[integer] - -last_login_at -[timestamp with time zone] - -password_changed_at -[timestamp with time zone] + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] public.user_roles:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE public.user_roles:assigned_by->public.users:user_id - - - - - -public.user_roles:assigned_by->public.users:user_id - - + + +FOREIGN KEY (assigned_by) REFERENCES users(user_id) ON DELETE SET NULL public.roles - - -public.roles -     -[BASE TABLE] - -role_id -[bigint] - -role_name -[varchar(50)] - -description -[varchar(255)] - -is_active -[boolean] + + +public.roles +     +[BASE TABLE] + +role_id +[bigint] + +role_name +[varchar(50)] + +description +[varchar(255)] + +is_active +[boolean] public.user_roles:role_id->public.roles:role_id - - + + +FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE diff --git a/docs/database/merise/mpd/public.users.md b/docs/database/merise/mpd/public.users.md index 278ef47..dbe53cc 100644 --- a/docs/database/merise/mpd/public.users.md +++ b/docs/database/merise/mpd/public.users.md @@ -4,7 +4,7 @@ | Name | Type | Default | Nullable | Children | Parents | Comment | | ---- | ---- | ------- | -------- | -------- | ------- | ------- | -| user_id | uuid | gen_random_uuid() | false | [public.user_roles](public.user_roles.md) [public.email_tokens](public.email_tokens.md) [public.reset_tokens](public.reset_tokens.md) [public.audit_logs](public.audit_logs.md) | | | +| user_id | uuid | gen_random_uuid() | false | [public.user_roles](public.user_roles.md) [public.email_tokens](public.email_tokens.md) [public.reset_tokens](public.reset_tokens.md) [public.audit_logs](public.audit_logs.md) [public.courses](public.courses.md) [public.enrollments](public.enrollments.md) | | | | username | varchar(50) | | false | | | | | email | varchar(255) | | false | | | | | password | varchar(255) | | false | | | | diff --git a/docs/database/merise/mpd/public.users.svg b/docs/database/merise/mpd/public.users.svg index 2459378..bee818b 100644 --- a/docs/database/merise/mpd/public.users.svg +++ b/docs/database/merise/mpd/public.users.svg @@ -4,209 +4,282 @@ - + public.users - + public.users - - -public.users -     -[BASE TABLE] - -user_id -[uuid] - -username -[varchar(50)] - -email -[varchar(255)] - -password -[varchar(255)] - -first_name -[varchar(100)] - -last_name -[varchar(100)] - -is_active -[boolean] - -is_verified -[boolean] - -is_locked -[boolean] - -failed_login_attempts -[integer] - -last_login_at -[timestamp with time zone] - -password_changed_at -[timestamp with time zone] - + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] + public.user_roles - - -public.user_roles -     -[BASE TABLE] - -user_id -[uuid] - -role_id -[bigint] - -assigned_at -[timestamp with time zone] - -assigned_by -[uuid] + + +public.user_roles +     +[BASE TABLE] + +user_id +[uuid] + +role_id +[bigint] + +assigned_at +[timestamp with time zone] + +assigned_by +[uuid] public.user_roles:assigned_by->public.users:user_id - - + + +FOREIGN KEY (assigned_by) REFERENCES users(user_id) ON DELETE SET NULL public.user_roles:user_id->public.users:user_id - - - - - -public.user_roles:assigned_by->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE public.email_tokens - - -public.email_tokens -     -[BASE TABLE] - -token_id -[bigint] - -token -[varchar(255)] - -expires_at -[timestamp with time zone] - -used_at -[timestamp with time zone] - -user_id -[uuid] + + +public.email_tokens +     +[BASE TABLE] + +token_id +[bigint] + +token +[varchar(255)] + +expires_at +[timestamp with time zone] + +used_at +[timestamp with time zone] + +user_id +[uuid] public.email_tokens:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE public.reset_tokens - - -public.reset_tokens -     -[BASE TABLE] - -token_id -[bigint] - -token -[varchar(255)] - -expires_at -[timestamp with time zone] - -used_at -[timestamp with time zone] - -ip_address -[inet] - -user_id -[uuid] + + +public.reset_tokens +     +[BASE TABLE] + +token_id +[bigint] + +token +[varchar(255)] + +expires_at +[timestamp with time zone] + +used_at +[timestamp with time zone] + +ip_address +[inet] + +user_id +[uuid] public.reset_tokens:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE public.audit_logs - - -public.audit_logs -     -[BASE TABLE] - -log_id -[bigint] - -action_type -[varchar(100)] - -entity_type -[varchar(100)] - -entity_id -[varchar(64)] - -description -[text] - -ip_address -[inet] - -user_agent -[text] - -was_successful -[boolean] - -error_message -[text] - -metadata -[jsonb] - -created_at -[timestamp with time zone] - -user_id -[uuid] + + +public.audit_logs +     +[BASE TABLE] + +log_id +[bigint] + +action_type +[varchar(100)] + +entity_type +[varchar(100)] + +entity_id +[varchar(64)] + +description +[text] + +ip_address +[inet] + +user_agent +[text] + +was_successful +[boolean] + +error_message +[text] + +metadata +[jsonb] + +created_at +[timestamp with time zone] + +user_id +[uuid] public.audit_logs:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL + + + +public.courses + + +public.courses +     +[BASE TABLE] + +course_id +[bigint] + +title +[varchar(255)] + +description +[text] + +status +[varchar(20)] + +published_at +[timestamp with time zone] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +instructor_id +[uuid] + + + +public.courses:instructor_id->public.users:user_id + + +FOREIGN KEY (instructor_id) REFERENCES users(user_id) ON DELETE RESTRICT + + + +public.enrollments + + +public.enrollments +     +[BASE TABLE] + +user_id +[uuid] + +course_id +[bigint] + +status +[varchar(20)] + +enrolled_at +[timestamp with time zone] + +completed_at +[timestamp with time zone] + +dropped_at +[timestamp with time zone] + + + +public.enrollments:user_id->public.users:user_id + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE diff --git a/docs/database/merise/mpd/schema.json b/docs/database/merise/mpd/schema.json new file mode 100644 index 0000000..2cefe35 --- /dev/null +++ b/docs/database/merise/mpd/schema.json @@ -0,0 +1,1068 @@ +{ + "name": "learndev", + "tables": [ + { + "name": "public.users", + "type": "BASE TABLE", + "columns": [ + { + "name": "user_id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()" + }, + { + "name": "username", + "type": "varchar(50)", + "nullable": false + }, + { + "name": "email", + "type": "varchar(255)", + "nullable": false + }, + { + "name": "password", + "type": "varchar(255)", + "nullable": false + }, + { + "name": "first_name", + "type": "varchar(100)", + "nullable": true + }, + { + "name": "last_name", + "type": "varchar(100)", + "nullable": true + }, + { + "name": "is_active", + "type": "boolean", + "nullable": false, + "default": "true" + }, + { + "name": "is_verified", + "type": "boolean", + "nullable": false, + "default": "false" + }, + { + "name": "is_locked", + "type": "boolean", + "nullable": false, + "default": "false" + }, + { + "name": "failed_login_attempts", + "type": "integer", + "nullable": false, + "default": "0" + }, + { + "name": "last_login_at", + "type": "timestamp with time zone", + "nullable": true + }, + { + "name": "password_changed_at", + "type": "timestamp with time zone", + "nullable": true + } + ], + "indexes": [ + { + "name": "users_pkey", + "def": "CREATE UNIQUE INDEX users_pkey ON public.users USING btree (user_id)", + "table": "public.users", + "columns": [ + "user_id" + ] + }, + { + "name": "users_username_key", + "def": "CREATE UNIQUE INDEX users_username_key ON public.users USING btree (username)", + "table": "public.users", + "columns": [ + "username" + ] + }, + { + "name": "users_email_key", + "def": "CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)", + "table": "public.users", + "columns": [ + "email" + ] + } + ], + "constraints": [ + { + "name": "users_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (user_id)", + "table": "public.users", + "referenced_table": "", + "columns": [ + "user_id" + ] + }, + { + "name": "users_username_key", + "type": "UNIQUE", + "def": "UNIQUE (username)", + "table": "public.users", + "referenced_table": "", + "columns": [ + "username" + ] + }, + { + "name": "users_email_key", + "type": "UNIQUE", + "def": "UNIQUE (email)", + "table": "public.users", + "referenced_table": "", + "columns": [ + "email" + ] + } + ] + }, + { + "name": "public.roles", + "type": "BASE TABLE", + "columns": [ + { + "name": "role_id", + "type": "bigint", + "nullable": false + }, + { + "name": "role_name", + "type": "varchar(50)", + "nullable": false + }, + { + "name": "description", + "type": "varchar(255)", + "nullable": true + }, + { + "name": "is_active", + "type": "boolean", + "nullable": false, + "default": "true" + } + ], + "indexes": [ + { + "name": "roles_pkey", + "def": "CREATE UNIQUE INDEX roles_pkey ON public.roles USING btree (role_id)", + "table": "public.roles", + "columns": [ + "role_id" + ] + }, + { + "name": "roles_role_name_key", + "def": "CREATE UNIQUE INDEX roles_role_name_key ON public.roles USING btree (role_name)", + "table": "public.roles", + "columns": [ + "role_name" + ] + } + ], + "constraints": [ + { + "name": "roles_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (role_id)", + "table": "public.roles", + "referenced_table": "", + "columns": [ + "role_id" + ] + }, + { + "name": "roles_role_name_key", + "type": "UNIQUE", + "def": "UNIQUE (role_name)", + "table": "public.roles", + "referenced_table": "", + "columns": [ + "role_name" + ] + } + ] + }, + { + "name": "public.user_roles", + "type": "BASE TABLE", + "columns": [ + { + "name": "user_id", + "type": "uuid", + "nullable": false + }, + { + "name": "role_id", + "type": "bigint", + "nullable": false + }, + { + "name": "assigned_at", + "type": "timestamp with time zone", + "nullable": false, + "default": "now()" + }, + { + "name": "assigned_by", + "type": "uuid", + "nullable": true + } + ], + "indexes": [ + { + "name": "user_roles_pkey", + "def": "CREATE UNIQUE INDEX user_roles_pkey ON public.user_roles USING btree (user_id, role_id)", + "table": "public.user_roles", + "columns": [ + "user_id", + "role_id" + ] + }, + { + "name": "idx_user_roles_role_id", + "def": "CREATE INDEX idx_user_roles_role_id ON public.user_roles USING btree (role_id)", + "table": "public.user_roles", + "columns": [ + "role_id" + ] + } + ], + "constraints": [ + { + "name": "user_roles_assigned_by_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (assigned_by) REFERENCES users(user_id) ON DELETE SET NULL", + "table": "public.user_roles", + "referenced_table": "public.users", + "columns": [ + "assigned_by" + ], + "referenced_columns": [ + "user_id" + ] + }, + { + "name": "user_roles_user_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE", + "table": "public.user_roles", + "referenced_table": "public.users", + "columns": [ + "user_id" + ], + "referenced_columns": [ + "user_id" + ] + }, + { + "name": "user_roles_role_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE", + "table": "public.user_roles", + "referenced_table": "public.roles", + "columns": [ + "role_id" + ], + "referenced_columns": [ + "role_id" + ] + }, + { + "name": "user_roles_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (user_id, role_id)", + "table": "public.user_roles", + "referenced_table": "", + "columns": [ + "user_id", + "role_id" + ] + } + ] + }, + { + "name": "public.email_tokens", + "type": "BASE TABLE", + "columns": [ + { + "name": "token_id", + "type": "bigint", + "nullable": false + }, + { + "name": "token", + "type": "varchar(255)", + "nullable": false + }, + { + "name": "expires_at", + "type": "timestamp with time zone", + "nullable": false + }, + { + "name": "used_at", + "type": "timestamp with time zone", + "nullable": true + }, + { + "name": "user_id", + "type": "uuid", + "nullable": false + } + ], + "indexes": [ + { + "name": "email_tokens_pkey", + "def": "CREATE UNIQUE INDEX email_tokens_pkey ON public.email_tokens USING btree (token_id)", + "table": "public.email_tokens", + "columns": [ + "token_id" + ] + }, + { + "name": "email_tokens_token_key", + "def": "CREATE UNIQUE INDEX email_tokens_token_key ON public.email_tokens USING btree (token)", + "table": "public.email_tokens", + "columns": [ + "token" + ] + }, + { + "name": "idx_email_tokens_user_id", + "def": "CREATE INDEX idx_email_tokens_user_id ON public.email_tokens USING btree (user_id)", + "table": "public.email_tokens", + "columns": [ + "user_id" + ] + } + ], + "constraints": [ + { + "name": "email_tokens_user_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE", + "table": "public.email_tokens", + "referenced_table": "public.users", + "columns": [ + "user_id" + ], + "referenced_columns": [ + "user_id" + ] + }, + { + "name": "email_tokens_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (token_id)", + "table": "public.email_tokens", + "referenced_table": "", + "columns": [ + "token_id" + ] + }, + { + "name": "email_tokens_token_key", + "type": "UNIQUE", + "def": "UNIQUE (token)", + "table": "public.email_tokens", + "referenced_table": "", + "columns": [ + "token" + ] + } + ] + }, + { + "name": "public.reset_tokens", + "type": "BASE TABLE", + "columns": [ + { + "name": "token_id", + "type": "bigint", + "nullable": false + }, + { + "name": "token", + "type": "varchar(255)", + "nullable": false + }, + { + "name": "expires_at", + "type": "timestamp with time zone", + "nullable": false + }, + { + "name": "used_at", + "type": "timestamp with time zone", + "nullable": true + }, + { + "name": "ip_address", + "type": "inet", + "nullable": true + }, + { + "name": "user_id", + "type": "uuid", + "nullable": false + } + ], + "indexes": [ + { + "name": "reset_tokens_pkey", + "def": "CREATE UNIQUE INDEX reset_tokens_pkey ON public.reset_tokens USING btree (token_id)", + "table": "public.reset_tokens", + "columns": [ + "token_id" + ] + }, + { + "name": "reset_tokens_token_key", + "def": "CREATE UNIQUE INDEX reset_tokens_token_key ON public.reset_tokens USING btree (token)", + "table": "public.reset_tokens", + "columns": [ + "token" + ] + }, + { + "name": "idx_reset_tokens_user_id", + "def": "CREATE INDEX idx_reset_tokens_user_id ON public.reset_tokens USING btree (user_id)", + "table": "public.reset_tokens", + "columns": [ + "user_id" + ] + } + ], + "constraints": [ + { + "name": "reset_tokens_user_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE", + "table": "public.reset_tokens", + "referenced_table": "public.users", + "columns": [ + "user_id" + ], + "referenced_columns": [ + "user_id" + ] + }, + { + "name": "reset_tokens_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (token_id)", + "table": "public.reset_tokens", + "referenced_table": "", + "columns": [ + "token_id" + ] + }, + { + "name": "reset_tokens_token_key", + "type": "UNIQUE", + "def": "UNIQUE (token)", + "table": "public.reset_tokens", + "referenced_table": "", + "columns": [ + "token" + ] + } + ] + }, + { + "name": "public.audit_logs", + "type": "BASE TABLE", + "columns": [ + { + "name": "log_id", + "type": "bigint", + "nullable": false + }, + { + "name": "action_type", + "type": "varchar(100)", + "nullable": false + }, + { + "name": "entity_type", + "type": "varchar(100)", + "nullable": true + }, + { + "name": "entity_id", + "type": "varchar(64)", + "nullable": true + }, + { + "name": "description", + "type": "text", + "nullable": true + }, + { + "name": "ip_address", + "type": "inet", + "nullable": true + }, + { + "name": "user_agent", + "type": "text", + "nullable": true + }, + { + "name": "was_successful", + "type": "boolean", + "nullable": false, + "default": "true" + }, + { + "name": "error_message", + "type": "text", + "nullable": true + }, + { + "name": "metadata", + "type": "jsonb", + "nullable": true + }, + { + "name": "created_at", + "type": "timestamp with time zone", + "nullable": false, + "default": "now()" + }, + { + "name": "user_id", + "type": "uuid", + "nullable": true + } + ], + "indexes": [ + { + "name": "audit_logs_pkey", + "def": "CREATE UNIQUE INDEX audit_logs_pkey ON public.audit_logs USING btree (log_id)", + "table": "public.audit_logs", + "columns": [ + "log_id" + ] + }, + { + "name": "idx_audit_logs_user_id", + "def": "CREATE INDEX idx_audit_logs_user_id ON public.audit_logs USING btree (user_id)", + "table": "public.audit_logs", + "columns": [ + "user_id" + ] + } + ], + "constraints": [ + { + "name": "audit_logs_user_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL", + "table": "public.audit_logs", + "referenced_table": "public.users", + "columns": [ + "user_id" + ], + "referenced_columns": [ + "user_id" + ] + }, + { + "name": "audit_logs_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (log_id)", + "table": "public.audit_logs", + "referenced_table": "", + "columns": [ + "log_id" + ] + } + ] + }, + { + "name": "public.courses", + "type": "BASE TABLE", + "columns": [ + { + "name": "course_id", + "type": "bigint", + "nullable": false + }, + { + "name": "title", + "type": "varchar(255)", + "nullable": false + }, + { + "name": "description", + "type": "text", + "nullable": true + }, + { + "name": "status", + "type": "varchar(20)", + "nullable": false, + "default": "'DRAFT'::character varying" + }, + { + "name": "published_at", + "type": "timestamp with time zone", + "nullable": true + }, + { + "name": "created_at", + "type": "timestamp with time zone", + "nullable": false, + "default": "now()" + }, + { + "name": "updated_at", + "type": "timestamp with time zone", + "nullable": false, + "default": "now()" + }, + { + "name": "instructor_id", + "type": "uuid", + "nullable": false + } + ], + "indexes": [ + { + "name": "courses_pkey", + "def": "CREATE UNIQUE INDEX courses_pkey ON public.courses USING btree (course_id)", + "table": "public.courses", + "columns": [ + "course_id" + ] + }, + { + "name": "idx_courses_instructor_id", + "def": "CREATE INDEX idx_courses_instructor_id ON public.courses USING btree (instructor_id)", + "table": "public.courses", + "columns": [ + "instructor_id" + ] + } + ], + "constraints": [ + { + "name": "chk_courses_status", + "type": "CHECK", + "def": "CHECK (((status)::text = ANY ((ARRAY['DRAFT'::character varying, 'PUBLISHED'::character varying, 'ARCHIVED'::character varying])::text[])))", + "table": "public.courses", + "referenced_table": "", + "columns": [ + "status" + ] + }, + { + "name": "courses_instructor_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (instructor_id) REFERENCES users(user_id) ON DELETE RESTRICT", + "table": "public.courses", + "referenced_table": "public.users", + "columns": [ + "instructor_id" + ], + "referenced_columns": [ + "user_id" + ] + }, + { + "name": "courses_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (course_id)", + "table": "public.courses", + "referenced_table": "", + "columns": [ + "course_id" + ] + } + ] + }, + { + "name": "public.lessons", + "type": "BASE TABLE", + "columns": [ + { + "name": "lesson_id", + "type": "bigint", + "nullable": false + }, + { + "name": "title", + "type": "varchar(255)", + "nullable": false + }, + { + "name": "content_markdown", + "type": "text", + "nullable": false, + "default": "''::text" + }, + { + "name": "position", + "type": "integer", + "nullable": false + }, + { + "name": "status", + "type": "varchar(20)", + "nullable": false, + "default": "'DRAFT'::character varying" + }, + { + "name": "created_at", + "type": "timestamp with time zone", + "nullable": false, + "default": "now()" + }, + { + "name": "updated_at", + "type": "timestamp with time zone", + "nullable": false, + "default": "now()" + }, + { + "name": "course_id", + "type": "bigint", + "nullable": false + } + ], + "indexes": [ + { + "name": "lessons_pkey", + "def": "CREATE UNIQUE INDEX lessons_pkey ON public.lessons USING btree (lesson_id)", + "table": "public.lessons", + "columns": [ + "lesson_id" + ] + }, + { + "name": "uq_lessons_course_position", + "def": "CREATE UNIQUE INDEX uq_lessons_course_position ON public.lessons USING btree (course_id, \"position\")", + "table": "public.lessons", + "columns": [ + "course_id", + "position" + ] + } + ], + "constraints": [ + { + "name": "chk_lessons_status", + "type": "CHECK", + "def": "CHECK (((status)::text = ANY ((ARRAY['DRAFT'::character varying, 'PUBLISHED'::character varying, 'ARCHIVED'::character varying])::text[])))", + "table": "public.lessons", + "referenced_table": "", + "columns": [ + "status" + ] + }, + { + "name": "lessons_course_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE", + "table": "public.lessons", + "referenced_table": "public.courses", + "columns": [ + "course_id" + ], + "referenced_columns": [ + "course_id" + ] + }, + { + "name": "lessons_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (lesson_id)", + "table": "public.lessons", + "referenced_table": "", + "columns": [ + "lesson_id" + ] + }, + { + "name": "uq_lessons_course_position", + "type": "UNIQUE", + "def": "UNIQUE (course_id, \"position\")", + "table": "public.lessons", + "referenced_table": "", + "columns": [ + "course_id", + "position" + ] + } + ] + }, + { + "name": "public.enrollments", + "type": "BASE TABLE", + "columns": [ + { + "name": "user_id", + "type": "uuid", + "nullable": false + }, + { + "name": "course_id", + "type": "bigint", + "nullable": false + }, + { + "name": "status", + "type": "varchar(20)", + "nullable": false, + "default": "'ENROLLED'::character varying" + }, + { + "name": "enrolled_at", + "type": "timestamp with time zone", + "nullable": false, + "default": "now()" + }, + { + "name": "completed_at", + "type": "timestamp with time zone", + "nullable": true + }, + { + "name": "dropped_at", + "type": "timestamp with time zone", + "nullable": true + } + ], + "indexes": [ + { + "name": "enrollments_pkey", + "def": "CREATE UNIQUE INDEX enrollments_pkey ON public.enrollments USING btree (user_id, course_id)", + "table": "public.enrollments", + "columns": [ + "user_id", + "course_id" + ] + }, + { + "name": "idx_enrollments_course_id", + "def": "CREATE INDEX idx_enrollments_course_id ON public.enrollments USING btree (course_id)", + "table": "public.enrollments", + "columns": [ + "course_id" + ] + } + ], + "constraints": [ + { + "name": "chk_enrollments_status", + "type": "CHECK", + "def": "CHECK (((status)::text = ANY ((ARRAY['ENROLLED'::character varying, 'IN_PROGRESS'::character varying, 'COMPLETED'::character varying, 'DROPPED'::character varying])::text[])))", + "table": "public.enrollments", + "referenced_table": "", + "columns": [ + "status" + ] + }, + { + "name": "enrollments_user_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE", + "table": "public.enrollments", + "referenced_table": "public.users", + "columns": [ + "user_id" + ], + "referenced_columns": [ + "user_id" + ] + }, + { + "name": "enrollments_course_id_fkey", + "type": "FOREIGN KEY", + "def": "FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE", + "table": "public.enrollments", + "referenced_table": "public.courses", + "columns": [ + "course_id" + ], + "referenced_columns": [ + "course_id" + ] + }, + { + "name": "enrollments_pkey", + "type": "PRIMARY KEY", + "def": "PRIMARY KEY (user_id, course_id)", + "table": "public.enrollments", + "referenced_table": "", + "columns": [ + "user_id", + "course_id" + ] + } + ] + } + ], + "relations": [ + { + "table": "public.user_roles", + "columns": [ + "assigned_by" + ], + "cardinality": "zero_or_more", + "parent_table": "public.users", + "parent_columns": [ + "user_id" + ], + "parent_cardinality": "zero_or_one", + "def": "FOREIGN KEY (assigned_by) REFERENCES users(user_id) ON DELETE SET NULL" + }, + { + "table": "public.user_roles", + "columns": [ + "user_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.users", + "parent_columns": [ + "user_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE" + }, + { + "table": "public.user_roles", + "columns": [ + "role_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.roles", + "parent_columns": [ + "role_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE" + }, + { + "table": "public.email_tokens", + "columns": [ + "user_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.users", + "parent_columns": [ + "user_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE" + }, + { + "table": "public.reset_tokens", + "columns": [ + "user_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.users", + "parent_columns": [ + "user_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE" + }, + { + "table": "public.audit_logs", + "columns": [ + "user_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.users", + "parent_columns": [ + "user_id" + ], + "parent_cardinality": "zero_or_one", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL" + }, + { + "table": "public.courses", + "columns": [ + "instructor_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.users", + "parent_columns": [ + "user_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (instructor_id) REFERENCES users(user_id) ON DELETE RESTRICT" + }, + { + "table": "public.lessons", + "columns": [ + "course_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.courses", + "parent_columns": [ + "course_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE" + }, + { + "table": "public.enrollments", + "columns": [ + "user_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.users", + "parent_columns": [ + "user_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE" + }, + { + "table": "public.enrollments", + "columns": [ + "course_id" + ], + "cardinality": "zero_or_more", + "parent_table": "public.courses", + "parent_columns": [ + "course_id" + ], + "parent_cardinality": "exactly_one", + "def": "FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE" + } + ], + "driver": { + "name": "postgres", + "database_version": "PostgreSQL 17.10 (Debian 17.10-1.pgdg13+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit", + "meta": { + "current_schema": "public", + "search_paths": [ + "learndev", + "public" + ], + "dict": { + "Functions": "Stored procedures and functions" + } + } + } +} diff --git a/docs/database/merise/mpd/schema.svg b/docs/database/merise/mpd/schema.svg index 3a08b89..37b5c8c 100644 --- a/docs/database/merise/mpd/schema.svg +++ b/docs/database/merise/mpd/schema.svg @@ -3,236 +3,357 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - - -learn-dev - + + + +learndev + public.users - - -public.users -     -[BASE TABLE] - -user_id -[uuid] - -username -[varchar(50)] - -email -[varchar(255)] - -password -[varchar(255)] - -first_name -[varchar(100)] - -last_name -[varchar(100)] - -is_active -[boolean] - -is_verified -[boolean] - -is_locked -[boolean] - -failed_login_attempts -[integer] - -last_login_at -[timestamp with time zone] - -password_changed_at -[timestamp with time zone] + + +public.users +     +[BASE TABLE] + +user_id +[uuid] + +username +[varchar(50)] + +email +[varchar(255)] + +password +[varchar(255)] + +first_name +[varchar(100)] + +last_name +[varchar(100)] + +is_active +[boolean] + +is_verified +[boolean] + +is_locked +[boolean] + +failed_login_attempts +[integer] + +last_login_at +[timestamp with time zone] + +password_changed_at +[timestamp with time zone] public.roles - - -public.roles -     -[BASE TABLE] - -role_id -[bigint] - -role_name -[varchar(50)] - -description -[varchar(255)] - -is_active -[boolean] + + +public.roles +     +[BASE TABLE] + +role_id +[bigint] + +role_name +[varchar(50)] + +description +[varchar(255)] + +is_active +[boolean] public.user_roles - - -public.user_roles -     -[BASE TABLE] - -user_id -[uuid] - -role_id -[bigint] - -assigned_at -[timestamp with time zone] - -assigned_by -[uuid] + + +public.user_roles +     +[BASE TABLE] + +user_id +[uuid] + +role_id +[bigint] + +assigned_at +[timestamp with time zone] + +assigned_by +[uuid] public.user_roles:assigned_by->public.users:user_id - - + + +FOREIGN KEY (assigned_by) REFERENCES users(user_id) ON DELETE SET NULL public.user_roles:user_id->public.users:user_id - - - - - -public.user_roles:assigned_by->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE public.user_roles:role_id->public.roles:role_id - - + + +FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE public.email_tokens - - -public.email_tokens -     -[BASE TABLE] - -token_id -[bigint] - -token -[varchar(255)] - -expires_at -[timestamp with time zone] - -used_at -[timestamp with time zone] - -user_id -[uuid] + + +public.email_tokens +     +[BASE TABLE] + +token_id +[bigint] + +token +[varchar(255)] + +expires_at +[timestamp with time zone] + +used_at +[timestamp with time zone] + +user_id +[uuid] public.email_tokens:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE public.reset_tokens - - -public.reset_tokens -     -[BASE TABLE] - -token_id -[bigint] - -token -[varchar(255)] - -expires_at -[timestamp with time zone] - -used_at -[timestamp with time zone] - -ip_address -[inet] - -user_id -[uuid] + + +public.reset_tokens +     +[BASE TABLE] + +token_id +[bigint] + +token +[varchar(255)] + +expires_at +[timestamp with time zone] + +used_at +[timestamp with time zone] + +ip_address +[inet] + +user_id +[uuid] public.reset_tokens:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE public.audit_logs - - -public.audit_logs -     -[BASE TABLE] - -log_id -[bigint] - -action_type -[varchar(100)] - -entity_type -[varchar(100)] - -entity_id -[varchar(64)] - -description -[text] - -ip_address -[inet] - -user_agent -[text] - -was_successful -[boolean] - -error_message -[text] - -metadata -[jsonb] - -created_at -[timestamp with time zone] - -user_id -[uuid] + + +public.audit_logs +     +[BASE TABLE] + +log_id +[bigint] + +action_type +[varchar(100)] + +entity_type +[varchar(100)] + +entity_id +[varchar(64)] + +description +[text] + +ip_address +[inet] + +user_agent +[text] + +was_successful +[boolean] + +error_message +[text] + +metadata +[jsonb] + +created_at +[timestamp with time zone] + +user_id +[uuid] public.audit_logs:user_id->public.users:user_id - - + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL + + + +public.courses + + +public.courses +     +[BASE TABLE] + +course_id +[bigint] + +title +[varchar(255)] + +description +[text] + +status +[varchar(20)] + +published_at +[timestamp with time zone] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +instructor_id +[uuid] + + + +public.courses:instructor_id->public.users:user_id + + +FOREIGN KEY (instructor_id) REFERENCES users(user_id) ON DELETE RESTRICT + + + +public.lessons + + +public.lessons +     +[BASE TABLE] + +lesson_id +[bigint] + +title +[varchar(255)] + +content_markdown +[text] + +position +[integer] + +status +[varchar(20)] + +created_at +[timestamp with time zone] + +updated_at +[timestamp with time zone] + +course_id +[bigint] + + + +public.lessons:course_id->public.courses:course_id + + +FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE + + + +public.enrollments + + +public.enrollments +     +[BASE TABLE] + +user_id +[uuid] + +course_id +[bigint] + +status +[varchar(20)] + +enrolled_at +[timestamp with time zone] + +completed_at +[timestamp with time zone] + +dropped_at +[timestamp with time zone] + + + +public.enrollments:user_id->public.users:user_id + + +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + + + +public.enrollments:course_id->public.courses:course_id + + +FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE diff --git a/src/main/resources/db/changelog/changes/V20260711230000-create-courses-table.sql b/src/main/resources/db/changelog/changes/V20260711230000-create-courses-table.sql new file mode 100644 index 0000000..4bac536 --- /dev/null +++ b/src/main/resources/db/changelog/changes/V20260711230000-create-courses-table.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +-- courses: units of learning content owned by an instructor. BIGINT identity PK +-- (ADR-0003: UUID is for users only). The status column implements the course +-- lifecycle (see CONTRIBUTING.md): DRAFT, PUBLISHED, ARCHIVED; removal is +-- modeled as ARCHIVED, so deleting a row is not part of the v1 lifecycle. +--changeset ebouchut:V20260711230000 +CREATE TABLE courses ( + course_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' + CONSTRAINT chk_courses_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')), + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + instructor_id UUID NOT NULL REFERENCES users (user_id) ON DELETE RESTRICT +); +--rollback DROP TABLE courses; diff --git a/src/main/resources/db/changelog/changes/V20260711230001-create-lessons-table.sql b/src/main/resources/db/changelog/changes/V20260711230001-create-lessons-table.sql new file mode 100644 index 0000000..f4ab476 --- /dev/null +++ b/src/main/resources/db/changelog/changes/V20260711230001-create-lessons-table.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql + +-- lessons: ordered content within a course, authored as Markdown. Deleting a +-- course cascades to its lessons. UNIQUE (course_id, position) keeps the +-- ordering unambiguous and its index doubles as the foreign-key index +-- (course_id is the leading column), so no separate idx_lessons_course_id. +--changeset ebouchut:V20260711230001 +CREATE TABLE lessons ( + lesson_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content_markdown TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' + CONSTRAINT chk_lessons_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + course_id BIGINT NOT NULL REFERENCES courses (course_id) ON DELETE CASCADE, + CONSTRAINT uq_lessons_course_position UNIQUE (course_id, position) +); +--rollback DROP TABLE lessons; diff --git a/src/main/resources/db/changelog/changes/V20260711230002-create-enrollments-table.sql b/src/main/resources/db/changelog/changes/V20260711230002-create-enrollments-table.sql new file mode 100644 index 0000000..d8347a4 --- /dev/null +++ b/src/main/resources/db/changelog/changes/V20260711230002-create-enrollments-table.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql + +-- enrollments: the association between a student and a course, one row per +-- (student, course) pair, so the composite PK IS the identity (no surrogate +-- id) and its index also serves user_id lookups (leading column). The status +-- column implements the persisted part of the student course progress +-- lifecycle (see CONTRIBUTING.md); enrolled_at, completed_at, and dropped_at +-- record when the matching transitions happened. +--changeset ebouchut:V20260711230002 +CREATE TABLE enrollments ( + user_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + course_id BIGINT NOT NULL REFERENCES courses (course_id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'ENROLLED' + CONSTRAINT chk_enrollments_status CHECK (status IN ('ENROLLED', 'IN_PROGRESS', 'COMPLETED', 'DROPPED')), + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + dropped_at TIMESTAMPTZ, + PRIMARY KEY (user_id, course_id) +); +--rollback DROP TABLE enrollments; diff --git a/src/main/resources/db/changelog/changes/V20260711230003-create-idx-courses-instructor-id.sql b/src/main/resources/db/changelog/changes/V20260711230003-create-idx-courses-instructor-id.sql new file mode 100644 index 0000000..6c312d2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/V20260711230003-create-idx-courses-instructor-id.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +-- Index on the courses.instructor_id foreign key. +--changeset ebouchut:V20260711230003 +CREATE INDEX idx_courses_instructor_id ON courses (instructor_id); +--rollback DROP INDEX idx_courses_instructor_id; diff --git a/src/main/resources/db/changelog/changes/V20260711230004-create-idx-enrollments-course-id.sql b/src/main/resources/db/changelog/changes/V20260711230004-create-idx-enrollments-course-id.sql new file mode 100644 index 0000000..d43906b --- /dev/null +++ b/src/main/resources/db/changelog/changes/V20260711230004-create-idx-enrollments-course-id.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql + +-- Index on the enrollments.course_id foreign key (the composite PK leads +-- with user_id, so course_id lookups need their own index). +--changeset ebouchut:V20260711230004 +CREATE INDEX idx_enrollments_course_id ON enrollments (course_id); +--rollback DROP INDEX idx_enrollments_course_id; From 666422dd3f0a8f9742e78c8667538f6d72285c8e Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sun, 12 Jul 2026 02:38:40 +0200 Subject: [PATCH 2/3] feat(course): Add the course, lesson, and enrollment entities Map the new tables with the house JPA idioms (Lombok, OffsetDateTime, explicit column names, BIGINT identity keys). Course and Lesson share the PublicationStatus enum and bump updated_at from a PreUpdate callback; their to-one associations are LAZY. Enrollment is a join entity rather than a bare ManyToMany because the relationship carries state (status plus enrollment, completion, and drop timestamps): EnrollmentId is the embedded (user_id, course_id) composite key and MapsId lets the User and Course references reuse those key columns, so double enrollment is impossible by construction. Spring Data repositories cover the expected access paths (instructor catalogue, published courses, lessons in reading order, student and course rosters). Both glossaries gain the join entity term and a more precise Enrollment entry. --- GLOSSAIRE.md | 12 +++- GLOSSARY.md | 11 +++- .../learndev/course/entity/Course.java | 51 +++++++++++++++++ .../learndev/course/entity/Enrollment.java | 57 +++++++++++++++++++ .../learndev/course/entity/EnrollmentId.java | 33 +++++++++++ .../course/entity/EnrollmentStatus.java | 15 +++++ .../learndev/course/entity/Lesson.java | 56 ++++++++++++++++++ .../course/entity/PublicationStatus.java | 14 +++++ .../course/repository/CourseRepository.java | 16 ++++++ .../repository/EnrollmentRepository.java | 18 ++++++ .../course/repository/LessonRepository.java | 13 +++++ 11 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ericbouchut/learndev/course/entity/Course.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/entity/Enrollment.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentId.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentStatus.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/entity/Lesson.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/entity/PublicationStatus.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/repository/CourseRepository.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java diff --git a/GLOSSAIRE.md b/GLOSSAIRE.md index 7428c30..453a346 100644 --- a/GLOSSAIRE.md +++ b/GLOSSAIRE.md @@ -22,7 +22,10 @@ pour la justification des décisions de conception, voir les [ADR](docs/adr/READ - **Drop a course (abandonner un cours)** — Le retrait d'un étudiant d'un cours avant de l'avoir terminé. - **Enrollment (inscription)** — La relation qui lie un étudiant à un cours - qu'il a rejoint. + qu'il a rejoint. Persistée comme une *entité de jointure* sur la table + `enrollments`, identifiée par la paire (utilisateur, cours), avec un statut + qui suit le cycle de vie de la progression de l'étudiant (voir + CONTRIBUTING.md). - **Lesson (leçon)** — Un élément de contenu individuel au sein d'un cours. - **Role (rôle)** — Un ensemble nommé de permissions accordées à un utilisateur. Les rôles fournis par défaut sont `STUDENT`, `INSTRUCTOR` et @@ -84,6 +87,13 @@ pour la justification des décisions de conception, voir les [ADR](docs/adr/READ relations (produit ici avec Mermaid). - **Hibernate** — L'implémentation JPA (ORM) utilisée pour faire correspondre les entités Java aux tables. +- **Entité de jointure (join entity)** — Une association plusieurs-à-plusieurs + promue en entité JPA à part entière parce que la relation porte son propre + état (par exemple `Enrollment` avec son statut et ses horodatages). Sa clé + primaire est le composite des deux clés étrangères (`@EmbeddedId`), et + `@MapsId` permet aux deux références `@ManyToOne` de réutiliser ces colonnes + de clé. À distinguer d'une simple *table* de jointure comme `user_roles`, + qui reste invisible derrière `@ManyToMany`. - **JPA (Jakarta Persistence API)** — L'API Java standard du mapping objet-relationnel ; implémentée par Hibernate. - **JSESSIONID** — Le nom par défaut du cookie de session servlet. diff --git a/GLOSSARY.md b/GLOSSARY.md index 4bb574f..8b3e7c4 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -18,7 +18,10 @@ rationale behind design decisions, see the [ADRs](docs/adr/README.md). - **Deactivate** — Disable an account (for example an instructor or student) so it can no longer be used, without deleting it. See also *disabled account*. - **Drop a course** — A student withdrawing from a course before finishing it. -- **Enrollment** — The relationship linking a student to a course they have joined. +- **Enrollment** — The relationship linking a student to a course they have + joined. Persisted as a *join entity* on the `enrollments` table, keyed by + the (user, course) pair, with a status following the student course + progress lifecycle (see CONTRIBUTING.md). - **Lesson** — An individual piece of content within a course. - **Role** — A named set of permissions granted to a user. The seeded roles are `STUDENT`, `INSTRUCTOR`, and `ADMIN`; `SUPERADMIN` is planned (see issue #65). @@ -69,6 +72,12 @@ rationale behind design decisions, see the [ADRs](docs/adr/README.md). - **ERD (Entity-Relationship Diagram)** — A diagram of entities and their relationships (rendered here with Mermaid). - **Hibernate** — The JPA implementation (ORM) used to map Java entities to tables. +- **Join entity** — A many-to-many association promoted to a full JPA entity + because the relationship carries state of its own (for example `Enrollment` + with its status and timestamps). Its primary key is the composite of the two + foreign keys (`@EmbeddedId`), and `@MapsId` lets the two `@ManyToOne` + references reuse those key columns. Contrast with a plain join *table* like + `user_roles`, which stays invisible behind `@ManyToMany`. - **JPA (Jakarta Persistence API)** — The standard Java API for object-relational mapping; implemented by Hibernate. - **JSESSIONID** — The default name of the servlet session cookie. diff --git a/src/main/java/com/ericbouchut/learndev/course/entity/Course.java b/src/main/java/com/ericbouchut/learndev/course/entity/Course.java new file mode 100644 index 0000000..b21a9a6 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/entity/Course.java @@ -0,0 +1,51 @@ +package com.ericbouchut.learndev.course.entity; + +import com.ericbouchut.learndev.user.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "courses") +@Getter +@Setter +@NoArgsConstructor +public class Course { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "course_id") + private Long courseId; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PublicationStatus status = PublicationStatus.DRAFT; + + @Column(name = "published_at") + private OffsetDateTime publishedAt; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + // LAZY: listing courses must not fetch one instructor row per course. + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "instructor_id", nullable = false) + private User instructor; + + @PreUpdate + void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/entity/Enrollment.java b/src/main/java/com/ericbouchut/learndev/course/entity/Enrollment.java new file mode 100644 index 0000000..d52bd09 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/entity/Enrollment.java @@ -0,0 +1,57 @@ +package com.ericbouchut.learndev.course.entity; + +import com.ericbouchut.learndev.user.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.OffsetDateTime; + +/** + * Join entity linking a student to a course they joined, with state of its + * own (status and transition timestamps), which is why the association is an + * entity rather than a bare {@code @ManyToMany} like user_roles. + *

{@code @MapsId} reuses the embedded id's columns for the two + * associations, so the entity exposes real {@link User} and {@link Course} + * references while the primary key stays the (user, course) pair. + */ +@Entity +@Table(name = "enrollments") +@Getter +@Setter +@NoArgsConstructor +public class Enrollment { + + @EmbeddedId + private EnrollmentId id; + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @MapsId("courseId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "course_id") + private Course course; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private EnrollmentStatus status = EnrollmentStatus.ENROLLED; + + @Column(name = "enrolled_at", nullable = false) + private OffsetDateTime enrolledAt = OffsetDateTime.now(); + + @Column(name = "completed_at") + private OffsetDateTime completedAt; + + @Column(name = "dropped_at") + private OffsetDateTime droppedAt; + + public Enrollment(User user, Course course) { + this.id = new EnrollmentId(user.getUserId(), course.getCourseId()); + this.user = user; + this.course = course; + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentId.java b/src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentId.java new file mode 100644 index 0000000..49f026f --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentId.java @@ -0,0 +1,33 @@ +package com.ericbouchut.learndev.course.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Composite primary key of {@link Enrollment}: the (user, course) pair IS the + * identity, so a student cannot enroll twice in the same course by + * construction. Serializable and value-equal as the JPA spec requires for + * embedded ids. + */ +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class EnrollmentId implements Serializable { + + @Column(name = "user_id") + private UUID userId; + + @Column(name = "course_id") + private Long courseId; +} diff --git a/src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentStatus.java b/src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentStatus.java new file mode 100644 index 0000000..1cc75cd --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/entity/EnrollmentStatus.java @@ -0,0 +1,15 @@ +package com.ericbouchut.learndev.course.entity; + +/** + * Persisted states of the student course progress lifecycle + * (see the diagram in CONTRIBUTING.md). + *

{@code ENROLLED} covers the diagram's Enrolled and NotStarted states: + * v1 has no prerequisites, so enrolling succeeds synchronously and the + * pending/failed states are not stored. + */ +public enum EnrollmentStatus { + ENROLLED, + IN_PROGRESS, + COMPLETED, + DROPPED +} diff --git a/src/main/java/com/ericbouchut/learndev/course/entity/Lesson.java b/src/main/java/com/ericbouchut/learndev/course/entity/Lesson.java new file mode 100644 index 0000000..f7fb90c --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/entity/Lesson.java @@ -0,0 +1,56 @@ +package com.ericbouchut.learndev.course.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.OffsetDateTime; + +/** + * An individual piece of content within a course, authored as Markdown + * ({@code content_markdown}) and rendered to HTML at display time. + * {@code position} orders the lessons of a course; the pair + * (course, position) is unique. + */ +@Entity +@Table(name = "lessons") +@Getter +@Setter +@NoArgsConstructor +public class Lesson { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "lesson_id") + private Long lessonId; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content_markdown", nullable = false) + private String contentMarkdown = ""; + + @Column(name = "position", nullable = false) + private Integer position; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PublicationStatus status = PublicationStatus.DRAFT; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + // LAZY: loading a lesson must not drag the whole course row along. + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "course_id", nullable = false) + private Course course; + + @PreUpdate + void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/entity/PublicationStatus.java b/src/main/java/com/ericbouchut/learndev/course/entity/PublicationStatus.java new file mode 100644 index 0000000..0f59fc7 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/entity/PublicationStatus.java @@ -0,0 +1,14 @@ +package com.ericbouchut.learndev.course.entity; + +/** + * Editorial lifecycle shared by courses and lessons (see the Course and + * Lesson lifecycle diagrams in CONTRIBUTING.md). + *

Removal is modeled as {@code ARCHIVED}: v1 has no destructive delete, + * and the transient "Updated" state of the diagrams is just an edit of a + * {@code PUBLISHED} row, not a stored status. + */ +public enum PublicationStatus { + DRAFT, + PUBLISHED, + ARCHIVED +} diff --git a/src/main/java/com/ericbouchut/learndev/course/repository/CourseRepository.java b/src/main/java/com/ericbouchut/learndev/course/repository/CourseRepository.java new file mode 100644 index 0000000..e63ed64 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/repository/CourseRepository.java @@ -0,0 +1,16 @@ +package com.ericbouchut.learndev.course.repository; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CourseRepository extends JpaRepository { + + List findByInstructor(User instructor); + + /** The public catalogue lists {@code PUBLISHED} courses only. */ + List findByStatus(PublicationStatus status); +} diff --git a/src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java b/src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java new file mode 100644 index 0000000..42186f8 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java @@ -0,0 +1,18 @@ +package com.ericbouchut.learndev.course.repository; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Enrollment; +import com.ericbouchut.learndev.course.entity.EnrollmentId; +import com.ericbouchut.learndev.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface EnrollmentRepository extends JpaRepository { + + /** A student's enrollments (their personal course list). */ + List findByUser(User user); + + /** The roster of a course. */ + List findByCourse(Course course); +} diff --git a/src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java b/src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java new file mode 100644 index 0000000..ee9e3d4 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java @@ -0,0 +1,13 @@ +package com.ericbouchut.learndev.course.repository; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Lesson; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LessonRepository extends JpaRepository { + + /** The lessons of a course in reading order (position ascending). */ + List findByCourseOrderByPositionAsc(Course course); +} From 54630d24e51c7ea2f37de8c245fe9b2f337a25b6 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sun, 12 Jul 2026 02:38:41 +0200 Subject: [PATCH 3/3] test(course): Cover the course domain persistence Exercise the new schema against the real PostgreSQL container: statuses round-trip as strings, lessons come back in position order, the enrollment identity is the (user, course) pair with lifecycle defaults, UNIQUE (course_id, position) rejects duplicate positions, course deletion cascades to lessons and enrollments in the database, and deleting an instructor who still teaches is rejected (RESTRICT). The delete tests flush and clear the persistence context first so Hibernate issues plain DELETEs, making the database constraints the thing actually proven. --- .../CourseDomainRepositoryTest.java | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java diff --git a/src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java b/src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java new file mode 100644 index 0000000..f3d6d4f --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java @@ -0,0 +1,171 @@ +package com.ericbouchut.learndev.course.repository; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Enrollment; +import com.ericbouchut.learndev.course.entity.EnrollmentId; +import com.ericbouchut.learndev.course.entity.EnrollmentStatus; +import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Persistence tests of the course domain (courses, lessons, enrollments) + * against the real schema: statuses round-trip as strings, the lesson order + * is unique per course, the enrollment identity is the (user, course) pair, + * and deleting a course cascades in the DATABASE (ON DELETE CASCADE), not in + * Hibernate. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class CourseDomainRepositoryTest extends AbstractPostgresIT { + + @Autowired + CourseRepository courseRepository; + + @Autowired + LessonRepository lessonRepository; + + @Autowired + EnrollmentRepository enrollmentRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + TestEntityManager entityManager; + + private User newUser(String username) { + User user = new User(); + user.setUsername(username); + user.setEmail(username + "@example.com"); + user.setPassword("hashed"); + return userRepository.save(user); + } + + private Course newCourse(User instructor, String title) { + Course course = new Course(); + course.setTitle(title); + course.setInstructor(instructor); + return courseRepository.save(course); + } + + private Lesson newLesson(Course course, int position, String title) { + Lesson lesson = new Lesson(); + lesson.setCourse(course); + lesson.setPosition(position); + lesson.setTitle(title); + return lessonRepository.save(lesson); + } + + @Test + void persists_a_course_with_its_lessons_in_reading_order() { + // Arrange (Given): a draft course with lessons saved out of order + User instructor = newUser("instructor1"); + Course course = newCourse(instructor, "PostgreSQL indexing"); + newLesson(course, 2, "Composite indexes"); + newLesson(course, 1, "B-tree basics"); + + // Act (When): publish the course and reload everything from the DB + course.setStatus(PublicationStatus.PUBLISHED); + courseRepository.saveAndFlush(course); + entityManager.clear(); + Course reloaded = courseRepository.findById(course.getCourseId()).orElseThrow(); + var lessons = lessonRepository.findByCourseOrderByPositionAsc(reloaded); + + // Assert (Then): the status round-trips and the order is by position + assertThat(reloaded.getStatus()).isEqualTo(PublicationStatus.PUBLISHED); + assertThat(lessons) + .extracting(Lesson::getTitle) + .containsExactly("B-tree basics", "Composite indexes"); + assertThat(lessons) + .allSatisfy(lesson -> assertThat(lesson.getStatus()).isEqualTo(PublicationStatus.DRAFT)); + } + + @Test + void rejects_two_lessons_at_the_same_position_in_a_course() { + // Arrange (Given): a course with a lesson at position 1 + User instructor = newUser("instructor2"); + Course course = newCourse(instructor, "Duplicate positions"); + newLesson(course, 1, "First"); + + // Act + Assert (When/Then): a second lesson at position 1 violates + // uq_lessons_course_position + assertThatThrownBy(() -> { + newLesson(course, 1, "Usurper"); + lessonRepository.flush(); + }).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void enrollment_identity_is_the_user_and_course_pair() { + // Arrange (Given): a student enrolled in a course + User instructor = newUser("instructor3"); + User student = newUser("student3"); + Course course = newCourse(instructor, "Enrollment basics"); + enrollmentRepository.saveAndFlush(new Enrollment(student, course)); + entityManager.clear(); + + // Act (When): look the enrollment up by the composite id + var enrollmentId = new EnrollmentId(student.getUserId(), course.getCourseId()); + var maybeEnrollment = enrollmentRepository.findById(enrollmentId); + + // Assert (Then): found, with the defaults from the lifecycle + assertThat(maybeEnrollment).isPresent(); + assertThat(maybeEnrollment.get().getStatus()).isEqualTo(EnrollmentStatus.ENROLLED); + assertThat(maybeEnrollment.get().getEnrolledAt()).isNotNull(); + assertThat(maybeEnrollment.get().getCompletedAt()).isNull(); + } + + @Test + void deleting_a_course_cascades_to_its_lessons_and_enrollments() { + // Arrange (Given): a course with a lesson and an enrolled student + User instructor = newUser("instructor4"); + User student = newUser("student4"); + Course course = newCourse(instructor, "Doomed course"); + Lesson lesson = newLesson(course, 1, "Doomed lesson"); + enrollmentRepository.save(new Enrollment(student, course)); + entityManager.flush(); + entityManager.clear(); + + // Act (When): delete the course row from a fresh context (no managed + // lesson/enrollment referencing it); the DATABASE cascades + courseRepository.deleteById(course.getCourseId()); + courseRepository.flush(); + entityManager.clear(); + + // Assert (Then): the lesson and the enrollment are gone with it + assertThat(lessonRepository.findById(lesson.getLessonId())).isEmpty(); + assertThat(enrollmentRepository + .findById(new EnrollmentId(student.getUserId(), course.getCourseId()))) + .isEmpty(); + } + + @Test + void deleting_an_instructor_with_courses_is_rejected() { + // Arrange (Given): an instructor who teaches a course + User instructor = newUser("instructor5"); + newCourse(instructor, "Orphan-proof course"); + entityManager.flush(); + entityManager.clear(); + + // Act + Assert (When/Then): courses.instructor_id is ON DELETE + // RESTRICT, so the user row cannot go while the course exists. + // The fresh context makes Hibernate issue a plain DELETE instead of + // balking at the managed course that references the user. + assertThatThrownBy(() -> { + userRepository.deleteById(instructor.getUserId()); + userRepository.flush(); + }).isInstanceOf(DataIntegrityViolationException.class); + } +}