Services

2026 04 05 Phase20 Certificate Pdf Quiz


Phase 20: Certificate PDF & Quiz Enhancements — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Generate downloadable PDF certificates on course completion and add explanatory feedback + cooldown to quiz results.

Architecture: Two sequential epics. Epic 1 adds go-pdf/fpdf + skip2/go-qrcode to the Go backend, creates a certificate generation service called from CompleteEnrollment(), stores PDFs on local filesystem, and adds download/verify endpoints. Epic 2 adds Explanation/Hint/CooldownMinutes fields to quiz domain, enriches the submit response, and updates the frontend quiz result view.

Tech Stack: Go 1.24, Gin, GORM, go-pdf/fpdf, skip2/go-qrcode, Next.js 15, React, TypeScript


File Map#

New Files#

FileResponsibility
backend/internal/learn/service/certificate.goPDF generation: template layout, QR code, file write
backend/internal/learn/service/certificate_test.goUnit tests for PDF generation
apps/learn/app/(course)/verify/[id]/page.tsxPublic certificate verification page

Modified Files#

FileChanges
backend/go.modAdd go-pdf/fpdf, skip2/go-qrcode
backend/internal/learn/domain/quiz.goAdd Explanation, Hint, CooldownMinutes fields
backend/internal/learn/service/learn_service.goAccept CertificateGenerator interface, call it in CompleteEnrollment()
backend/internal/learn/handler/certificate_handler.goAdd downloadCertificate() and verifyCertificate() handlers
backend/internal/learn/handler/quiz_handler.goAdd cooldown check, enrich submit response with explanations
backend/internal/learn/repository/interfaces.goAdd GetLastAttempt to QuizRepository
backend/internal/learn/repository/quiz.goImplement GetLastAttempt
backend/cmd/learn-api/main.goWire certificate generator, ensure data/certificates/ dir
apps/learn/app/(protected)/certificates/page.tsxPoint download to /certificates/:id/download
apps/learn/components/quiz/quiz-container.tsxShow per-question explanations + cooldown in result view

Epic 1: Certificate PDF Generation#

Task 1: Add Go dependencies#

Files:

  • Modify: backend/go.mod

  • Step 1: Add fpdf and go-qrcode

bash
1
cd /home/vchavkov/src/assistance/backend && go get github.com/go-pdf/fpdf && go get github.com/skip2/go-qrcode
  • Step 2: Verify go.mod updated
bash
1
cd /home/vchavkov/src/assistance/backend && grep -E "fpdf|qrcode" go.mod

Expected: Two lines showing github.com/go-pdf/fpdf and github.com/skip2/go-qrcode.

  • Step 3: Tidy
bash
1
cd /home/vchavkov/src/assistance/backend && go mod tidy
  • Step 4: Commit
bash
1
cd /home/vchavkov/src/assistance/backend && git add go.mod go.sum && git commit -m "feat(learn): add fpdf and go-qrcode dependencies for certificate generation"

Task 2: Create certificate generation service#

Files:

  • Create: backend/internal/learn/service/certificate_test.go

  • Create: backend/internal/learn/service/certificate.go

  • Step 1: Write the failing test

Create backend/internal/learn/service/certificate_test.go:

go
1
package service
2
3
import (
4
"os"
5
"path/filepath"
6
"testing"
7
"time"
8
9
"github.com/assistance/backend/internal/learn/domain"
10
"github.com/google/uuid"
11
)
12
13
func TestGenerateCertificatePDF(t *testing.T) {
14
dir := t.TempDir()
15
gen := NewCertificateGenerator(dir, "http://localhost:3065")
16
17
completion := &domain.Completion{
18
ID: uuid.New(),
19
EnrollmentID: uuid.New(),
20
CompletedAt: time.Date(2026, 4, 5, 0, 0, 0, 0, time.UTC),
21
}
22
course := &domain.Course{
23
Title: "Kubernetes Fundamentals",
24
Platform: "kubernetes",
25
Level: "beginner",
26
}
27
learnerName := "Jane Doe"
28
29
path, err := gen.Generate(completion, course, learnerName)
30
if err != nil {
31
t.Fatalf("Generate() error: %v", err)
32
}
33
34
// File must exist
35
if _, err := os.Stat(path); os.IsNotExist(err) {
36
t.Fatalf("PDF file not created at %s", path)
37
}
38
39
// File must be non-empty
40
info, _ := os.Stat(path)
41
if info.Size() < 100 {
42
t.Fatalf("PDF file too small: %d bytes", info.Size())
43
}
44
45
// Path must match expected pattern
46
expected := filepath.Join(dir, completion.ID.String()+".pdf")
47
if path != expected {
48
t.Fatalf("path = %s, want %s", path, expected)
49
}
50
}
51
52
func TestGenerateCertificatePDF_CreatesDir(t *testing.T) {
53
dir := filepath.Join(t.TempDir(), "nested", "certs")
54
gen := NewCertificateGenerator(dir, "http://localhost:3065")
55
56
completion := &domain.Completion{
57
ID: uuid.New(),
58
CompletedAt: time.Now(),
59
}
60
course := &domain.Course{Title: "Test", Platform: "test", Level: "beginner"}
61
62
_, err := gen.Generate(completion, course, "Test User")
63
if err != nil {
64
t.Fatalf("Generate() should create nested dirs, got: %v", err)
65
}
66
}
  • Step 2: Run test to verify it fails
bash
1
cd /home/vchavkov/src/assistance/backend && go test ./internal/learn/service/ -run TestGenerateCertificatePDF -v

Expected: FAIL — NewCertificateGenerator undefined.

  • Step 3: Write the implementation

Create backend/internal/learn/service/certificate.go:

