Skip to content
40 changes: 40 additions & 0 deletions docs/database-migrations/001_create_user_progress_table.sql
Original file line number Diff line number Diff line change
@@ -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%)';
2 changes: 1 addition & 1 deletion src/app/api/certificates/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,4 @@ function getClientIp(request: NextRequest): string {
if (realIp) return realIp;

return '127.0.0.1';
}
}
11 changes: 11 additions & 0 deletions src/schemas/progress.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ export const UserProgressSchema = z.object({
});

export type UserProgress = z.infer<typeof UserProgressSchema>;

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<typeof CourseProgressSchema>;
206 changes: 206 additions & 0 deletions src/services/__tests__/certificate-service.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
51 changes: 38 additions & 13 deletions src/services/certificate-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from 'crypto';
import { query } from '@/lib/db/pool';
import { createLogger } from '@/lib/logging';
import {
CertificateInput,
Expand All @@ -17,29 +18,53 @@ const logger = createLogger('certificate-service');
const certificateStore = new Map<string, CertificateRecord>();

/**
* 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<CourseCompletion | null> {
// 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;
}
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -267,4 +292,4 @@ export async function getCertificatesForUser(userId: string): Promise<Certificat
}

return certs;
}
}
3 changes: 2 additions & 1 deletion src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -94,6 +94,7 @@ export type VideoNote = ZodVideoNote;
// ---------------------------------------------------------------------------

export type UserProgress = ZodUserProgress;
export type CourseProgress = ZodCourseProgress;

// ---------------------------------------------------------------------------
// Video analytics
Expand Down
Loading