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/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/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/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); +} 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; 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); + } +}