go
1
package service
2
3
import (
4
"fmt"
5
"io"
6
"os"
7
"path/filepath"
8
9
"github.com/assistance/backend/internal/learn/domain"
10
"github.com/go-pdf/fpdf"
11
qrcode "github.com/skip2/go-qrcode"
12
)
13
14
// CertificateGenerator creates PDF certificates.
15
type CertificateGenerator struct {
16
storageDir string
17
baseURL string
18
}
19
20
func NewCertificateGenerator(storageDir, baseURL string) *CertificateGenerator {
21
return &CertificateGenerator{storageDir: storageDir, baseURL: baseURL}
22
}
23
24
// Generate creates a PDF certificate and returns the file path.
25
func (g *CertificateGenerator) Generate(completion *domain.Completion, course *domain.Course, learnerName string) (string, error) {
26
if err := os.MkdirAll(g.storageDir, 0o755); err != nil {
27
return "", fmt.Errorf("create storage dir: %w", err)
28
}
29
30
pdf := fpdf.New("L", "mm", "A4", "")
31
pdf.SetAutoPageBreak(false, 0)
32
pdf.AddPage()
33
34
pageW, pageH := pdf.GetPageSize()
35
36
// Background border
37
pdf.SetDrawColor(0, 153, 99) // brand green
38
pdf.SetLineWidth(2)
39
pdf.Rect(10, 10, pageW-20, pageH-20, "D")
40
41
// Inner border
42
pdf.SetLineWidth(0.5)
43
pdf.Rect(15, 15, pageW-30, pageH-30, "D")
44
45
// Title
46
pdf.SetFont("Helvetica", "B", 36)
47
pdf.SetTextColor(0, 153, 99)
48
pdf.SetXY(0, 35)
49
pdf.CellFormat(pageW, 15, "Certificate of Completion", "", 1, "C", false, 0, "")
50
51
// Decorative line
52
pdf.SetDrawColor(0, 153, 99)
53
pdf.SetLineWidth(0.8)
54
lineY := 55.0
55
pdf.Line(pageW/2-60, lineY, pageW/2+60, lineY)
56
57
// "This certifies that"
58
pdf.SetFont("Helvetica", "", 14)
59
pdf.SetTextColor(100, 100, 100)
60
pdf.SetXY(0, 62)
61
pdf.CellFormat(pageW, 10, "This certifies that", "", 1, "C", false, 0, "")
62
63
// Learner name
64
pdf.SetFont("Helvetica", "B", 28)
65
pdf.SetTextColor(30, 30, 30)
66
pdf.SetXY(0, 78)
67
pdf.CellFormat(pageW, 14, learnerName, "", 1, "C", false, 0, "")
68
69
// "has successfully completed"
70
pdf.SetFont("Helvetica", "", 14)
71
pdf.SetTextColor(100, 100, 100)
72
pdf.SetXY(0, 98)
73
pdf.CellFormat(pageW, 10, "has successfully completed", "", 1, "C", false, 0, "")
74
75
// Course title
76
pdf.SetFont("Helvetica", "B", 22)
77
pdf.SetTextColor(30, 30, 30)
78
pdf.SetXY(0, 113)
79
pdf.CellFormat(pageW, 12, course.Title, "", 1, "C", false, 0, "")
80
81
// Platform + Level
82
pdf.SetFont("Helvetica", "", 12)
83
pdf.SetTextColor(100, 100, 100)
84
pdf.SetXY(0, 130)
85
pdf.CellFormat(pageW, 8, fmt.Sprintf("Platform: %s | Level: %s", course.Platform, course.Level), "", 1, "C", false, 0, "")
86
87
// Completion date
88
pdf.SetFont("Helvetica", "", 12)
89
pdf.SetXY(0, 142)
90
pdf.CellFormat(pageW, 8, fmt.Sprintf("Completed on %s", completion.CompletedAt.Format("January 2, 2006")), "", 1, "C", false, 0, "")
91
92
// Certificate ID (bottom-left)
93
pdf.SetFont("Helvetica", "", 9)
94
pdf.SetTextColor(150, 150, 150)
95
pdf.SetXY(25, pageH-30)
96
pdf.CellFormat(0, 5, fmt.Sprintf("Certificate ID: %s", completion.ID.String()), "", 0, "L", false, 0, "")
97
98
// QR code (bottom-right)
99
verifyURL := fmt.Sprintf("%s/api/v1/certificates/%s/verify", g.baseURL, completion.ID.String())
100
qrPNG, err := qrcode.Encode(verifyURL, qrcode.Medium, 256)
101
if err == nil {
102
opt := fpdf.ImageOptions{ImageType: "PNG"}
103
pdf.RegisterImageOptionsReader("qr", opt, byteReader(qrPNG))
104
qrSize := 28.0
105
pdf.ImageOptions("qr", pageW-25-qrSize, pageH-25-qrSize, qrSize, qrSize, false, opt, 0, "")
106
}
107
108
// Write file
109
outPath := filepath.Join(g.storageDir, completion.ID.String()+".pdf")
110
if err := pdf.OutputFileAndClose(outPath); err != nil {
111
return "", fmt.Errorf("write PDF: %w", err)
112
}
113
114
return outPath, nil
115
}
116
117
type byteReaderWrapper struct {
118
data []byte
119
pos int
120
}
121
122
func byteReader(data []byte) *byteReaderWrapper {
123
return &byteReaderWrapper{data: data}
124
}
125
126
func (r *byteReaderWrapper) Read(p []byte) (int, error) {
127
if r.pos >= len(r.data) {
128
return 0, io.EOF
129
}
130
n := copy(p, r.data[r.pos:])
131
r.pos += n
132
return n, nil
133
}
  • Step 4: Run tests to verify they pass
