Services

2026 04 05 Phase20 Certificate Pdf Quiz Design


Phase 20: Certificate PDF Generation & Quiz Enhancements — Design Spec

Date: 2026-04-05 Status: Approved Phase: 20 of 39 Milestone: v2.0

Overview#

Generate downloadable PDF certificates on course completion and add explanatory feedback to quiz results. Two epics, sequential: Epic 1 (Certificate PDF) then Epic 2 (Quiz Enhancements).

Epic 1: Certificate PDF Generation#

Goal#

Auto-generate branded PDF certificates when a learner completes a course. Certificates are downloadable and publicly verifiable via QR code.

Technology Choices#

ComponentChoiceRationale
PDF librarygithub.com/go-pdf/fpdfPure Go, maintained fork of gofpdf, zero external deps, fits fixed-layout certificates
QR codegithub.com/skip2/go-qrcodePure Go, generates QR PNG in-memory
StorageLocal filesystem data/certificates/Simple, no R2/S3 dependency; migrateable later via storage interface

Certificate Layout#

Fixed single-page A4 landscape PDF:

  • Top: Platform logo (embedded PNG)
  • Center: "Certificate of Completion" heading
  • Body: Learner full name (large), course title, platform + level badge text, completion date (formatted)
  • Bottom-left: Unique certificate ID
  • Bottom-right: QR code linking to {BASE_URL}/verify/{completion_id}
  • Typography: Helvetica (built-in to fpdf, no font embedding needed)

Data Flow#

1
All lessons marked complete
2
→ checkAndCompleteEnrollment() triggers
3
→ CompleteEnrollment(enrollmentID)
4
→ Create Completion record (UUID)
5
→ GenerateCertificatePDF(completion, course, userFullName)
6
→ Render PDF via fpdf
7
→ Embed QR code via go-qrcode
8
→ Write to data/certificates/{completion_id}.pdf
9
→ Update Completion.CertURL = /api/v1/certificates/{id}/download
10
→ Return Completion

New Files#

FilePurpose
backend/internal/learn/service/certificate.goPDF generation logic: template rendering, QR embedding, file I/O
backend/internal/learn/handler/certificate_handler.goAlready exists — extend with /download and /verify endpoints
apps/learn/app/(course)/verify/[id]/page.tsxPublic verification page

API Endpoints#

Existing (unchanged):

  • GET /api/v1/certificates/:id — returns completion metadata JSON

New:

  • GET /api/v1/certificates/:id/download — streams PDF file

    • Response: Content-Type: application/pdf, Content-Disposition: attachment; filename="certificate-{id}.pdf"
    • 404 if completion not found or PDF file missing
    • Public (no auth) — certificate ID is unguessable UUID
  • GET /api/v1/certificates/:id/verify — public verification JSON

    • Response: { valid: true, course_title, learner_name, platform, level, completed_at }
    • 404 if not found → { valid: false }
    • Public (no auth) — this is the QR code landing endpoint

Frontend Changes#

apps/learn/app/(protected)/certificates/page.tsx:

  • Change download link from current completion endpoint to /api/v1/certificates/{completion_id}/download
  • Add "Download PDF" button with download icon

apps/learn/app/(course)/verify/[id]/page.tsx (new):

  • Public page (no auth required)
  • Fetches /api/v1/certificates/{id}/verify
  • Displays: certificate validity badge, course title, learner name, completion date
  • Shows "Invalid Certificate" if not found

Storage#

  • Directory: data/certificates/ relative to backend working directory
  • File naming: {completion_id}.pdf (UUID, globally unique)
  • Created on first certificate generation (mkdir -p equivalent)
  • No cleanup/expiry needed — certificates are permanent
  • Future migration: swap file I/O in certificate.go for S3 client; endpoint stays the same

Error Handling#

  • PDF generation failure: log error, still create Completion record but leave CertURL empty. Certificate can be regenerated later via admin endpoint (future).
  • File not found on download: return 404 with JSON error
  • Storage directory not writable: fail loudly at startup (check in main.go init)

Epic 2: Quiz Hints & Explanations#

Goal#

Show per-question explanations and correct answers after quiz submission. Add retry cooldown to prevent spam.

Domain Model Changes#

backend/internal/learn/domain/quiz.go:

go
1
type Question struct {
2
// ... existing fields ...
3
Explanation string `json:"explanation" gorm:"type:text;default:''"`
4
}
5
6
type Option struct {
7
// ... existing fields ...
8
Hint string `json:"hint" gorm:"type:text;default:''"`
9
}
10
11
type Quiz struct {
12
// ... existing fields ...
13
CooldownMinutes int `json:"cooldown_minutes" gorm:"default:0"`
14
}

