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 index 9a53778a..03e0d712 100644 --- a/src/app/api/certificates/generate/route.ts +++ b/src/app/api/certificates/generate/route.ts @@ -205,4 +205,4 @@ function getClientIp(request: NextRequest): string { if (realIp) return realIp; return '127.0.0.1'; -} +} \ No newline at end of file diff --git a/src/schemas/progress.schema.ts b/src/schemas/progress.schema.ts index 8446b842..54d6fa40 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().optional(), +}); + +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 index 22595058..3c0d20c6 100644 --- a/src/services/certificate-service.ts +++ b/src/services/certificate-service.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto'; +import { query } from '@/lib/db/pool'; import { createLogger } from '@/lib/logging'; import { CertificateInput, @@ -17,29 +18,53 @@ const logger = createLogger('certificate-service'); const certificateStore = new Map(); /** - * Verify or get course completion status. + * Verify course completion status via the user_progress table. * * SECURITY: Server-side verification prevents users from generating certificates * for courses they haven't completed. Check must happen before generation. - * - * In production: Query enrollment/progress database with user ID and course ID. - * Returns: completion record with isCompleted boolean and completedAt timestamp. */ async function getCourseCompletion( userId: string, courseId: string, ): Promise { - // MOCK IMPLEMENTATION — Replace with actual database query - // Pattern: Query IDB or backend progress table for: - // SELECT * FROM user_progress WHERE userId = ? AND courseId = ? AND isCompleted = true - logger.debug('Checking course completion', { context: { userId, courseId }, }); - // For now, all requests return null (requires implementation with actual data source) - // TODO: Connect to actual progress/enrollment tracking system - return null; + try { + 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) { + return null; + } + + 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; + }; + + return { + userId: row.user_id, + courseId: row.course_id, + isCompleted: row.progress >= 100, + completedAt: row.completed_at ?? undefined, + }; + } catch (error) { + logger.error('Failed to check course completion', { + context: { userId, courseId }, + error, + }); + return null; + } } /** @@ -121,7 +146,7 @@ function computeCertificateHash( * * SECURITY CHECKS: * 1. User must be authenticated (verified by caller via requireAuth) - * 2. User must have completed the course (server-side verification) + * 2. User must have completed the course (server-side verification against user_progress) * 3. Input must be sanitized (schema validation) * 4. Rate limiting applied by caller * 5. All changes logged to audit trail by caller @@ -267,4 +292,4 @@ export async function getCertificatesForUser(userId: string): Promise