bash
1
cd /home/vchavkov/src/assistance/backend && go test ./internal/learn/service/ -run TestGenerateCertificatePDF -v

Expected: PASS (both tests).

  • Step 5: Commit
bash
1
cd /home/vchavkov/src/assistance/backend && git add internal/learn/service/certificate.go internal/learn/service/certificate_test.go && git commit -m "feat(learn): certificate PDF generation service with QR code"

Task 3: Wire certificate generation into CompleteEnrollment#

Files:

  • Modify: backend/internal/learn/service/learn_service.go

  • Step 1: Add CertGenerator field to LearnService

In backend/internal/learn/service/learn_service.go, replace the LearnService struct and constructor (lines 13-38) with:

go
1
type LearnService struct {
2
courses repository.CourseRepository
3
modules repository.ModuleRepository
4
lessons repository.LessonRepository
5
enrollments repository.EnrollmentRepository
6
progress repository.ProgressRepository
7
completions repository.CompletionRepository
8
certGen *CertificateGenerator
9
}
10
11
func NewLearnService(
12
courses repository.CourseRepository,
13
modules repository.ModuleRepository,
14
lessons repository.LessonRepository,
15
enrollments repository.EnrollmentRepository,
16
progress repository.ProgressRepository,
17
completions repository.CompletionRepository,
18
) *LearnService {
19
return &LearnService{
20
courses: courses,
21
modules: modules,
22
lessons: lessons,
23
enrollments: enrollments,
24
progress: progress,
25
completions: completions,
26
}
27
}
28
29
// SetCertificateGenerator configures PDF certificate generation.
30
// If not set, certificates are created without a PDF file.
31
func (s *LearnService) SetCertificateGenerator(gen *CertificateGenerator) {
32
s.certGen = gen
33
}
  • Step 2: Update CompleteEnrollment to generate PDF

Replace the CompleteEnrollment method (lines 179-204) with:

go
1
func (s *LearnService) CompleteEnrollment(enrollmentID uuid.UUID) (*domain.Completion, error) {
2
existing, _ := s.completions.GetByEnrollment(enrollmentID)
3
if existing != nil {
4
return existing, nil
5
}
6
7
completionID := uuid.New()
8
completion := &domain.Completion{
9
ID: completionID,
10
EnrollmentID: enrollmentID,
11
CompletedAt: time.Now(),
12
CertURL: "/api/v1/certificates/" + completionID.String(),
13
}
14
if err := s.completions.Create(completion); err != nil {
15
return nil, err
16
}
17
18
// Update enrollment status to completed
19
enrollment, err := s.enrollments.GetByID(enrollmentID)
20
if err == nil && enrollment != nil {
21
enrollment.Status = "completed"
22
_ = s.enrollments.Update(enrollment)
23
}
24
25
// Generate PDF certificate (best-effort: log error but don't fail completion)
26
if s.certGen != nil && enrollment != nil {
27
course, err := s.courses.GetByID(enrollment.CourseID)
28
if err == nil && course != nil {
29
// Use user_id as learner name placeholder — real name comes from auth service
30
learnerName := enrollment.UserID.String()
31
if _, genErr := s.certGen.Generate(completion, course, learnerName); genErr != nil {
32
// Log but don't fail — certificate can be regenerated later
33
fmt.Printf("WARN: certificate PDF generation failed for %s: %v\n", completionID, genErr)
34
} else {
35
completion.CertURL = "/api/v1/certificates/" + completionID.String() + "/download"
36
_ = s.completions.Update(completion)
37
}
38
}
39
}
40
41
return completion, nil
42
}

Add "fmt" to the imports at the top of the file (line 3-11).

  • Step 3: Verify build
bash
1
cd /home/vchavkov/src/assistance/backend && go build ./...

Expected: No errors.

  • Step 4: Commit
bash
1
cd /home/vchavkov/src/assistance/backend && git add internal/learn/service/learn_service.go && git commit -m "feat(learn): wire certificate PDF generation into CompleteEnrollment"

Task 4: Add download and verify endpoints#

Files:

  • Modify: backend/internal/learn/handler/certificate_handler.go

  • Step 1: Replace certificate_handler.go with extended version

Replace the entire contents of backend/internal/learn/handler/certificate_handler.go:

go
1
package handler
2
3
import (
4
"net/http"
5
"os"
6
"path/filepath"
7
8
"github.com/assistance/backend/internal/learn/repository"
9
"github.com/assistance/backend/internal/learn/service"
10
"github.com/gin-gonic/gin"
11
"github.com/google/uuid"
12
)
13
14
type CertificateHandler struct {
15
svc *service.LearnService
16
completions repository.CompletionRepository
17
enrollments repository.EnrollmentRepository
18
courses repository.CourseRepository
19
certDir string
20
}
21
22
func NewCertificateHandler(
23
svc *service.LearnService,
24
completions repository.CompletionRepository,
25
enrollments repository.EnrollmentRepository,
26
courses repository.CourseRepository,
27
certDir string,
28
) *CertificateHandler {
29
return &CertificateHandler{
30
svc: svc,
31
completions: completions,
32
enrollments: enrollments,
33
courses: courses,
34
certDir: certDir,
35
}
36
}
37
38
func (h *CertificateHandler) RegisterRoutes(rg *gin.RouterGroup) {
39
rg.GET("/certificates/:id", h.getCertificate)
40
rg.GET("/certificates/:id/download", h.downloadCertificate)
41
rg.GET("/certificates/:id/verify", h.verifyCertificate)
42
}
43
44
func (h *CertificateHandler) getCertificate(c *gin.Context) {
45
id, err := uuid.Parse(c.Param("id"))
46
if err != nil {
47
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
48
return
49
}
50
51
completion, err := h.completions.GetByID(id)
52
if err != nil {
53
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
54
return
55
}
56
57
enrollment, err := h.enrollments.GetByID(completion.EnrollmentID)
58
if err != nil {
59
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
60
return
61
}
62
63
course, err := h.courses.GetByID(enrollment.CourseID)
64
if err != nil {
65
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
66
return
67
}
68
69
c.JSON(http.StatusOK, gin.H{
70
"id": completion.ID,
71
"course_title": course.Title,
72
"platform": course.Platform,
73
"level": course.Level,
74
"completed_at": completion.CompletedAt.Format("January 2, 2006"),
75
"cert_url": completion.CertURL,
76
"enrollment_id": completion.EnrollmentID,
77
})
78
}
79
80
func (h *CertificateHandler) downloadCertificate(c *gin.Context) {
81
id, err := uuid.Parse(c.Param("id"))
82
if err != nil {
83
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
84
return
85
}
86
87
// Verify completion exists
88
if _, err := h.completions.GetByID(id); err != nil {
89
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
90
return
91
}
92
93
pdfPath := filepath.Join(h.certDir, id.String()+".pdf")
94
if _, err := os.Stat(pdfPath); os.IsNotExist(err) {
95
c.JSON(http.StatusNotFound, gin.H{"error": "certificate PDF not available"})
96
return
97
}
98
99
c.Header("Content-Disposition", "attachment; filename=\"certificate-"+id.String()+".pdf\"")
100
c.File(pdfPath)
101
}
102
103
func (h *CertificateHandler) verifyCertificate(c *gin.Context) {
104
id, err := uuid.Parse(c.Param("id"))
105
if err != nil {
106
c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": "invalid certificate ID"})
107
return
108
}
109
110
completion, err := h.completions.GetByID(id)
111
if err != nil {
112
c.JSON(http.StatusNotFound, gin.H{"valid": false})
113
return
114
}
115
116
enrollment, err := h.enrollments.GetByID(completion.EnrollmentID)
117
if err != nil {
118
c.JSON(http.StatusNotFound, gin.H{"valid": false})
119
return
120
}
121
122
course, err := h.courses.GetByID(enrollment.CourseID)
123
if err != nil {
124
c.JSON(http.StatusNotFound, gin.H{"valid": false})
125
return
126
}
127
128
c.JSON(http.StatusOK, gin.H{
129
"valid": true,
130
"course_title": course.Title,
131
"platform": course.Platform,
132
"level": course.Level,
133
"completed_at": completion.CompletedAt.Format("January 2, 2006"),
134
})
135
}
  • Step 2: Update main.go — wire certDir and new constructor

In backend/cmd/learn-api/main.go, after the learnSvc line (line 83), add:

go
1
// Certificate PDF storage
2
certDir := "data/certificates"
3
if d := os.Getenv("CERT_STORAGE_DIR"); d != "" {
4
certDir = d
5
}
6
if err := os.MkdirAll(certDir, 0o755); err != nil {
7
log.Fatalf("failed to create certificate storage dir: %v", err)
8
}
9
10
certBaseURL := os.Getenv("CERT_BASE_URL")
11
if certBaseURL == "" {
12
certBaseURL = fmt.Sprintf("http://localhost:%d", cfg.Port)
13
}
14
certGen := learnService.NewCertificateGenerator(certDir, certBaseURL)
15
learnSvc.SetCertificateGenerator(certGen)

Update the NewCertificateHandler call (line 116) to pass certDir:

go
1
certH := learnHandler.NewCertificateHandler(learnSvc, completionRepo, enrollmentRepo, courseRepo, certDir)
  • Step 3: Verify build
bash
1
cd /home/vchavkov/src/assistance/backend && go build ./...

Expected: No errors.

  • Step 4: Commit
bash
1
cd /home/vchavkov/src/assistance/backend && git add internal/learn/handler/certificate_handler.go cmd/learn-api/main.go && git commit -m "feat(learn): add certificate download and verify endpoints"

Task 5: Update frontend certificates page#

Files:

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

  • Step 1: Update download link

In apps/learn/app/(protected)/certificates/page.tsx, replace the <a> tag (lines 74-81) with:

tsx
1
<a
2
href={`${process.env.NEXT_PUBLIC_LEARN_API_URL || 'http://localhost:3065'}/api/v1/certificates/${enrollment.id}/download`}
3
download
4
className="inline-flex items-center gap-1.5 rounded-md bg-brand-500/10 px-3 py-1.5 text-xs font-medium text-brand-500 hover:bg-brand-500/20 transition-colors"
5
>
6
<Download className="h-3.5 w-3.5" />
7
Download PDF
8
</a>

Note: The download link uses enrollment.id because the completion ID comes from the enrollment in the current data flow. The backend's GET /completions/:enrollmentId returns the completion with its ID, so we need to fetch the completion ID first. Actually, looking at the current flow, the certificates page uses enrollment.id — but the download endpoint expects a completion ID. We need to fetch the completion for each enrollment.

Let me revise — add a helper to fetch completion IDs:

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