Database Migration#

New migration 003_add_quiz_explanations.up.sql:

sql
1
ALTER TABLE questions ADD COLUMN IF NOT EXISTS explanation TEXT DEFAULT '';
2
ALTER TABLE options ADD COLUMN IF NOT EXISTS hint TEXT DEFAULT '';
3
ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS cooldown_minutes INTEGER DEFAULT 0;

Down migration:

sql
1
ALTER TABLE questions DROP COLUMN IF EXISTS explanation;
2
ALTER TABLE options DROP COLUMN IF EXISTS hint;
3
ALTER TABLE quizzes DROP COLUMN IF EXISTS cooldown_minutes;

Quiz Submission Response Change#

Current response:

json
1
{ "score": 85, "passed": true, "correct": 4, "total": 5 }

New response (after submission only):

json
1
{
2
"score": 85,
3
"passed": true,
4
"correct": 4,
5
"total": 5,
6
"questions": [
7
{
8
"id": "uuid",
9
"text": "What is Kubernetes?",
10
"explanation": "Kubernetes is a container orchestration platform...",
11
"your_answer": "option-uuid-1",
12
"correct_answer": "option-uuid-2",
13
"is_correct": false,
14
"options": [
15
{ "id": "option-uuid-1", "text": "A database", "hint": "Databases store data, not orchestrate containers" },
16
{ "id": "option-uuid-2", "text": "A container orchestrator", "hint": "", "is_correct": true }
17
]
18
}
19
]
20
}

Important: GET /api/v1/quizzes/:id continues to strip is_correct, explanation, and hint from responses. These are only revealed in the submission response.

Retry Cooldown#

  • Quiz.CooldownMinutes (default 0 = no cooldown)
  • On POST /api/v1/quizzes/:id/submit:
    1. Check last attempt timestamp for this user+quiz
    2. If now - last_attempt < cooldown_minutes → return 429 with Retry-After header (seconds remaining)
  • Frontend shows countdown timer when blocked

Frontend Changes#

apps/learn/components/quiz/quiz-container.tsx:

After submission, render result view with:

  • Per-question list showing:
    • Question text
    • User's selected answer (highlighted red if wrong, green if correct)
    • Correct answer (highlighted green)
    • Explanation text below each question
    • Option hints (shown on incorrect options if available)
  • Overall score and pass/fail status (existing)
  • If cooldown active: show "You can retry in X minutes" with countdown

Error Handling#

  • Cooldown check: 429 status with retry_after_seconds in JSON body
  • Missing explanations: gracefully handle empty strings (don't show explanation section if empty)

Verification Matrix#

EpicTestExpected Result
E1Complete a courseCompletion record created, PDF generated in data/certificates/
E1GET /certificates/:id/downloadReturns PDF with correct content
E1GET /certificates/:id/verifyReturns valid certificate metadata
E1Visit /verify/:id in browserShows certificate details
E1Visit /verify/invalid-uuidShows "Invalid Certificate"
E2Submit quizResponse includes per-question explanations
E2GET /quizzes/:id (before submit)No explanations/correct answers exposed
E2Submit quiz within cooldown429 with retry-after
E2Quiz result UIShows correct/incorrect highlighting + explanations

Dependencies#

  • github.com/go-pdf/fpdf — PDF generation
  • github.com/skip2/go-qrcode — QR code generation
  • No new frontend dependencies

Files Changed (Summary)#

New#

  • backend/internal/learn/service/certificate.go
  • backend/migrations/003_add_quiz_explanations.up.sql
  • backend/migrations/003_add_quiz_explanations.down.sql
  • apps/learn/app/(course)/verify/[id]/page.tsx

Modified#

  • backend/go.mod + backend/go.sum (new deps)
  • backend/internal/learn/domain/quiz.go (Explanation, Hint, CooldownMinutes fields)
  • backend/internal/learn/service/learn_service.go (call PDF generation in CompleteEnrollment)
  • backend/internal/learn/handler/certificate_handler.go (download + verify endpoints)
  • backend/internal/learn/handler/quiz_handler.go (explanations in submit response, cooldown check)
  • backend/cmd/learn-api/main.go (register new routes, storage dir check)
  • apps/learn/app/(protected)/certificates/page.tsx (download button URL)
  • apps/learn/components/quiz/quiz-container.tsx (result view with explanations)