Database Schema¶
Learnify Childcare uses PostgreSQL with Prisma ORM. The database is hosted on Neon in production.
Entity Relationship Diagram¶
erDiagram
Organization ||--o{ User : has
Organization ||--o{ CourseAssignment : tracks
User ||--o{ Enrollment : has
User ||--o{ LectureProgress : tracks
User ||--o{ Certificate : earns
User ||--o{ CourseAssignment : receives
User ||--o{ CourseAssignment : assigns
User ||--o{ Course : creates
Course ||--o{ Section : contains
Section ||--o{ Lecture : contains
Lecture ||--o{ LectureProgress : tracked_by
Course ||--o{ Enrollment : has
Course ||--o{ CourseAssignment : assigned_via
Course }o--|| Category : belongs_to Enums¶
enum UserRole {
LEARNER
CORPORATE_ADMIN
SUPER_ADMIN
}
enum CourseLevel {
BEGINNER
INTERMEDIATE
ADVANCED
ALL_LEVELS
}
enum CourseStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum LectureType {
VIDEO
TEXT
QUIZ
}
enum AssignmentStatus {
ASSIGNED
IN_PROGRESS
COMPLETED
OVERDUE
}
Models¶
User¶
The central user model representing learners, corporate admins, and super admins. Users belong to an organisation (except super admins).
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
password String?
name String?
image String?
bio String? @db.Text
role UserRole @default(LEARNER)
jobTitle String?
staffId String?
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Auth relations
accounts Account[]
sessions Session[]
// Learning relations
enrollments Enrollment[]
progress LectureProgress[]
certificates Certificate[]
// Course creation (SUPER_ADMIN)
createdCourses Course[]
// Assignments
assignments CourseAssignment[] // Courses assigned to this learner
assignedByMe CourseAssignment[] @relation("AssignedBy") // Courses this admin assigned
@@index([email])
@@index([role])
@@index([organizationId])
}
Key fields:
| Field | Description |
|---|---|
role | LEARNER, CORPORATE_ADMIN, or SUPER_ADMIN |
organizationId | Which childcare centre the user belongs to (null for SUPER_ADMIN) |
jobTitle | Role at the childcare centre (e.g., "Lead Teacher") |
staffId | Organisation-issued staff identifier |
Organization¶
Represents a childcare centre. Contains billing configuration and learner limits.
model Organization {
id String @id @default(cuid())
name String
slug String @unique
contactName String?
contactEmail String?
phone String?
address String? @db.Text
licenseNumber String?
maxLearners Int @default(50)
billingEnabled Boolean @default(false)
stripeCustomerId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
assignments CourseAssignment[]
@@index([slug])
}
Key fields:
| Field | Description |
|---|---|
slug | URL-safe unique identifier |
licenseNumber | ECDA or government-issued childcare licence |
maxLearners | Cap on learner accounts (default: 50) |
billingEnabled | Whether Stripe billing is required for course assignments |
stripeCustomerId | Linked Stripe customer for billing |
Course¶
Training courses created by Super Admins. Uses SGD pricing and includes CPD points and SCORM metadata.
model Course {
id String @id @default(cuid())
title String
slug String @unique
subtitle String?
description String? @db.Text
thumbnail String?
priceSgd Decimal @default(60) @db.Decimal(10, 2)
cpdPoints Int @default(0)
estimatedHours Decimal @default(2) @db.Decimal(4, 1)
scormVersion String @default("2.0")
level CourseLevel @default(ALL_LEVELS)
language String @default("English")
learningOutcomes String[]
status CourseStatus @default(DRAFT)
publishedAt DateTime?
totalDuration Int @default(0)
totalLectures Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
createdBy User @relation(fields: [createdById], references: [id])
categoryId String
category Category @relation(fields: [categoryId], references: [id])
sections Section[]
enrollments Enrollment[]
assignments CourseAssignment[]
@@index([slug])
@@index([createdById])
@@index([categoryId])
@@index([status])
}
Key fields:
| Field | Description |
|---|---|
priceSgd | Price in SGD per assignment (default: 60.00) |
cpdPoints | CPD points awarded on completion |
estimatedHours | Expected time to complete |
scormVersion | SCORM compatibility version (default: "2.0") |
status | DRAFT, PUBLISHED, or ARCHIVED |
learningOutcomes | Array of learning outcome strings |
Category¶
Course categorisation for early childhood education topics.
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
description String?
icon String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
courses Course[]
@@index([slug])
}
Default categories: Child Development, Health & Safety, Nutrition & Wellness, Curriculum Planning, Special Needs, Parent Communication, Regulatory Compliance.
Section and Lecture¶
Course content organisation. Sections contain lectures. Both support drag-and-drop reordering via position field.
model Section {
id String @id @default(cuid())
title String
description String?
position Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lectures Lecture[]
@@index([courseId])
@@index([position])
}
model Lecture {
id String @id @default(cuid())
title String
description String? @db.Text
type LectureType @default(VIDEO)
position Int
videoUrl String?
videoDuration Int?
videoPublicId String? // Cloudinary public ID for video management
content String? @db.Text // Quiz JSON or rich text content
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sectionId String
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
progress LectureProgress[]
@@index([sectionId])
@@index([position])
}
Lecture types:
| Type | Description |
|---|---|
VIDEO | Video content uploaded via Cloudinary |
TEXT | Rich text content (HTML) |
QUIZ | Quiz stored as JSON in the content field |
CourseAssignment¶
Tracks when a corporate admin assigns a course to a learner. This is the central workflow model.
model CourseAssignment {
id String @id @default(cuid())
deadline DateTime?
status AssignmentStatus @default(ASSIGNED)
assignedAt DateTime @default(now())
notes String? @db.Text
stripeSessionId String?
learnerId String
learner User @relation(fields: [learnerId], references: [id], onDelete: Cascade)
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
assignedById String
assignedBy User @relation("AssignedBy", fields: [assignedById], references: [id])
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([learnerId, courseId])
@@index([learnerId])
@@index([courseId])
@@index([organizationId])
@@index([status])
}
Key fields:
| Field | Description |
|---|---|
learnerId | The learner who receives the assignment |
assignedById | The corporate admin who created the assignment |
organizationId | The organisation context (auto-set from corporate admin's org) |
deadline | Optional completion deadline |
status | ASSIGNED, IN_PROGRESS, COMPLETED, or OVERDUE |
stripeSessionId | Stripe checkout session ID (when billing is enabled) |
notes | Optional notes from the corporate admin |
Unique constraint: @@unique([learnerId, courseId]) ensures each learner is assigned a course at most once.
Enrollment and LectureProgress¶
Tracks learner progress through courses with SCORM-compatible data fields.
model Enrollment {
id String @id @default(cuid())
progress Int @default(0) // Percentage 0-100
completedAt DateTime?
lastAccessedAt DateTime?
assignedById String?
assignedAt DateTime?
deadline DateTime?
// SCORM fields
scormStatus String @default("not attempted")
scormScore Decimal? @db.Decimal(5, 2)
scormTotalTime String?
scormSuspendData String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
@@index([userId])
@@index([courseId])
}
model LectureProgress {
id String @id @default(cuid())
isCompleted Boolean @default(false)
watchedDuration Int @default(0)
lastPosition Int @default(0) // Video resume position in seconds
// SCORM fields
scormLessonStatus String?
scormSessionTime String?
scormLessonLocation String?
scormSuspendData String? @db.Text
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lectureId String
lecture Lecture @relation(fields: [lectureId], references: [id], onDelete: Cascade)
@@unique([userId, lectureId])
@@index([userId])
@@index([lectureId])
}
SCORM fields at the enrollment level:
| Field | Description |
|---|---|
scormStatus | Overall course status: "not attempted", "incomplete", "completed", "passed", "failed" |
scormScore | Aggregate score (0-100) |
scormTotalTime | Cumulative time in ISO 8601 duration format |
scormSuspendData | Serialised state for course-level resume |
SCORM fields at the lecture level:
| Field | Description |
|---|---|
scormLessonStatus | Per-lesson status |
scormSessionTime | Time spent in the current session |
scormLessonLocation | Bookmark for resuming within a lecture |
scormSuspendData | Per-lesson serialised state |
Certificate¶
Completion certificates with CPD points and organisation context.
model Certificate {
id String @id @default(cuid())
certificateId String @unique
issuedAt DateTime @default(now())
courseName String
organizationName String?
cpdPoints Int @default(0)
expiresAt DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
courseId String
@@index([userId])
@@index([certificateId])
}
Key fields:
| Field | Description |
|---|---|
certificateId | Unique human-readable identifier for verification (e.g., "CERT-2026-ABC123") |
courseName | Denormalised course name (preserved even if course is later archived) |
organizationName | Learner's organisation at time of completion |
cpdPoints | CPD points awarded (denormalised from course) |
expiresAt | Optional expiry date for time-limited certifications |
Auth Models¶
NextAuth.js v5 adapter models for OAuth and session management.
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model PasswordResetToken {
id String @id @default(cuid())
email String
token String @unique
expires DateTime
createdAt DateTime @default(now())
@@index([email])
}
Database Commands¶
# Generate Prisma Client
npx prisma generate
# Push schema to database (no migration history)
npm run db:push
# Run migrations (creates migration history)
npm run db:migrate
# Seed database with sample data
npm run db:seed
# Open Prisma Studio (browser-based DB GUI)
npm run db:studio
# Reset database (destructive)
npx prisma db push --force-reset
Indexes¶
The schema uses strategic indexes for query performance:
| Table | Index | Purpose |
|---|---|---|
| User | email | Login lookups |
| User | role | Role-based queries |
| User | organizationId | Organisation-scoped queries |
| Organization | slug | URL-based lookups |
| Course | slug, status, categoryId, createdById | Course listing and filtering |
| Section | courseId, position | Ordered section retrieval |
| Lecture | sectionId, position | Ordered lecture retrieval |
| Enrollment | userId, courseId | Progress lookups |
| LectureProgress | userId, lectureId | Per-lecture progress lookups |
| CourseAssignment | learnerId, courseId, organizationId, status | Assignment queries and filtering |
| Certificate | userId, certificateId | Certificate lookups |
Unique Constraints¶
| Constraint | Purpose |
|---|---|
Enrollment(userId, courseId) | One enrollment per learner per course |
LectureProgress(userId, lectureId) | One progress record per learner per lecture |
CourseAssignment(learnerId, courseId) | One assignment per learner per course |
Organization.slug | Unique organisation slugs for URLs |
Course.slug | Unique course slugs for URLs |
User.email | Unique email addresses |
Certificate.certificateId | Unique certificate IDs for verification |