tsx
1
'use client'
2
3
import { useEnrollments } from '@/hooks/use-learn-api'
4
import { Award, Download, ArrowRight } from 'lucide-react'
5
import Link from 'next/link'
6
import { useEffect, useState } from 'react'
7
import { get } from 'common'
8
import { Skeleton } from 'ui'
9
import { EmptyStatePresentational } from 'ui-patterns'
10
11
const API_URL = process.env.NEXT_PUBLIC_LEARN_API_URL || 'http://localhost:3065'
12
13
export default function CertificatesPage() {
14
const { enrollments, loading } = useEnrollments()
15
const completed = enrollments.filter((e) => e.status === 'completed')
16
const [completionMap, setCompletionMap] = useState<Record<string, string>>({})
17
18
useEffect(() => {
19
completed.forEach((enrollment) => {
20
get(`${API_URL}/api/v1/completions/${enrollment.id}`)
21
.then((res: { id: string }) => {
22
if (res.id) {
23
setCompletionMap((prev) => ({ ...prev, [enrollment.id]: res.id }))
24
}
25
})
26
.catch(() => {})
27
})
28
}, [completed.length])
29
30
return (
31
<div className="mx-auto max-w-4xl px-6 py-12">
32
<div className="mb-8">
33
<h1 className="text-2xl font-bold text-foreground">Certificates</h1>
34
<p className="mt-1 text-sm text-foreground-light">
35
Download certificates for your completed courses.
36
</p>
37
</div>
38
39
{loading && (
40
<div className="space-y-3">
41
{[1, 2].map((i) => (
42
<div key={i} className="flex items-center gap-3 rounded-lg border bg-surface-75 p-4">
43
<Skeleton className="h-10 w-10 rounded-lg" />
44
<div className="flex-1 space-y-2">
45
<Skeleton className="h-4 w-48" />
46
<Skeleton className="h-3 w-32" />
47
</div>
48
</div>
49
))}
50
</div>
51
)}
52
53
{!loading && completed.length === 0 && (
54
<EmptyStatePresentational
55
icon={Award}
56
title="No certificates yet"
57
description="Complete a course to earn your certificate."
58
>
59
<Link
60
href="/trainings"
61
className="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600 transition-colors"
62
>
63
Browse courses
64
</Link>
65
</EmptyStatePresentational>
66
)}
67
68
{!loading && completed.length > 0 && (
69
<div className="space-y-3">
70
{completed.map((enrollment) => {
71
const completionId = completionMap[enrollment.id]
72
return (
73
<div
74
key={enrollment.id}
75
className="flex items-center justify-between rounded-lg border bg-surface-75 p-4"
76
>
77
<div className="flex items-center gap-3">
78
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/10">
79
<Award className="h-5 w-5 text-brand-500" />
80
</div>
81
<div>
82
<p className="text-sm font-medium text-foreground">
83
{enrollment.course?.title ?? 'Course'}
84
</p>
85
<p className="text-xs text-foreground-muted">
86
{enrollment.course?.platform && (
87
<span className="capitalize">{enrollment.course.platform}</span>
88
)}
89
</p>
90
</div>
91
</div>
92
<div className="flex items-center gap-3">
93
{completionId ? (
94
<a
95
href={`${API_URL}/api/v1/certificates/${completionId}/download`}
96
download
97
className="inline-flex items-center gap-1.5 rounded-md bg-brand-500/10 px-3 py-1.5 text-xs font-medium text-brand-500 hover:bg-brand-500/20 transition-colors"
98
>
99
<Download className="h-3.5 w-3.5" />
100
Download PDF
101
</a>
102
) : (
103
<span className="text-xs text-foreground-muted">Loading...</span>
104
)}
105
<Link
106
href={`/courses/${enrollment.course?.slug ?? enrollment.course_id}`}
107
className="text-xs text-foreground-muted hover:text-foreground transition-colors"
108
>
109
View course <ArrowRight className="inline h-3 w-3" />
110
</Link>
111
</div>
112
</div>
113
)
114
})}
115
</div>
116
)}
117
</div>
118
)
119
}
  • Step 2: Verify build
bash
1
cd /home/vchavkov/src/assistance/apps/learn && pnpm build 2>&1 | tail -5

Expected: Build succeeds.

  • Step 3: Commit
bash
1
cd /home/vchavkov/src/assistance && git add apps/learn/app/\(protected\)/certificates/page.tsx && git commit -m "feat(learn): update certificates page to download PDF via completion ID"

Task 6: Create public verification page#

Files:

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

  • Step 1: Create the verification page

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

tsx
1
import { Metadata } from 'next'
2
import { VerifyClient } from './verify-client'
3
4
export const metadata: Metadata = {
5
title: 'Verify Certificate — Assistance Academy',
6
description: 'Verify the authenticity of a training certificate.',
7
}
8
9
export default function VerifyPage({ params }: { params: { id: string } }) {
10
return <VerifyClient certificateId={params.id} />
11
}

Create apps/learn/app/(course)/verify/[id]/verify-client.tsx:

