534 lines
No EOL
15 KiB
Text
534 lines
No EOL
15 KiB
Text
// E-Learning Platform - Enhanced ERD v3.0
|
|
// Major improvements:
|
|
// 1. Separated roles table with display values
|
|
// 2. Separated user_profiles table
|
|
// 3. Added created_by/updated_by audit trails
|
|
// 4. Made updated_at/updated_by nullable
|
|
// 5. Used ENUM for status fields
|
|
// 6. Optimized audit trail placement
|
|
// 7. Added announcement status and multi-instructor support
|
|
|
|
// ============================================
|
|
// CORE TABLES
|
|
// ============================================
|
|
|
|
Table roles {
|
|
id int [pk, increment]
|
|
code varchar [unique, not null, note: 'ADMIN | INSTRUCTOR | STUDENT']
|
|
name jsonb [not null, note: '{"th": "ผู้ดูแลระบบ", "en": "Administrator"}']
|
|
description jsonb [note: 'role description']
|
|
created_at datetime [not null, default: `now()`]
|
|
|
|
indexes {
|
|
code [unique]
|
|
}
|
|
}
|
|
|
|
Table users {
|
|
id int [pk, increment]
|
|
username varchar [unique, not null]
|
|
email varchar [unique, not null]
|
|
password varchar [not null]
|
|
role_id int [not null, ref: > roles.id]
|
|
email_verified_at datetime
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
indexes {
|
|
username [unique]
|
|
email [unique]
|
|
role_id
|
|
}
|
|
}
|
|
|
|
Table user_profiles {
|
|
id int [pk, increment]
|
|
user_id int [unique, not null, ref: > users.id]
|
|
prefix varchar [note: 'Mr. | Mrs. | Ms. | Dr. etc.']
|
|
first_name varchar [not null]
|
|
last_name varchar [not null]
|
|
phone varchar
|
|
avatar_url varchar
|
|
birth_date datetime
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
updated_by int [ref: > users.id]
|
|
|
|
indexes {
|
|
user_id [unique]
|
|
}
|
|
}
|
|
|
|
Table categories {
|
|
id int [pk, increment]
|
|
name jsonb [not null, note: 'multi-language category name']
|
|
slug varchar [unique, not null]
|
|
description jsonb
|
|
icon varchar [note: 'icon identifier']
|
|
sort_order int [not null, default: 0]
|
|
is_active boolean [not null, default: true]
|
|
created_at datetime [not null, default: `now()`]
|
|
created_by int [not null, ref: > users.id]
|
|
updated_at datetime
|
|
updated_by int [ref: > users.id]
|
|
|
|
indexes {
|
|
slug [unique]
|
|
is_active
|
|
sort_order
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// COURSE STRUCTURE
|
|
// ============================================
|
|
|
|
Table courses {
|
|
id int [pk, increment]
|
|
category_id int [ref: > categories.id]
|
|
title jsonb [not null, note: '{"th": "...", "en": "..."}']
|
|
slug varchar [unique, not null]
|
|
description jsonb
|
|
thumbnail_url varchar
|
|
price decimal [not null, default: 0, note: 'must be >= 0']
|
|
is_free boolean [not null, default: false]
|
|
have_certificate boolean [not null, default: false, note: 'issue certificate upon completion']
|
|
status varchar [not null, default: 'DRAFT', note: 'ENUM: DRAFT | PENDING | APPROVED | REJECTED | ARCHIVED']
|
|
approved_by int [ref: > users.id, note: 'admin user id']
|
|
approved_at datetime
|
|
rejection_reason varchar
|
|
created_at datetime [not null, default: `now()`]
|
|
created_by int [not null, ref: > users.id, note: 'primary instructor']
|
|
updated_at datetime
|
|
updated_by int [ref: > users.id]
|
|
|
|
indexes {
|
|
category_id
|
|
slug [unique]
|
|
status
|
|
created_by
|
|
(status, is_free)
|
|
(category_id, status)
|
|
}
|
|
}
|
|
|
|
Table course_instructors {
|
|
id int [pk, increment]
|
|
course_id int [not null, ref: > courses.id]
|
|
user_id int [not null, ref: > users.id]
|
|
is_primary boolean [not null, default: false, note: 'primary instructor']
|
|
joined_at datetime [not null, default: `now()`]
|
|
|
|
indexes {
|
|
(course_id, user_id) [unique]
|
|
course_id
|
|
user_id
|
|
is_primary
|
|
}
|
|
}
|
|
|
|
Table course_approvals {
|
|
id int [pk, increment]
|
|
course_id int [not null, ref: > courses.id]
|
|
submitted_by int [not null, ref: > users.id, note: 'instructor who submitted']
|
|
reviewed_by int [ref: > users.id, note: 'admin who reviewed']
|
|
action varchar [not null, note: 'ENUM: SUBMITTED | APPROVED | REJECTED']
|
|
previous_status varchar [not null, note: 'status before this action']
|
|
new_status varchar [not null, note: 'status after this action']
|
|
comment text [note: 'admin comment or rejection reason']
|
|
created_at datetime [not null, default: `now()`]
|
|
|
|
Note: 'Tracks complete approval workflow history'
|
|
|
|
indexes {
|
|
course_id
|
|
submitted_by
|
|
reviewed_by
|
|
action
|
|
created_at
|
|
(course_id, created_at)
|
|
}
|
|
}
|
|
|
|
Table chapters {
|
|
id int [pk, increment]
|
|
course_id int [not null, ref: > courses.id]
|
|
title jsonb [not null, note: 'multi-language']
|
|
description jsonb
|
|
sort_order int [not null, default: 0, note: 'must be >= 0']
|
|
is_published boolean [not null, default: false]
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
Note: 'No created_by/updated_by - use course audit trail'
|
|
|
|
indexes {
|
|
course_id
|
|
(course_id, sort_order)
|
|
}
|
|
}
|
|
|
|
Table lessons {
|
|
id int [pk, increment]
|
|
chapter_id int [not null, ref: > chapters.id]
|
|
title jsonb [not null, note: 'multi-language']
|
|
content jsonb [note: 'multi-language lesson content']
|
|
type varchar [not null, note: 'ENUM: VIDEO QUIZ']
|
|
duration_minutes int [note: 'estimated duration']
|
|
sort_order int [not null, default: 0, note: 'must be >= 0']
|
|
is_sequential boolean [not null, default: true]
|
|
prerequisite_lesson_ids jsonb [note: 'array of lesson IDs [1, 2, 3]']
|
|
require_pass_quiz boolean [default: false]
|
|
is_published boolean [not null, default: false]
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
Note: 'No created_by/updated_by - use course audit trail'
|
|
|
|
indexes {
|
|
chapter_id
|
|
(chapter_id, sort_order)
|
|
type
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// QUIZ SYSTEM
|
|
// ============================================
|
|
|
|
Table quizzes {
|
|
id int [pk, increment]
|
|
lesson_id int [not null, ref: > lessons.id]
|
|
title jsonb [not null, note: 'multi-language']
|
|
description jsonb
|
|
passing_score int [not null, default: 60, note: 'must be 0-100']
|
|
time_limit int [note: 'in minutes, must be > 0 if set']
|
|
shuffle_questions boolean [not null, default: false]
|
|
shuffle_choices boolean [not null, default: false]
|
|
show_answers_after_completion boolean [not null, default: true, note: 'show correct answers after quiz completion']
|
|
created_at datetime [not null, default: `now()`]
|
|
created_by int [not null, ref: > users.id]
|
|
updated_at datetime
|
|
updated_by int [ref: > users.id]
|
|
|
|
indexes {
|
|
lesson_id
|
|
}
|
|
}
|
|
|
|
Table questions {
|
|
id int [pk, increment]
|
|
quiz_id int [not null, ref: > quizzes.id]
|
|
question jsonb [not null, note: 'multi-language']
|
|
explanation jsonb [note: 'answer explanation']
|
|
question_type varchar [not null, default: 'MULTIPLE_CHOICE', note: 'ENUM: MULTIPLE_CHOICE | TRUE_FALSE | SHORT_ANSWER']
|
|
score int [not null, default: 1, note: 'must be > 0']
|
|
sort_order int [not null, default: 0]
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
Note: 'No created_by/updated_by - saved with quiz'
|
|
|
|
indexes {
|
|
quiz_id
|
|
(quiz_id, sort_order)
|
|
}
|
|
}
|
|
|
|
Table choices {
|
|
id int [pk, increment]
|
|
question_id int [not null, ref: > questions.id]
|
|
text jsonb [not null, note: 'multi-language']
|
|
is_correct boolean [not null, default: false]
|
|
sort_order int [not null, default: 0]
|
|
indexes {
|
|
question_id
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// STUDENT PROGRESS
|
|
// ============================================
|
|
|
|
Table enrollments {
|
|
id int [pk, increment]
|
|
user_id int [not null, ref: > users.id]
|
|
course_id int [not null, ref: > courses.id]
|
|
status varchar [not null, default: 'ENROLLED', note: 'ENUM: ENROLLED | IN_PROGRESS | COMPLETED | DROPPED']
|
|
progress_percentage int [not null, default: 0, note: '0-100']
|
|
enrolled_at datetime [not null, default: `now()`]
|
|
started_at datetime [note: 'first lesson access']
|
|
completed_at datetime
|
|
last_accessed_at datetime
|
|
|
|
Note: 'No created_by/updated_by - system managed'
|
|
|
|
indexes {
|
|
(user_id, course_id) [unique, name: 'unique_enrollment']
|
|
user_id
|
|
course_id
|
|
status
|
|
last_accessed_at
|
|
}
|
|
}
|
|
|
|
|
|
Table certificates {
|
|
id int [pk, increment]
|
|
user_id int [not null, ref: > users.id]
|
|
course_id int [not null, ref: > courses.id]
|
|
enrollment_id int [unique, not null, ref: > enrollments.id]
|
|
file_path varchar [not null, note: 'S3 path to certificate PDF']
|
|
issued_at datetime [not null, default: `now()`]
|
|
|
|
indexes {
|
|
user_id
|
|
course_id
|
|
enrollment_id [unique]
|
|
(user_id, course_id)
|
|
}
|
|
}
|
|
|
|
|
|
Table lesson_progress {
|
|
id int [pk, increment]
|
|
user_id int [not null, ref: > users.id]
|
|
lesson_id int [not null, ref: > lessons.id]
|
|
is_completed boolean [not null, default: false]
|
|
completed_at datetime
|
|
video_progress_seconds int [default: 0]
|
|
video_duration_seconds int
|
|
video_progress_percentage decimal(5,2)
|
|
last_watched_at datetime
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
Note: 'No created_by/updated_by - student action only'
|
|
|
|
indexes {
|
|
(user_id, lesson_id) [unique]
|
|
user_id
|
|
lesson_id
|
|
last_watched_at
|
|
}
|
|
}
|
|
|
|
Table quiz_attempts {
|
|
id int [pk, increment]
|
|
user_id int [not null, ref: > users.id]
|
|
quiz_id int [not null, ref: > quizzes.id]
|
|
score int [not null, default: 0, note: '0-100']
|
|
total_questions int [not null]
|
|
correct_answers int [not null, default: 0]
|
|
is_passed boolean [not null, default: false]
|
|
attempt_number int [not null, default: 1]
|
|
answers jsonb [note: 'student answers for review']
|
|
started_at datetime [not null, default: `now()`]
|
|
completed_at datetime
|
|
|
|
Note: 'No updated_at - attempts are immutable after completion'
|
|
|
|
indexes {
|
|
user_id
|
|
quiz_id
|
|
(user_id, quiz_id)
|
|
(user_id, quiz_id, attempt_number)
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// COMMUNICATION
|
|
// ============================================
|
|
|
|
Table announcements {
|
|
id int [pk, increment]
|
|
course_id int [not null, ref: > courses.id]
|
|
title jsonb [not null, note: 'multi-language']
|
|
content jsonb [not null, note: 'multi-language']
|
|
status varchar [not null, default: 'DRAFT', note: 'ENUM: DRAFT | PUBLISHED | ARCHIVED']
|
|
is_pinned boolean [not null, default: false]
|
|
published_at datetime [note: 'scheduled publish date']
|
|
created_at datetime [not null, default: `now()`]
|
|
created_by int [not null, ref: > users.id]
|
|
updated_at datetime
|
|
updated_by int [ref: > users.id]
|
|
|
|
indexes {
|
|
course_id
|
|
created_by
|
|
status
|
|
(course_id, status, is_pinned, published_at)
|
|
}
|
|
}
|
|
|
|
Table announcement_attachments {
|
|
id int [pk, increment]
|
|
announcement_id int [not null, ref: > announcements.id]
|
|
file_name varchar [not null]
|
|
file_path varchar [not null, note: 'S3 key']
|
|
file_size int [not null, note: 'bytes']
|
|
mime_type varchar [not null]
|
|
created_at datetime [not null, default: `now()`]
|
|
|
|
Note: 'No updated_at - attachments are immutable'
|
|
|
|
indexes {
|
|
announcement_id
|
|
}
|
|
}
|
|
|
|
Table lesson_attachments {
|
|
id int [pk, increment]
|
|
lesson_id int [not null, ref: > lessons.id]
|
|
file_name varchar [not null]
|
|
file_path varchar [not null, note: 'S3 key']
|
|
file_size int [not null, note: 'bytes']
|
|
mime_type varchar [not null]
|
|
description jsonb [note: 'multi-language']
|
|
sort_order int [not null, default: 0]
|
|
created_at datetime [not null, default: `now()`]
|
|
|
|
Note: 'No updated_at - use lesson audit trail'
|
|
|
|
indexes {
|
|
lesson_id
|
|
(lesson_id, sort_order)
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// PAYMENT SYSTEM (Future)
|
|
// ============================================
|
|
|
|
Table orders {
|
|
id int [pk, increment]
|
|
user_id int [not null, ref: > users.id]
|
|
total_amount decimal [not null, default: 0]
|
|
status varchar [not null, default: 'PENDING', note: 'ENUM: PENDING | PAID | CANCELLED | REFUNDED']
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
indexes {
|
|
user_id
|
|
status
|
|
(user_id, status)
|
|
}
|
|
}
|
|
|
|
Table order_items {
|
|
id int [pk, increment]
|
|
order_id int [not null, ref: > orders.id]
|
|
course_id int [not null, ref: > courses.id]
|
|
price decimal [not null]
|
|
created_at datetime [not null, default: `now()`]
|
|
|
|
indexes {
|
|
order_id
|
|
course_id
|
|
}
|
|
}
|
|
|
|
Table payments {
|
|
id int [pk, increment]
|
|
order_id int [not null, ref: > orders.id]
|
|
provider varchar [not null, note: 'stripe | paypal | promptpay']
|
|
transaction_id varchar [unique]
|
|
amount decimal [not null]
|
|
status varchar [not null, default: 'PENDING', note: 'ENUM: PENDING | SUCCESS | FAILED']
|
|
paid_at datetime
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
indexes {
|
|
order_id
|
|
transaction_id [unique]
|
|
status
|
|
}
|
|
}
|
|
|
|
Table instructor_balances {
|
|
id int [pk, increment]
|
|
instructor_id int [not null, unique, ref: > users.id]
|
|
available_amount decimal [not null, default: 0]
|
|
withdrawn_amount decimal [not null, default: 0]
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
|
|
indexes {
|
|
instructor_id [unique]
|
|
}
|
|
}
|
|
|
|
Table withdrawal_requests {
|
|
id int [pk, increment]
|
|
instructor_id int [not null, ref: > users.id]
|
|
amount decimal [not null]
|
|
status varchar [not null, default: 'PENDING', note: 'ENUM: PENDING | APPROVED | REJECTED | PAID']
|
|
approved_by int [ref: > users.id]
|
|
approved_at datetime
|
|
rejected_reason varchar
|
|
created_at datetime [not null, default: `now()`]
|
|
updated_at datetime
|
|
updated_by int [ref: > users.id]
|
|
|
|
indexes {
|
|
instructor_id
|
|
status
|
|
(instructor_id, status)
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// NOTES & BEST PRACTICES
|
|
// ============================================
|
|
|
|
// 1. ENUM Status Values:
|
|
// - courses: DRAFT | PENDING | APPROVED | REJECTED | ARCHIVED
|
|
// - enrollments: ENROLLED | IN_PROGRESS | COMPLETED | DROPPED
|
|
// - announcements: DRAFT | PUBLISHED | ARCHIVED
|
|
// - orders: PENDING | PAID | CANCELLED | REFUNDED
|
|
// - payments: PENDING | SUCCESS | FAILED
|
|
// - withdrawals: PENDING | APPROVED | REJECTED | PAID
|
|
// - score_policy: HIGHEST | LATEST | FIRST | AVERAGE
|
|
// - lesson_type: VIDEO | PDF | TEXT | QUIZ
|
|
// - question_type: MULTIPLE_CHOICE | TRUE_FALSE | SHORT_ANSWER
|
|
|
|
// 2. Audit Trail Strategy:
|
|
// - Full audit (created_by, updated_by): courses, quizzes, categories, announcements
|
|
// - Partial audit (created_by only): user_profiles
|
|
// - No audit: student actions (enrollments, progress, attempts)
|
|
// - No audit: child records saved with parent (questions, choices, chapters, lessons)
|
|
|
|
// 3. Updated Fields:
|
|
// - updated_at is NULL on creation, only set when actually updated
|
|
// - updated_by is NULL on creation, only set when updated by someone
|
|
|
|
// 4. Multi-Instructor Support:
|
|
// - course_instructors table allows multiple instructors per course
|
|
// - is_primary flag identifies main instructor
|
|
// - created_by in courses table is the original creator
|
|
|
|
// 5. Role System:
|
|
// - roles table stores code (ADMIN) and display names ({"th": "ผู้ดูแลระบบ"})
|
|
// - Allows easy translation and role management
|
|
// - users.role_id references roles.id
|
|
|
|
// 6. User Profiles:
|
|
// - Separated from users table for cleaner authentication
|
|
// - Contains display information (name, bio, avatar)
|
|
// - prefix field for Thai/international name prefixes
|
|
|
|
// 7. Announcement Status:
|
|
// - DRAFT: not visible to students
|
|
// - PUBLISHED: visible to enrolled students
|
|
// - ARCHIVED: hidden but kept for history
|
|
|
|
// 8. Foreign Key Actions:
|
|
// - users.role_id: RESTRICT (don't delete roles in use)
|
|
// - courses.created_by: RESTRICT (don't delete instructors with courses)
|
|
// - enrollments: CASCADE (delete with user/course)
|
|
// - announcements: CASCADE (delete with course)
|
|
// - quiz_attempts: CASCADE (delete with user)
|
|
|
|
|
|
Ref: "certificates"."issued_at" < "certificates"."enrollment_id" |