Services

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:

go
1
type 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:

go
1
Prerequisites []CoursePrerequisite `gorm:"foreignKey:CourseID" json:"prerequisites,omitempty"`

Table created via GORM AutoMigrate (same pattern as Quiz, Review, etc.).

Repository#

New PrerequisiteRepository interface:

go
1
type PrerequisiteRepository interface {
2
ListByCourse(courseID uuid.UUID) ([]domain.CoursePrerequisite, error)
3
SetForCourse(courseID uuid.UUID, prerequisiteCourseIDs []uuid.UUID) error
4
DeleteForCourse(courseID uuid.UUID) error
5
}

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):

  1. Fetch prerequisites via prereqRepo.ListByCourse(courseID) where Required == true
  2. For each required prerequisite, check if user has a completion via completions.GetByEnrollment(enrollmentID) — need to first find the enrollment for (userID, prerequisiteCourseID)
  3. If any required prerequisite is not completed, return a structured error

New error type:

go
1
type PrerequisitesNotMetError struct {
2
Missing []MissingPrerequisite
3
}
4
5
type MissingPrerequisite struct {
6
CourseID uuid.UUID `json:"course_id"`
7
Title string `json:"title"`
8
Slug string `json:"slug"`
9
}

Handler returns 403:

json
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):

json
1
{
2
"prerequisites": [
3
{
4
"course_id": "uuid",
5
"title": "EKS Fundamentals",
6
"slug": "eks-fundamentals",
7
"required": true,
8
"completed": true
9
}
10
],
11
"all_met": true
12
}

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"] }
  • 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:

yaml
1
prerequisites:
2
- "EKS Fundamentals or equivalent hands-on experience"
3
prerequisite_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 courseSlug prop
  • 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 === true or 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 PrerequisiteGate instead 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:

go
1
type 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:

go
1
type NotificationPreferenceRepository interface {
2
GetByUserID(userID uuid.UUID) (*domain.NotificationPreference, error)
3
Upsert(pref *domain.NotificationPreference) error
4
}

Notification Service Extension#

Extend existing backend/internal/learn/service/notification.go:

New methods:

  • SendEnrollmentConfirmation(enrollment, course) — sends welcome email with course title and link
  • SendMilestoneNotification(enrollment, course, milestone int) — sends at 25%, 50%, 75%, 100%

Email templates: Simple plain-text emails via existing SMTP (Mailpit in dev). Format:

1
From: learn@assistance.bg
2
Subject: You enrolled in {course.Title}!
3
Body: 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, call notifSvc.SendEnrollmentConfirmation(enrollment, course) (fire-and-forget goroutine)

Milestone notifications:

  • In handler.go markComplete handler, 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 }

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#

EpicTestExpected Result
E1Try to enroll in advanced course without completing prereq403 with missing prerequisites list
E1Complete prerequisite course, then enroll in advanced courseEnrollment succeeds
E1GET /courses/:slug/prerequisites (authenticated, prereqs complete)all_met: true
E1GET /courses/:slug/prerequisites (unauthenticated)Prerequisites listed, completed: false
E1Admin sets prerequisites via POSTPrerequisites saved, visible in GET
E1Course detail page shows locked state when prereqs not metPrerequisiteGate renders with links
E2Enroll in courseEnrollment email appears in Mailpit (port 3062)
E2Complete 25% of lessonsMilestone email appears in Mailpit
E2Disable milestone emails in settingsNo more milestone emails on progress
E2GET /notifications/preferencesReturns current preference state

Files Changed (Summary)#

New#

  • backend/internal/learn/repository/prerequisite.go
  • backend/internal/learn/domain/prerequisite_error.go
  • backend/internal/learn/handler/notification_handler.go
  • backend/internal/learn/repository/notification_preference.go
  • apps/learn/components/prerequisite-gate.tsx
  • apps/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.