tsx
1
'use client'
2
3
import { useEffect, useState } from 'react'
4
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
5
6
const API_URL = process.env.NEXT_PUBLIC_LEARN_API_URL || 'http://localhost:3065'
7
8
interface VerifyResult {
9
valid: boolean
10
course_title?: string
11
platform?: string
12
level?: string
13
completed_at?: string
14
}
15
16
export function VerifyClient({ certificateId }: { certificateId: string }) {
17
const [result, setResult] = useState<VerifyResult | null>(null)
18
const [loading, setLoading] = useState(true)
19
20
useEffect(() => {
21
fetch(`${API_URL}/api/v1/certificates/${encodeURIComponent(certificateId)}/verify`)
22
.then((res) => res.json())
23
.then((data: VerifyResult) => setResult(data))
24
.catch(() => setResult({ valid: false }))
25
.finally(() => setLoading(false))
26
}, [certificateId])
27
28
if (loading) {
29
return (
30
<div className="flex min-h-[60vh] items-center justify-center">
31
<Loader2 className="h-8 w-8 animate-spin text-foreground-muted" />
32
</div>
33
)
34
}
35
36
if (!result?.valid) {
37
return (
38
<div className="flex min-h-[60vh] items-center justify-center">
39
<div className="text-center">
40
<XCircle className="mx-auto h-12 w-12 text-red-500 mb-4" />
41
<h1 className="text-xl font-bold text-foreground">Invalid Certificate</h1>
42
<p className="mt-2 text-sm text-foreground-muted">
43
This certificate could not be verified. It may not exist or the ID is incorrect.
44
</p>
45
</div>
46
</div>
47
)
48
}
49
50
return (
51
<div className="flex min-h-[60vh] items-center justify-center">
52
<div className="max-w-md rounded-xl border bg-surface-75 p-8 text-center">
53
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
54
<h1 className="text-xl font-bold text-foreground">Valid Certificate</h1>
55
<div className="mt-6 space-y-3 text-left">
56
<div className="flex justify-between border-b pb-2">
57
<span className="text-sm text-foreground-muted">Course</span>
58
<span className="text-sm font-medium text-foreground">{result.course_title}</span>
59
</div>
60
<div className="flex justify-between border-b pb-2">
61
<span className="text-sm text-foreground-muted">Platform</span>
62
<span className="text-sm font-medium text-foreground capitalize">{result.platform}</span>
63
</div>
64
<div className="flex justify-between border-b pb-2">
65
<span className="text-sm text-foreground-muted">Level</span>
66
<span className="text-sm font-medium text-foreground capitalize">{result.level}</span>
67
</div>
68
<div className="flex justify-between">
69
<span className="text-sm text-foreground-muted">Completed</span>
70
<span className="text-sm font-medium text-foreground">{result.completed_at}</span>
71
</div>
72
</div>
73
<p className="mt-6 text-xs text-foreground-muted">
74
Certificate ID: {certificateId}
75
</p>
76
</div>
77
</div>
78
)
79
}
  • Step 2: Verify build
bash
1
cd /home/vchavkov/src/assistance/apps/learn && pnpm build 2>&1 | tail -5

Expected: Build succeeds.

  • Step 3: Commit
bash
1
cd /home/vchavkov/src/assistance && git add apps/learn/app/\(course\)/verify/ && git commit -m "feat(learn): add public certificate verification page"

Epic 2: Quiz Hints & Explanations#

Task 7: Add domain fields and repository method#

Files:

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

  • Modify: backend/internal/learn/repository/interfaces.go

  • Modify: backend/internal/learn/repository/quiz.go

  • Step 1: Add Explanation, Hint, CooldownMinutes fields

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

Add CooldownMinutes to Quiz struct (after line 15):

go
1
CooldownMinutes int `gorm:"default:0" json:"cooldown_minutes"`

Add Explanation to Question struct (after line 27):

go
1
Explanation string `gorm:"type:text;default:''" json:"explanation,omitempty"`

Add Hint to Option struct (after line 36):

go
1
Hint string `gorm:"type:text;default:''" json:"hint,omitempty"`
  • Step 2: Add GetLastAttempt to QuizRepository interface

In backend/internal/learn/repository/interfaces.go, add to the QuizRepository interface (after line 74):

go
1
GetLastAttempt(quizID, userID uuid.UUID) (*domain.QuizAttempt, error)
  • Step 3: Implement GetLastAttempt

In backend/internal/learn/repository/quiz.go, add after the GetAttemptsByUser method:

go
1
func (r *quizRepository) GetLastAttempt(quizID, userID uuid.UUID) (*domain.QuizAttempt, error) {
2
var attempt domain.QuizAttempt
3
err := r.db.Where("quiz_id = ? AND user_id = ?", quizID, userID).
4
Order("submitted_at DESC").
5
First(&attempt).Error
6
if err != nil {
7
return nil, err
8
}
9
return &attempt, nil
10
}
  • Step 4: Verify build
bash
1
cd /home/vchavkov/src/assistance/backend && go build ./...

Expected: No errors.

  • Step 5: Commit
bash
1
cd /home/vchavkov/src/assistance/backend && git add internal/learn/domain/quiz.go internal/learn/repository/interfaces.go internal/learn/repository/quiz.go && git commit -m "feat(learn): add quiz explanation, hint, and cooldown fields"

Task 8: Add cooldown check and explanations to quiz submit handler#

Files:

  • Modify: backend/internal/learn/handler/quiz_handler.go

  • Step 1: Replace submitQuiz method

Replace the submitQuiz method in backend/internal/learn/handler/quiz_handler.go (lines 87-172) with:

