2026 04 05 Phase21 Prerequisites Notifications Design
Phase 21: Prerequisite Enforcement & Progress Notifications — Design Spec
Date: 2026-04-05 Status: Approved Phase: 21 of 39 Milestone: v2.0
Overview#
Block enrollment in advanced courses until required prerequisites are completed, and send email notifications for enrollment confirmation and progress milestones. Two sequential epics: Epic 1 (Prerequisites) then Epic 2 (Notifications, slimmed).
Epic 1: Prerequisite Enforcement#
Goal#
Learners cannot enroll in advanced courses without completing required prerequisite courses. Prerequisites are defined per-course, enforced at enrollment time, and surfaced in the UI.
Domain Model#
New struct in backend/internal/learn/domain/entities.go:
1type CoursePrerequisite struct {2 ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`3 CourseID uuid.UUID `gorm:"type:uuid;not null;index" json:"course_id"`4 PrerequisiteCourseID uuid.UUID `gorm:"type:uuid;not null" json:"prerequisite_course_id"`5 PrerequisiteCourse *Course `gorm:"foreignKey:PrerequisiteCourseID" json:"prerequisite_course,omitempty"`6 Required bool `gorm:"default:true" json:"required"`7 CreatedAt time.Time `json:"created_at"`8}Add to Course struct:
1Prerequisites []CoursePrerequisite `gorm:"foreignKey:CourseID" json:"prerequisites,omitempty"`Table created via GORM AutoMigrate (same pattern as Quiz, Review, etc.).
Repository#
New PrerequisiteRepository interface:
1type PrerequisiteRepository interface {2 ListByCourse(courseID uuid.UUID) ([]domain.CoursePrerequisite, error)3 SetForCourse(courseID uuid.UUID, prerequisiteCourseIDs []uuid.UUID) error4 DeleteForCourse(courseID uuid.UUID) error5}Implementation in backend/internal/learn/repository/prerequisite.go.
SetForCourse is idempotent: deletes existing prerequisites for the course, then inserts the new set. This avoids partial update complexity.
Enrollment Gate#
In LearnService.Enroll(userID, courseID):
- Fetch prerequisites via
prereqRepo.ListByCourse(courseID)whereRequired == true - For each required prerequisite, check if user has a completion via
completions.GetByEnrollment(enrollmentID)— need to first find the enrollment for (userID, prerequisiteCourseID) - If any required prerequisite is not completed, return a structured error
New error type:
1type PrerequisitesNotMetError struct {2 Missing []MissingPrerequisite3}45type MissingPrerequisite struct {6 CourseID uuid.UUID `json:"course_id"`7 Title string `json:"title"`8 Slug string `json:"slug"`9}Handler returns 403:
1{2 "error": "prerequisites_not_met",3 "missing": [4 { "course_id": "uuid", "title": "EKS Fundamentals", "slug": "eks-fundamentals" }5 ]6}API Endpoints#
Public:
GET /api/v1/courses/:slug/prerequisites— returns prerequisite courses with completion status
Response (authenticated user):
1{2 "prerequisites": [3 {4 "course_id": "uuid",5 "title": "EKS Fundamentals",6 "slug": "eks-fundamentals",7 "required": true,8 "completed": true9 }10 ],11 "all_met": true12}Response (unauthenticated): same but completed is always false, all_met is false if any prerequisites exist.
Admin:
POST /api/v1/admin/learn/courses/:id/prerequisites— set prerequisites (replaces all)- Body:
{ "prerequisite_course_ids": ["uuid1", "uuid2"] }
- Body:
DELETE /api/v1/admin/learn/courses/:id/prerequisites— remove all prerequisites
Content Sync (Seeder)#
Course MDX files already have free-text prerequisites: for display. Add a new field prerequisite_courses: with course slugs:
1prerequisites:2 - "EKS Fundamentals or equivalent hands-on experience"3prerequisite_courses:4 - "eks-fundamentals"The learn-seed command (or content sync admin endpoint) resolves slugs to course IDs via CourseRepository.GetBySlug() and calls prereqRepo.SetForCourse().
ContentLayer2 config update: add prerequisite_courses as list('string') field.
Frontend#
apps/learn/components/prerequisite-gate.tsx (new):
- Client component accepting
courseSlugprop - Fetches
GET /api/v1/courses/:slug/prerequisites - If
all_met === false: renders locked state card with:- Lock icon
- "Complete these courses first" heading
- List of prerequisite courses with links to their detail pages
- Completed prerequisites show checkmark, incomplete show arrow link
- If
all_met === trueor no prerequisites: renders nothing (children pass through)
apps/learn/components/enroll-button.tsx (modify):
- Before showing enroll button, check prerequisites status
- If prerequisites not met, render
PrerequisiteGateinstead of the enroll button - If prerequisites met (or none exist), show normal enroll button
Error Handling#
- Missing prerequisite course (slug doesn't exist): skip that prerequisite in seeder, log warning
- Circular prerequisites: not checked at enforcement time (admin responsibility) — future validation
- Deleted prerequisite course: enrollment check skips prerequisites where the prerequisite course no longer exists
Epic 2: Progress Notifications (Slimmed)#
Goal#
Send email notifications for enrollment confirmation and progress milestones. Respect per-user notification preferences.
Domain Model#
New struct in backend/internal/learn/domain/entities.go:
1type NotificationPreference struct {2 ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`3 UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"user_id"`4 EmailEnrollment bool `gorm:"default:true" json:"email_enrollment"`5 EmailMilestones bool `gorm:"default:true" json:"email_milestones"`6 EmailReminders bool `gorm:"default:true" json:"email_reminders"`7 CreatedAt time.Time `json:"created_at"`8 UpdatedAt time.Time `json:"updated_at"`9}Table created via GORM AutoMigrate.
Repository#
New NotificationPreferenceRepository interface:
1type NotificationPreferenceRepository interface {2 GetByUserID(userID uuid.UUID) (*domain.NotificationPreference, error)3 Upsert(pref *domain.NotificationPreference) error4}Notification Service Extension#
Extend existing backend/internal/learn/service/notification.go:
New methods:
SendEnrollmentConfirmation(enrollment, course)— sends welcome email with course title and linkSendMilestoneNotification(enrollment, course, milestone int)— sends at 25%, 50%, 75%, 100%
Email templates: Simple plain-text emails via existing SMTP (Mailpit in dev). Format:
1From: learn@assistance.bg2Subject: You enrolled in {course.Title}!3Body: Welcome! You've enrolled in {course.Title}. Start learning at {link}.Preference check: Before sending any email, load NotificationPreference for the user. If no preference exists, default to all-enabled. Check the relevant boolean before sending.
Trigger Hooks#
Enrollment confirmation:
- In
LearnService.Enroll(), after successful enrollment creation, callnotifSvc.SendEnrollmentConfirmation(enrollment, course)(fire-and-forget goroutine)
Milestone notifications:
- In
handler.gomarkCompletehandler, after updating progress, calculate percentage:completedCount / totalLessons * 100 - If percentage crosses 25%, 50%, 75%, or 100% thresholds, call
notifSvc.SendMilestoneNotification(...)(fire-and-forget goroutine) - Track which milestones were already sent to avoid duplicates: check if a milestone email was already sent by comparing previous vs current percentage
API Endpoints#
Protected:
GET /api/v1/notifications/preferences— returns user's notification preferences (creates default if none exist)PATCH /api/v1/notifications/preferences— update preferences- Body:
{ "email_enrollment": true, "email_milestones": false, "email_reminders": true }
- Body:
Frontend#
apps/learn/app/(protected)/settings/notifications/page.tsx (new):
- Fetches
GET /api/v1/notifications/preferences - Shows 3 toggles (enrollment, milestones, reminders)
- On toggle change, PATCH to update
- Simple form matching existing
(protected)styling
Deferred#
- Cron job for weekly summaries → later phase
- In-app notification bell → later phase
- HTML email templates → later phase (plain text for now)
Verification Matrix#
Files Changed (Summary)#
New#
backend/internal/learn/repository/prerequisite.gobackend/internal/learn/domain/prerequisite_error.gobackend/internal/learn/handler/notification_handler.gobackend/internal/learn/repository/notification_preference.goapps/learn/components/prerequisite-gate.tsxapps/learn/app/(protected)/settings/notifications/page.tsx
Modified#
backend/internal/learn/domain/entities.go(CoursePrerequisite, NotificationPreference, Course.Prerequisites field)backend/internal/learn/repository/interfaces.go(PrerequisiteRepository, NotificationPreferenceRepository)backend/internal/learn/service/learn_service.go(prerequisite check in Enroll, prereqRepo field)backend/internal/learn/service/notification.go(enrollment + milestone methods, preference checks)backend/internal/learn/handler/handler.go(prerequisites endpoint, milestone trigger in markComplete)backend/internal/learn/handler/admin_handler.go(admin prerequisite CRUD)backend/cmd/learn-api/main.go(wire prereqRepo, notifPrefRepo, new handlers, AutoMigrate new types)apps/learn/components/enroll-button.tsx(prerequisite check)apps/learn/contentlayer.config.js(prerequisite_courses field)
Dependencies#
No new Go dependencies. No new frontend dependencies.