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#
Modified Files#
Epic 1: Certificate PDF Generation#
Task 1: Add Go dependencies#
Files:
-
Modify:
backend/go.mod -
Step 1: Add fpdf and go-qrcode
1cd /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
1cd /home/vchavkov/src/assistance/backend && grep -E "fpdf|qrcode" go.modExpected: Two lines showing github.com/go-pdf/fpdf and github.com/skip2/go-qrcode.
- Step 3: Tidy
1cd /home/vchavkov/src/assistance/backend && go mod tidy- Step 4: Commit
1cd /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:
1package service23import (4 "os"5 "path/filepath"6 "testing"7 "time"89 "github.com/assistance/backend/internal/learn/domain"10 "github.com/google/uuid"11)1213func TestGenerateCertificatePDF(t *testing.T) {14 dir := t.TempDir()15 gen := NewCertificateGenerator(dir, "http://localhost:3065")1617 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"2829 path, err := gen.Generate(completion, course, learnerName)30 if err != nil {31 t.Fatalf("Generate() error: %v", err)32 }3334 // File must exist35 if _, err := os.Stat(path); os.IsNotExist(err) {36 t.Fatalf("PDF file not created at %s", path)37 }3839 // File must be non-empty40 info, _ := os.Stat(path)41 if info.Size() < 100 {42 t.Fatalf("PDF file too small: %d bytes", info.Size())43 }4445 // Path must match expected pattern46 expected := filepath.Join(dir, completion.ID.String()+".pdf")47 if path != expected {48 t.Fatalf("path = %s, want %s", path, expected)49 }50}5152func TestGenerateCertificatePDF_CreatesDir(t *testing.T) {53 dir := filepath.Join(t.TempDir(), "nested", "certs")54 gen := NewCertificateGenerator(dir, "http://localhost:3065")5556 completion := &domain.Completion{57 ID: uuid.New(),58 CompletedAt: time.Now(),59 }60 course := &domain.Course{Title: "Test", Platform: "test", Level: "beginner"}6162 _, 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
1cd /home/vchavkov/src/assistance/backend && go test ./internal/learn/service/ -run TestGenerateCertificatePDF -vExpected: FAIL — NewCertificateGenerator undefined.
- Step 3: Write the implementation
Create backend/internal/learn/service/certificate.go:
1package service23import (4 "fmt"5 "io"6 "os"7 "path/filepath"89 "github.com/assistance/backend/internal/learn/domain"10 "github.com/go-pdf/fpdf"11 qrcode "github.com/skip2/go-qrcode"12)1314// CertificateGenerator creates PDF certificates.15type CertificateGenerator struct {16 storageDir string17 baseURL string18}1920func NewCertificateGenerator(storageDir, baseURL string) *CertificateGenerator {21 return &CertificateGenerator{storageDir: storageDir, baseURL: baseURL}22}2324// Generate creates a PDF certificate and returns the file path.25func (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 }2930 pdf := fpdf.New("L", "mm", "A4", "")31 pdf.SetAutoPageBreak(false, 0)32 pdf.AddPage()3334 pageW, pageH := pdf.GetPageSize()3536 // Background border37 pdf.SetDrawColor(0, 153, 99) // brand green38 pdf.SetLineWidth(2)39 pdf.Rect(10, 10, pageW-20, pageH-20, "D")4041 // Inner border42 pdf.SetLineWidth(0.5)43 pdf.Rect(15, 15, pageW-30, pageH-30, "D")4445 // Title46 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, "")5051 // Decorative line52 pdf.SetDrawColor(0, 153, 99)53 pdf.SetLineWidth(0.8)54 lineY := 55.055 pdf.Line(pageW/2-60, lineY, pageW/2+60, lineY)5657 // "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, "")6263 // Learner name64 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, "")6869 // "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, "")7475 // Course title76 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, "")8081 // Platform + Level82 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, "")8687 // Completion date88 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, "")9192 // 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, "")9798 // 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.0105 pdf.ImageOptions("qr", pageW-25-qrSize, pageH-25-qrSize, qrSize, qrSize, false, opt, 0, "")106 }107108 // Write file109 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 }113114 return outPath, nil115}116117type byteReaderWrapper struct {118 data []byte119 pos int120}121122func byteReader(data []byte) *byteReaderWrapper {123 return &byteReaderWrapper{data: data}124}125126func (r *byteReaderWrapper) Read(p []byte) (int, error) {127 if r.pos >= len(r.data) {128 return 0, io.EOF129 }130 n := copy(p, r.data[r.pos:])131 r.pos += n132 return n, nil133}- Step 4: Run tests to verify they pass
1cd /home/vchavkov/src/assistance/backend && go test ./internal/learn/service/ -run TestGenerateCertificatePDF -vExpected: PASS (both tests).
- Step 5: Commit
1cd /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:
1type LearnService struct {2 courses repository.CourseRepository3 modules repository.ModuleRepository4 lessons repository.LessonRepository5 enrollments repository.EnrollmentRepository6 progress repository.ProgressRepository7 completions repository.CompletionRepository8 certGen *CertificateGenerator9}1011func 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}2829// SetCertificateGenerator configures PDF certificate generation.30// If not set, certificates are created without a PDF file.31func (s *LearnService) SetCertificateGenerator(gen *CertificateGenerator) {32 s.certGen = gen33}- Step 2: Update CompleteEnrollment to generate PDF
Replace the CompleteEnrollment method (lines 179-204) with:
1func (s *LearnService) CompleteEnrollment(enrollmentID uuid.UUID) (*domain.Completion, error) {2 existing, _ := s.completions.GetByEnrollment(enrollmentID)3 if existing != nil {4 return existing, nil5 }67 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, err16 }1718 // Update enrollment status to completed19 enrollment, err := s.enrollments.GetByID(enrollmentID)20 if err == nil && enrollment != nil {21 enrollment.Status = "completed"22 _ = s.enrollments.Update(enrollment)23 }2425 // 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 service30 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 later33 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 }4041 return completion, nil42}Add "fmt" to the imports at the top of the file (line 3-11).
- Step 3: Verify build
1cd /home/vchavkov/src/assistance/backend && go build ./...Expected: No errors.
- Step 4: Commit
1cd /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:
1package handler23import (4 "net/http"5 "os"6 "path/filepath"78 "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)1314type CertificateHandler struct {15 svc *service.LearnService16 completions repository.CompletionRepository17 enrollments repository.EnrollmentRepository18 courses repository.CourseRepository19 certDir string20}2122func 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}3738func (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}4344func (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 return49 }5051 completion, err := h.completions.GetByID(id)52 if err != nil {53 c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})54 return55 }5657 enrollment, err := h.enrollments.GetByID(completion.EnrollmentID)58 if err != nil {59 c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})60 return61 }6263 course, err := h.courses.GetByID(enrollment.CourseID)64 if err != nil {65 c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})66 return67 }6869 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}7980func (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 return85 }8687 // Verify completion exists88 if _, err := h.completions.GetByID(id); err != nil {89 c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})90 return91 }9293 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 return97 }9899 c.Header("Content-Disposition", "attachment; filename=\"certificate-"+id.String()+".pdf\"")100 c.File(pdfPath)101}102103func (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 return108 }109110 completion, err := h.completions.GetByID(id)111 if err != nil {112 c.JSON(http.StatusNotFound, gin.H{"valid": false})113 return114 }115116 enrollment, err := h.enrollments.GetByID(completion.EnrollmentID)117 if err != nil {118 c.JSON(http.StatusNotFound, gin.H{"valid": false})119 return120 }121122 course, err := h.courses.GetByID(enrollment.CourseID)123 if err != nil {124 c.JSON(http.StatusNotFound, gin.H{"valid": false})125 return126 }127128 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:
1// Certificate PDF storage2 certDir := "data/certificates"3 if d := os.Getenv("CERT_STORAGE_DIR"); d != "" {4 certDir = d5 }6 if err := os.MkdirAll(certDir, 0o755); err != nil {7 log.Fatalf("failed to create certificate storage dir: %v", err)8 }910 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:
1certH := learnHandler.NewCertificateHandler(learnSvc, completionRepo, enrollmentRepo, courseRepo, certDir)- Step 3: Verify build
1cd /home/vchavkov/src/assistance/backend && go build ./...Expected: No errors.
- Step 4: Commit
1cd /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:
1<a2 href={`${process.env.NEXT_PUBLIC_LEARN_API_URL || 'http://localhost:3065'}/api/v1/certificates/${enrollment.id}/download`}3 download4 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 PDF8 </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:
1'use client'23import { useEnrollments } from '@/hooks/use-learn-api'4import { Award, Download, ArrowRight } from 'lucide-react'5import Link from 'next/link'6import { useEffect, useState } from 'react'7import { get } from 'common'8import { Skeleton } from 'ui'9import { EmptyStatePresentational } from 'ui-patterns'1011const API_URL = process.env.NEXT_PUBLIC_LEARN_API_URL || 'http://localhost:3065'1213export 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>>({})1718 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])2930 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>3839 {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 )}5253 {!loading && completed.length === 0 && (54 <EmptyStatePresentational55 icon={Award}56 title="No certificates yet"57 description="Complete a course to earn your certificate."58 >59 <Link60 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 courses64 </Link>65 </EmptyStatePresentational>66 )}6768 {!loading && completed.length > 0 && (69 <div className="space-y-3">70 {completed.map((enrollment) => {71 const completionId = completionMap[enrollment.id]72 return (73 <div74 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 <a95 href={`${API_URL}/api/v1/certificates/${completionId}/download`}96 download97 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 PDF101 </a>102 ) : (103 <span className="text-xs text-foreground-muted">Loading...</span>104 )}105 <Link106 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
1cd /home/vchavkov/src/assistance/apps/learn && pnpm build 2>&1 | tail -5Expected: Build succeeds.
- Step 3: Commit
1cd /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:
1import { Metadata } from 'next'2import { VerifyClient } from './verify-client'34export const metadata: Metadata = {5 title: 'Verify Certificate — Assistance Academy',6 description: 'Verify the authenticity of a training certificate.',7}89export default function VerifyPage({ params }: { params: { id: string } }) {10 return <VerifyClient certificateId={params.id} />11}Create apps/learn/app/(course)/verify/[id]/verify-client.tsx:
1'use client'23import { useEffect, useState } from 'react'4import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'56const API_URL = process.env.NEXT_PUBLIC_LEARN_API_URL || 'http://localhost:3065'78interface VerifyResult {9 valid: boolean10 course_title?: string11 platform?: string12 level?: string13 completed_at?: string14}1516export function VerifyClient({ certificateId }: { certificateId: string }) {17 const [result, setResult] = useState<VerifyResult | null>(null)18 const [loading, setLoading] = useState(true)1920 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])2728 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 }3536 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 }4950 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
1cd /home/vchavkov/src/assistance/apps/learn && pnpm build 2>&1 | tail -5Expected: Build succeeds.
- Step 3: Commit
1cd /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):
1CooldownMinutes int `gorm:"default:0" json:"cooldown_minutes"`Add Explanation to Question struct (after line 27):
1Explanation string `gorm:"type:text;default:''" json:"explanation,omitempty"`Add Hint to Option struct (after line 36):
1Hint 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):
1GetLastAttempt(quizID, userID uuid.UUID) (*domain.QuizAttempt, error)- Step 3: Implement GetLastAttempt
In backend/internal/learn/repository/quiz.go, add after the GetAttemptsByUser method:
1func (r *quizRepository) GetLastAttempt(quizID, userID uuid.UUID) (*domain.QuizAttempt, error) {2 var attempt domain.QuizAttempt3 err := r.db.Where("quiz_id = ? AND user_id = ?", quizID, userID).4 Order("submitted_at DESC").5 First(&attempt).Error6 if err != nil {7 return nil, err8 }9 return &attempt, nil10}- Step 4: Verify build
1cd /home/vchavkov/src/assistance/backend && go build ./...Expected: No errors.
- Step 5: Commit
1cd /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:
1func (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 return6 }78 quizID, err := uuid.Parse(c.Param("id"))9 if err != nil {10 c.JSON(http.StatusBadRequest, gin.H{"error": "invalid quiz ID"})11 return12 }1314 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 return21 }2223 enrollmentID, err := uuid.Parse(req.EnrollmentID)24 if err != nil {25 c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment_id"})26 return27 }2829 // Load quiz with correct answers30 quiz, err := h.quizzes.GetByID(quizID)31 if err != nil {32 c.JSON(http.StatusNotFound, gin.H{"error": "quiz not found"})33 return34 }3536 // Cooldown check37 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.Minute42 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 return50 }51 }52 }5354 // Build answer lookup: questionID -> selected optionID55 answerMap := make(map[string]string)56 for _, ans := range req.Answers {57 answerMap[ans.QuestionID] = ans.OptionID58 }5960 // Score and build detailed results61 correct := 062 total := len(quiz.Questions)6364 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 }7980 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 == correctAnswer97 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 }110111 score := 0112 if total > 0 {113 score = (correct * 100) / total114 }115 passed := score >= quiz.PassPct116117 // Serialize answers118 answersJSON, _ := json.Marshal(req.Answers)119120 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 }130131 if err := h.quizzes.CreateAttempt(attempt); err != nil {132 c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save attempt"})133 return134 }135136 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:
1quizzes[i].Questions[j].Options[k].Hint = ""And after the Options loop, add:
1quizzes[i].Questions[j].Explanation = ""Similarly in getQuiz method (lines 73-77), add after clearing IsCorrect:
1quiz.Questions[j].Options[k].Hint = ""And after the Options loop:
1quiz.Questions[j].Explanation = ""- Step 3: Verify build
1cd /home/vchavkov/src/assistance/backend && go build ./...Expected: No errors.
- Step 4: Commit
1cd /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:
1interface OptionData {2 id: string3 text: string4 order: number5 hint?: string6 is_correct?: boolean7}89interface QuestionData {10 id: string11 text: string12 type: string13 order: number14 options: OptionData[]15 explanation?: string16}1718interface Quiz {19 id: string20 title: string21 pass_pct: number22 cooldown_minutes?: number23 questions: QuestionData[]24}2526interface QuestionResult {27 id: string28 text: string29 explanation?: string30 your_answer: string31 correct_answer: string32 is_correct: boolean33 options: { id: string; text: string; hint?: string; is_correct: boolean }[]34}3536interface SubmitResult {37 score: number38 passed: boolean39 correct: number40 total: number41 questions?: QuestionResult[]42}- Step 2: Update result state type and add cooldown state
Replace the state declarations (lines 38-44) with:
1const [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:
1useEffect(() => {2 if (cooldownSeconds <= 0) return3 const timer = setInterval(() => {4 setCooldownSeconds((prev) => {5 if (prev <= 1) {6 clearInterval(timer)7 return 08 }9 return prev - 110 })11 }, 1000)12 return () => clearInterval(timer)13 }, [cooldownSeconds])- Step 4: Update handleSubmit to handle cooldown
Replace the handleSubmit function (lines 57-77) with:
1const handleSubmit = async () => {2 if (!activeQuiz || !session?.access_token || !enrollmentId) return3 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 return18 }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:
1if (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 <button21 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 > 026 ? `Retry in ${Math.floor(cooldownSeconds / 60)}:${String(cooldownSeconds % 60).padStart(2, '0')}`27 : 'Try Again'}28 </button>29 )}30 </div>3132 {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'5051 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
1cd /home/vchavkov/src/assistance/apps/learn && pnpm build 2>&1 | tail -5Expected: Build succeeds.
- Step 7: Commit
1cd /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
questionsarray with explanations → submit again within cooldown → verify 429 response