go
1
func (h *QuizHandler) submitQuiz(c *gin.Context) {
2
userID, err := getUserID(c)
3
if err != nil {
4
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
5
return
6
}
7
8
quizID, err := uuid.Parse(c.Param("id"))
9
if err != nil {
10
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid quiz ID"})
11
return
12
}
13
14
var req struct {
15
EnrollmentID string `json:"enrollment_id" binding:"required"`
16
Answers []submitAnswer `json:"answers" binding:"required"`
17
}
18
if err := c.ShouldBindJSON(&req); err != nil {
19
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
20
return
21
}
22
23
enrollmentID, err := uuid.Parse(req.EnrollmentID)
24
if err != nil {
25
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment_id"})
26
return
27
}
28
29
// Load quiz with correct answers
30
quiz, err := h.quizzes.GetByID(quizID)
31
if err != nil {
32
c.JSON(http.StatusNotFound, gin.H{"error": "quiz not found"})
33
return
34
}
35
36
// Cooldown check
37
if quiz.CooldownMinutes > 0 {
38
lastAttempt, err := h.quizzes.GetLastAttempt(quizID, userID)
39
if err == nil && lastAttempt != nil {
40
elapsed := time.Since(lastAttempt.SubmittedAt)
41
cooldown := time.Duration(quiz.CooldownMinutes) * time.Minute
42
if elapsed < cooldown {
43
remaining := int(cooldown.Seconds() - elapsed.Seconds())
44
c.Header("Retry-After", fmt.Sprintf("%d", remaining))
45
c.JSON(http.StatusTooManyRequests, gin.H{
46
"error": "cooldown active",
47
"retry_after_seconds": remaining,
48
})
49
return
50
}
51
}
52
}
53
54
// Build answer lookup: questionID -> selected optionID
55
answerMap := make(map[string]string)
56
for _, ans := range req.Answers {
57
answerMap[ans.QuestionID] = ans.OptionID
58
}
59
60
// Score and build detailed results
61
correct := 0
62
total := len(quiz.Questions)
63
64
type questionResult struct {
65
ID uuid.UUID `json:"id"`
66
Text string `json:"text"`
67
Explanation string `json:"explanation,omitempty"`
68
YourAnswer string `json:"your_answer"`
69
CorrectAnswer string `json:"correct_answer"`
70
IsCorrect bool `json:"is_correct"`
71
Options []optionResult `json:"options"`
72
}
73
type optionResult struct {
74
ID uuid.UUID `json:"id"`
75
Text string `json:"text"`
76
Hint string `json:"hint,omitempty"`
77
IsCorrect bool `json:"is_correct"`
78
}
79
80
questions := make([]questionResult, 0, total)
81
for _, q := range quiz.Questions {
82
yourAnswer := answerMap[q.ID.String()]
83
correctAnswer := ""
84
options := make([]optionResult, 0, len(q.Options))
85
for _, o := range q.Options {
86
if o.IsCorrect {
87
correctAnswer = o.ID.String()
88
}
89
options = append(options, optionResult{
90
ID: o.ID,
91
Text: o.Text,
92
Hint: o.Hint,
93
IsCorrect: o.IsCorrect,
94
})
95
}
96
isCorrect := yourAnswer == correctAnswer
97
if isCorrect {
98
correct++
99
}
100
questions = append(questions, questionResult{
101
ID: q.ID,
102
Text: q.Text,
103
Explanation: q.Explanation,
104
YourAnswer: yourAnswer,
105
CorrectAnswer: correctAnswer,
106
IsCorrect: isCorrect,
107
Options: options,
108
})
109
}
110
111
score := 0
112
if total > 0 {
113
score = (correct * 100) / total
114
}
115
passed := score >= quiz.PassPct
116
117
// Serialize answers
118
answersJSON, _ := json.Marshal(req.Answers)
119
120
attempt := &domain.QuizAttempt{
121
ID: uuid.New(),
122
QuizID: quizID,
123
UserID: userID,
124
EnrollmentID: enrollmentID,
125
Score: score,
126
Passed: passed,
127
Answers: string(answersJSON),
128
SubmittedAt: time.Now(),
129
}
130
131
if err := h.quizzes.CreateAttempt(attempt); err != nil {
132
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save attempt"})
133
return
134
}
135
136
c.JSON(http.StatusOK, gin.H{
137
"score": score,
138
"passed": passed,
139
"correct": correct,
140
"total": total,
141
"questions": questions,
142
})
143
}

Add "fmt" to the imports at the top of the file.

  • Step 2: Also strip explanations and hints from public GET endpoints

In the listQuizzes method, inside the triple-nested loop (lines 48-54), add after clearing IsCorrect:

go
1
quizzes[i].Questions[j].Options[k].Hint = ""

And after the Options loop, add:

go
1
quizzes[i].Questions[j].Explanation = ""

Similarly in getQuiz method (lines 73-77), add after clearing IsCorrect:

go
1
quiz.Questions[j].Options[k].Hint = ""

And after the Options loop:

go
1
quiz.Questions[j].Explanation = ""
  • Step 3: Verify build
bash
1
cd /home/vchavkov/src/assistance/backend && go build ./...

Expected: No errors.

  • Step 4: Commit
bash
1
cd /home/vchavkov/src/assistance/backend && git add internal/learn/handler/quiz_handler.go && git commit -m "feat(learn): add quiz explanations, cooldown check, and enriched submit response"

Task 9: Update frontend quiz result view#

Files:

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

  • Step 1: Update types and result state

In apps/learn/components/quiz/quiz-container.tsx, replace the interfaces (lines 10-29) with:

tsx
1
interface OptionData {
2
id: string
3
text: string
4
order: number
5
hint?: string
6
is_correct?: boolean
7
}
8
9
interface QuestionData {
10
id: string
11
text: string
12
type: string
13
order: number
14
options: OptionData[]
15
explanation?: string
16
}
17
18
interface Quiz {
19
id: string
20
title: string
21
pass_pct: number
22
cooldown_minutes?: number
23
questions: QuestionData[]
24
}
25
26
interface QuestionResult {
27
id: string
28
text: string
29
explanation?: string
30
your_answer: string
31
correct_answer: string
32
is_correct: boolean
33
options: { id: string; text: string; hint?: string; is_correct: boolean }[]
34
}
35
36
interface SubmitResult {
37
score: number
38
passed: boolean
39
correct: number
40
total: number
41
questions?: QuestionResult[]
42
}
  • Step 2: Update result state type and add cooldown state

Replace the state declarations (lines 38-44) with:

tsx
1
const [quizzes, setQuizzes] = useState<Quiz[]>([])
2
const [loading, setLoading] = useState(true)
3
const [activeQuiz, setActiveQuiz] = useState<Quiz | null>(null)
4
const [answers, setAnswers] = useState<Record<string, string>>({})
5
const [submitting, setSubmitting] = useState(false)
6
const [submitError, setSubmitError] = useState<string | null>(null)
7
const [result, setResult] = useState<SubmitResult | null>(null)
8
const [cooldownSeconds, setCooldownSeconds] = useState(0)
  • Step 3: Add cooldown timer effect

After the useEffect that fetches quizzes (after line 51), add:

