From 8fb1908617ee47ff016c285e9b16e2218c61a194 Mon Sep 17 00:00:00 2001 From: The Joel Date: Wed, 1 Jul 2026 23:32:38 +0100 Subject: [PATCH 1/6] feat: Connect certificate service to progress tracking system (#761) - Add CourseProgressSchema for tracking individual course completion - Create certificate-service.ts with validateCourseCompletion function - Add /api/certificates/generate endpoint with 403 for incomplete courses - Add comprehensive tests for certificate validation - Add database migration for user_progress table - Update API types to export CourseProgress Certificate generation now validates progress >= 100% before issuing, preventing certificates for incomplete courses. --- .../001_create_user_progress_table.sql | 40 ++++ src/app/api/certificates/generate/route.ts | 109 +++++++++ src/schemas/progress.schema.ts | 11 + .../__tests__/certificate-service.test.ts | 206 ++++++++++++++++++ src/services/certificate-service.ts | 99 +++++++++ src/types/api.ts | 3 +- 6 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 docs/database-migrations/001_create_user_progress_table.sql create mode 100644 src/app/api/certificates/generate/route.ts create mode 100644 src/services/__tests__/certificate-service.test.ts create mode 100644 src/services/certificate-service.ts diff --git a/docs/database-migrations/001_create_user_progress_table.sql b/docs/database-migrations/001_create_user_progress_table.sql new file mode 100644 index 00000000..a0241fe0 --- /dev/null +++ b/docs/database-migrations/001_create_user_progress_table.sql @@ -0,0 +1,40 @@ +-- Migration: Create user_progress table for course completion tracking +-- This table tracks individual user progress per course, enabling certificate generation validation + +CREATE TABLE IF NOT EXISTS user_progress ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + course_id VARCHAR(255) NOT NULL, + progress INTEGER NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100), + completed_lessons TEXT[] DEFAULT '{}', + last_accessed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE (user_id, course_id) +); + +-- Create indexes for common queries +CREATE INDEX IF NOT EXISTS idx_user_progress_user_id ON user_progress(user_id); +CREATE INDEX IF NOT EXISTS idx_user_progress_course_id ON user_progress(course_id); +CREATE INDEX IF NOT EXISTS idx_user_progress_progress ON user_progress(progress); + +-- Add trigger to automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_user_progress_updated_at + BEFORE UPDATE ON user_progress + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Comments for documentation +COMMENT ON TABLE user_progress IS 'Tracks user progress for individual courses, used for certificate generation validation'; +COMMENT ON COLUMN user_progress.progress IS 'Overall course progress percentage (0-100)'; +COMMENT ON COLUMN user_progress.completed_lessons IS 'Array of lesson IDs that have been completed'; +COMMENT ON COLUMN user_progress.completed_at IS 'Timestamp when the course was completed (progress reached 100%)'; diff --git a/src/app/api/certificates/generate/route.ts b/src/app/api/certificates/generate/route.ts new file mode 100644 index 00000000..7137a442 --- /dev/null +++ b/src/app/api/certificates/generate/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from 'next/server'; +import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; +import { + generateCertificate, + CertificateServiceError, +} from '@/services/certificate-service'; +import type { ApiResponse } from '@/types/api'; + +export const runtime = 'edge'; + +interface GenerateCertificateRequest { + userId: string; + courseId: string; + userName: string; + courseTitle: string; +} + +export async function POST(request: Request) { + edgeLog('info', '/api/certificates/generate', 'POST request received'); + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) { + return rateLimitResponse; + } + + try { + const body = (await request.json()) as GenerateCertificateRequest; + + // Validate required fields + if (!body.userId || !body.courseId || !body.userName || !body.courseTitle) { + return addHeaders( + NextResponse.json( + { + success: false, + error: 'Missing required fields: userId, courseId, userName, courseTitle', + }, + { status: 400 } + ) + ); + } + + // Generate certificate with progress validation + const certificate = await generateCertificate({ + userId: body.userId, + courseId: body.courseId, + userName: body.userName, + courseTitle: body.courseTitle, + completionDate: new Date().toISOString(), + }); + + return addHeaders( + NextResponse.json({ + success: true, + message: 'Certificate generated successfully', + data: certificate, + }) + ); + } catch (error) { + if (error instanceof CertificateServiceError) { + // Handle specific certificate service errors + if (error.statusCode === 403) { + return addHeaders( + NextResponse.json( + { + success: false, + error: 'Course not completed', + }, + { status: 403 } + ) + ); + } + + if (error.statusCode === 404) { + return addHeaders( + NextResponse.json( + { + success: false, + error: 'Course progress not found', + }, + { status: 404 } + ) + ); + } + + // Other certificate service errors + return addHeaders( + NextResponse.json( + { + success: false, + error: error.message, + }, + { status: error.statusCode } + ) + ); + } + + // Generic error handling + edgeLog('error', '/api/certificates/generate', 'Unexpected error', error); + return addHeaders( + NextResponse.json( + { + success: false, + error: 'Internal server error', + }, + { status: 500 } + ) + ); + } +} diff --git a/src/schemas/progress.schema.ts b/src/schemas/progress.schema.ts index 8446b842..13fc7a51 100644 --- a/src/schemas/progress.schema.ts +++ b/src/schemas/progress.schema.ts @@ -10,3 +10,14 @@ export const UserProgressSchema = z.object({ }); export type UserProgress = z.infer; + +export const CourseProgressSchema = z.object({ + userId: z.string().min(1), + courseId: z.string().min(1), + progress: z.number().min(0).max(100), + completedLessons: z.array(z.string()), + lastAccessedAt: z.string(), + completedAt: z.string().nullable(), +}); + +export type CourseProgress = z.infer; diff --git a/src/services/__tests__/certificate-service.test.ts b/src/services/__tests__/certificate-service.test.ts new file mode 100644 index 00000000..a68f3f77 --- /dev/null +++ b/src/services/__tests__/certificate-service.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + validateCourseCompletion, + generateCertificate, + CertificateServiceError, +} from '../certificate-service'; + +vi.mock('@/lib/db/pool', () => ({ + query: vi.fn(), +})); + +describe('Certificate Service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('validateCourseCompletion', () => { + it('should return progress data when course is completed (100% progress)', async () => { + const { query } = await import('@/lib/db/pool'); + vi.mocked(query).mockResolvedValue({ + rows: [ + { + user_id: 'user-123', + course_id: 'course-456', + progress: 100, + completed_lessons: ['lesson-1', 'lesson-2', 'lesson-3'], + last_accessed_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + }, + ], + } as any); + + const result = await validateCourseCompletion('user-123', 'course-456'); + + expect(result).toEqual({ + userId: 'user-123', + courseId: 'course-456', + progress: 100, + completedLessons: ['lesson-1', 'lesson-2', 'lesson-3'], + lastAccessedAt: expect.any(String), + completedAt: expect.any(String), + }); + }); + + it('should throw 403 error when course progress is below 100%', async () => { + const { query } = await import('@/lib/db/pool'); + vi.mocked(query).mockResolvedValue({ + rows: [ + { + user_id: 'user-123', + course_id: 'course-456', + progress: 75, + completed_lessons: ['lesson-1', 'lesson-2'], + last_accessed_at: new Date().toISOString(), + completed_at: null, + }, + ], + } as any); + + await expect(validateCourseCompletion('user-123', 'course-456')).rejects.toThrow( + CertificateServiceError + ); + + try { + await validateCourseCompletion('user-123', 'course-456'); + } catch (error) { + expect(error).toBeInstanceOf(CertificateServiceError); + if (error instanceof CertificateServiceError) { + expect(error.statusCode).toBe(403); + expect(error.code).toBe('COURSE_NOT_COMPLETED'); + expect(error.message).toBe('Course not completed'); + } + } + }); + + it('should throw 403 error when course progress is 0%', async () => { + const { query } = await import('@/lib/db/pool'); + vi.mocked(query).mockResolvedValue({ + rows: [ + { + user_id: 'user-123', + course_id: 'course-456', + progress: 0, + completed_lessons: [], + last_accessed_at: new Date().toISOString(), + completed_at: null, + }, + ], + } as any); + + await expect(validateCourseCompletion('user-123', 'course-456')).rejects.toThrow( + CertificateServiceError + ); + + try { + await validateCourseCompletion('user-123', 'course-456'); + } catch (error) { + expect(error).toBeInstanceOf(CertificateServiceError); + if (error instanceof CertificateServiceError) { + expect(error.statusCode).toBe(403); + expect(error.code).toBe('COURSE_NOT_COMPLETED'); + } + } + }); + + it('should throw 404 error when progress record not found', async () => { + const { query } = await import('@/lib/db/pool'); + vi.mocked(query).mockResolvedValue({ + rows: [], + } as any); + + await expect(validateCourseCompletion('user-123', 'course-456')).rejects.toThrow( + CertificateServiceError + ); + + try { + await validateCourseCompletion('user-123', 'course-456'); + } catch (error) { + expect(error).toBeInstanceOf(CertificateServiceError); + if (error instanceof CertificateServiceError) { + expect(error.statusCode).toBe(404); + expect(error.code).toBe('PROGRESS_NOT_FOUND'); + expect(error.message).toBe('Course progress not found'); + } + } + }); + + it('should throw 500 error on database query failure', async () => { + const { query } = await import('@/lib/db/pool'); + vi.mocked(query).mockRejectedValue(new Error('Database connection failed')); + + await expect(validateCourseCompletion('user-123', 'course-456')).rejects.toThrow( + CertificateServiceError + ); + + try { + await validateCourseCompletion('user-123', 'course-456'); + } catch (error) { + expect(error).toBeInstanceOf(CertificateServiceError); + if (error instanceof CertificateServiceError) { + expect(error.statusCode).toBe(500); + expect(error.code).toBe('VALIDATION_ERROR'); + } + } + }); + }); + + describe('generateCertificate', () => { + it('should generate certificate when course is completed', async () => { + const { query } = await import('@/lib/db/pool'); + vi.mocked(query).mockResolvedValue({ + rows: [ + { + user_id: 'user-123', + course_id: 'course-456', + progress: 100, + completed_lessons: ['lesson-1', 'lesson-2', 'lesson-3'], + last_accessed_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + }, + ], + } as any); + + const certificateData = { + userId: 'user-123', + courseId: 'course-456', + userName: 'John Doe', + courseTitle: 'Introduction to Programming', + completionDate: new Date().toISOString(), + }; + + const result = await generateCertificate(certificateData); + + expect(result).toEqual({ + ...certificateData, + completionDate: expect.any(String), + }); + }); + + it('should throw error when course is not completed', async () => { + const { query } = await import('@/lib/db/pool'); + vi.mocked(query).mockResolvedValue({ + rows: [ + { + user_id: 'user-123', + course_id: 'course-456', + progress: 50, + completed_lessons: ['lesson-1'], + last_accessed_at: new Date().toISOString(), + completed_at: null, + }, + ], + } as any); + + const certificateData = { + userId: 'user-123', + courseId: 'course-456', + userName: 'John Doe', + courseTitle: 'Introduction to Programming', + completionDate: new Date().toISOString(), + }; + + await expect(generateCertificate(certificateData)).rejects.toThrow(CertificateServiceError); + }); + }); +}); diff --git a/src/services/certificate-service.ts b/src/services/certificate-service.ts new file mode 100644 index 00000000..b4ddd302 --- /dev/null +++ b/src/services/certificate-service.ts @@ -0,0 +1,99 @@ +import { query } from '@/lib/db/pool'; +import type { CourseProgress } from '@/schemas/progress.schema'; + +/** + * Certificate Service + * Handles certificate generation with course completion validation + */ + +export interface CertificateData { + userId: string; + courseId: string; + userName: string; + courseTitle: string; + completionDate: string; +} + +export class CertificateServiceError extends Error { + constructor( + message: string, + public statusCode: number = 500, + public code: string = 'CERTIFICATE_ERROR' + ) { + super(message); + this.name = 'CertificateServiceError'; + } +} + +/** + * Validates that a user has completed a course by checking their progress + * @param userId - The user's ID + * @param courseId - The course's ID + * @returns The course progress data if found + * @throws CertificateServiceError with 403 if course is not completed + * @throws CertificateServiceError with 404 if progress record not found + */ +export async function validateCourseCompletion( + userId: string, + courseId: string +): Promise { + try { + // Query the user_progress table for the requesting user and target course + const result = await query( + `SELECT user_id, course_id, progress, completed_lessons, last_accessed_at, completed_at + FROM user_progress + WHERE user_id = $1 AND course_id = $2`, + [userId, courseId] + ); + + if (result.rows.length === 0) { + throw new CertificateServiceError( + 'Course progress not found', + 404, + 'PROGRESS_NOT_FOUND' + ); + } + + const progress = result.rows[0] as CourseProgress; + + // Assert that progress >= 100 before generating the certificate + if (progress.progress < 100) { + throw new CertificateServiceError( + 'Course not completed', + 403, + 'COURSE_NOT_COMPLETED' + ); + } + + return progress; + } catch (error) { + if (error instanceof CertificateServiceError) { + throw error; + } + throw new CertificateServiceError( + 'Failed to validate course completion', + 500, + 'VALIDATION_ERROR' + ); + } +} + +/** + * Generates a certificate for a completed course + * @param certificateData - The certificate data + * @returns The generated certificate data + * @throws CertificateServiceError if validation fails + */ +export async function generateCertificate( + certificateData: CertificateData +): Promise { + // Validate course completion before generating certificate + await validateCourseCompletion(certificateData.userId, certificateData.courseId); + + // In a real implementation, this would generate a PDF certificate + // For now, we return the certificate data + return { + ...certificateData, + completionDate: new Date().toISOString(), + }; +} diff --git a/src/types/api.ts b/src/types/api.ts index e50a6419..065427f4 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -2,7 +2,7 @@ import { User as ZodUser, UserRole as ZodUserRole } from '@/schemas/user.schema' import { Course as ZodCourse } from '@/schemas/course.schema'; import { AuthResponse as ZodAuthResponse } from '@/schemas/auth.schema'; import { AnalyticsEventPayload as ZodAnalyticsEventPayload } from '@/schemas/analytics.schema'; -import { UserProgress as ZodUserProgress } from '@/schemas/progress.schema'; +import { UserProgress as ZodUserProgress, CourseProgress as ZodCourseProgress } from '@/schemas/progress.schema'; import { VideoBookmark as ZodVideoBookmark, VideoNote as ZodVideoNote, @@ -93,6 +93,7 @@ export type VideoNote = ZodVideoNote; // --------------------------------------------------------------------------- export type UserProgress = ZodUserProgress; +export type CourseProgress = ZodCourseProgress; // --------------------------------------------------------------------------- // Video analytics From 37c079fedfab726f87589c761939ae239fd9075e Mon Sep 17 00:00:00 2001 From: The Joel Date: Wed, 1 Jul 2026 23:45:57 +0100 Subject: [PATCH 2/6] fix: Change completedAt from nullable to optional to fix TypeScript error - Update CourseProgressSchema to use .optional() instead of .nullable() - This changes the type from string | null to string | undefined - Fixes CI type-check error on line 53 of certificate-service.ts --- src/schemas/progress.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/progress.schema.ts b/src/schemas/progress.schema.ts index 13fc7a51..54d6fa40 100644 --- a/src/schemas/progress.schema.ts +++ b/src/schemas/progress.schema.ts @@ -17,7 +17,7 @@ export const CourseProgressSchema = z.object({ progress: z.number().min(0).max(100), completedLessons: z.array(z.string()), lastAccessedAt: z.string(), - completedAt: z.string().nullable(), + completedAt: z.string().optional(), }); export type CourseProgress = z.infer; From b5ba64836b3a4f1452589a379a32ac63fb10af3a Mon Sep 17 00:00:00 2001 From: The Joel Date: Thu, 2 Jul 2026 03:24:30 +0100 Subject: [PATCH 3/6] fix: Normalize database null to undefined in certificate-service.ts - Use nullish coalescing (??) to convert completed_at null to undefined - Fixes TypeScript TS2322 error: string | null not assignable to string | undefined - Maintains type safety between database schema and CourseProgress type --- src/services/certificate-service.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/services/certificate-service.ts b/src/services/certificate-service.ts index b4ddd302..2b3c4142 100644 --- a/src/services/certificate-service.ts +++ b/src/services/certificate-service.ts @@ -54,7 +54,23 @@ export async function validateCourseCompletion( ); } - const progress = result.rows[0] as CourseProgress; + const row = result.rows[0] as { + user_id: string; + course_id: string; + progress: number; + completed_lessons: string[]; + last_accessed_at: string; + completed_at: string | null; + }; + + const progress: CourseProgress = { + userId: row.user_id, + courseId: row.course_id, + progress: row.progress, + completedLessons: row.completed_lessons, + lastAccessedAt: row.last_accessed_at, + completedAt: row.completed_at ?? undefined, + }; // Assert that progress >= 100 before generating the certificate if (progress.progress < 100) { From 3cde0d4e7f7be72becba614546148aed5a54203b Mon Sep 17 00:00:00 2001 From: The Joel Date: Thu, 2 Jul 2026 12:45:09 +0100 Subject: [PATCH 4/6] fix: change completed_at type from string | null to string | undefined to match schema --- src/services/certificate-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/certificate-service.ts b/src/services/certificate-service.ts index 2b3c4142..df6265ca 100644 --- a/src/services/certificate-service.ts +++ b/src/services/certificate-service.ts @@ -60,7 +60,7 @@ export async function validateCourseCompletion( progress: number; completed_lessons: string[]; last_accessed_at: string; - completed_at: string | null; + completed_at: string | undefined; }; const progress: CourseProgress = { From 7c9fb72c39fa83cd87a3e197ba237c4321f4523c Mon Sep 17 00:00:00 2001 From: The Joel Date: Thu, 2 Jul 2026 12:55:52 +0100 Subject: [PATCH 5/6] fix: convert null to undefined for completedAt to match schema --- src/services/certificate-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/certificate-service.ts b/src/services/certificate-service.ts index 82ca00d2..05190738 100644 --- a/src/services/certificate-service.ts +++ b/src/services/certificate-service.ts @@ -54,7 +54,7 @@ async function getCourseCompletion( return { isCompleted: row.progress >= 100, - completedAt: row.completed_at, + completedAt: row.completed_at ?? undefined, }; } catch (error) { logger.error('Failed to check course completion', { From d319e389b1395e05e22a858d128f9b7c3d99fde7 Mon Sep 17 00:00:00 2001 From: The Joel Date: Thu, 2 Jul 2026 12:58:11 +0100 Subject: [PATCH 6/6] fix: add userId and courseId to CourseCompletion return object --- src/services/certificate-service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/certificate-service.ts b/src/services/certificate-service.ts index 05190738..3c0d20c6 100644 --- a/src/services/certificate-service.ts +++ b/src/services/certificate-service.ts @@ -53,6 +53,8 @@ async function getCourseCompletion( }; return { + userId: row.user_id, + courseId: row.course_id, isCompleted: row.progress >= 100, completedAt: row.completed_at ?? undefined, };