tsx
1
useEffect(() => {
2
if (cooldownSeconds <= 0) return
3
const timer = setInterval(() => {
4
setCooldownSeconds((prev) => {
5
if (prev <= 1) {
6
clearInterval(timer)
7
return 0
8
}
9
return prev - 1
10
})
11
}, 1000)
12
return () => clearInterval(timer)
13
}, [cooldownSeconds])
  • Step 4: Update handleSubmit to handle cooldown

Replace the handleSubmit function (lines 57-77) with:

tsx
1
const handleSubmit = async () => {
2
if (!activeQuiz || !session?.access_token || !enrollmentId) return
3
setSubmitting(true)
4
setSubmitError(null)
5
try {
6
const res = await post(`${API_URL}/api/v1/quizzes/${activeQuiz.id}/submit`, {
7
enrollment_id: enrollmentId,
8
answers: Object.entries(answers).map(([question_id, option_id]) => ({
9
question_id,
10
option_id,
11
})),
12
})
13
if (res.status === 429) {
14
const data = await res.json()
15
setCooldownSeconds(data.retry_after_seconds || 60)
16
setSubmitError(`Please wait before retrying.`)
17
return
18
}
19
if (!res.ok) throw new Error('Submit failed')
20
const data = await res.json()
21
setResult(data)
22
} catch {
23
setSubmitError('Failed to submit quiz. Please try again.')
24
} finally {
25
setSubmitting(false)
26
}
27
}
  • Step 5: Replace the result view

Replace the result block (lines 81-108) with:

tsx
1
if (result) {
2
return (
3
<div className="mt-8 space-y-6">
4
<div className="rounded-lg border bg-surface-75 p-6 text-center">
5
{result.passed ? (
6
<>
7
<CheckCircle2 className="mx-auto h-10 w-10 text-green-500 mb-3" />
8
<h3 className="text-lg font-semibold text-foreground">Quiz Passed!</h3>
9
</>
10
) : (
11
<>
12
<XCircle className="mx-auto h-10 w-10 text-red-500 mb-3" />
13
<h3 className="text-lg font-semibold text-foreground">Not Quite</h3>
14
</>
15
)}
16
<p className="mt-2 text-sm text-foreground-muted">
17
Score: {result.score}% ({result.correct}/{result.total} correct)
18
</p>
19
{!result.passed && (
20
<button
21
onClick={() => { setResult(null); setAnswers({}); setCooldownSeconds(0) }}
22
disabled={cooldownSeconds > 0}
23
className="mt-4 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600 transition-colors disabled:opacity-50"
24
>
25
{cooldownSeconds > 0
26
? `Retry in ${Math.floor(cooldownSeconds / 60)}:${String(cooldownSeconds % 60).padStart(2, '0')}`
27
: 'Try Again'}
28
</button>
29
)}
30
</div>
31
32
{result.questions && result.questions.length > 0 && (
33
<div className="space-y-4">
34
<h4 className="text-sm font-semibold text-foreground">Question Review</h4>
35
{result.questions.map((q, qi) => (
36
<div key={q.id} className="rounded-lg border bg-surface-75 p-4">
37
<p className="text-sm font-medium text-foreground mb-3">
38
{qi + 1}. {q.text}
39
{q.is_correct ? (
40
<CheckCircle2 className="inline ml-2 h-4 w-4 text-green-500" />
41
) : (
42
<XCircle className="inline ml-2 h-4 w-4 text-red-500" />
43
)}
44
</p>
45
<div className="space-y-1.5 mb-3">
46
{q.options.map((opt) => {
47
let borderClass = 'border-transparent'
48
if (opt.id === q.correct_answer) borderClass = 'border-green-500 bg-green-500/5'
49
else if (opt.id === q.your_answer && !q.is_correct) borderClass = 'border-red-500 bg-red-500/5'
50
51
return (
52
<div key={opt.id} className={`rounded-lg border p-2.5 text-sm ${borderClass}`}>
53
<span className="text-foreground">{opt.text}</span>
54
{opt.id === q.your_answer && <span className="ml-2 text-xs text-foreground-muted">(your answer)</span>}
55
{opt.is_correct && <span className="ml-2 text-xs text-green-600">(correct)</span>}
56
{opt.hint && opt.id === q.your_answer && !q.is_correct && (
57
<p className="mt-1 text-xs text-foreground-muted italic">{opt.hint}</p>
58
)}
59
</div>
60
)
61
})}
62
</div>
63
{q.explanation && (
64
<div className="rounded-md bg-blue-500/5 border border-blue-500/20 p-3">
65
<p className="text-xs text-foreground-muted">
66
<span className="font-medium text-blue-600">Explanation:</span> {q.explanation}
67
</p>
68
</div>
69
)}
70
</div>
71
))}
72
</div>
73
)}
74
</div>
75
)
76
}
  • Step 6: Verify build
bash
1
cd /home/vchavkov/src/assistance/apps/learn && pnpm build 2>&1 | tail -5

Expected: Build succeeds.

  • Step 7: Commit
bash
1
cd /home/vchavkov/src/assistance && git add apps/learn/components/quiz/quiz-container.tsx && git commit -m "feat(learn): show quiz explanations, correct answers, and cooldown in result view"

Verification#

After all tasks are complete, run the following checks:

  • Backend builds: cd backend && go build ./...
  • Backend tests pass: cd backend && go test ./internal/learn/service/ -v
  • Frontend builds: cd apps/learn && pnpm build
  • Manual test E1: Start learn-api + complete a course → check data/certificates/ for PDF → hit /api/v1/certificates/{id}/download → verify PDF opens → hit /api/v1/certificates/{id}/verify → verify JSON response
  • Manual test E2: Submit a quiz → verify response includes questions array with explanations → submit again within cooldown → verify 429 response