/** * Course Service * Handles course management operations */ import { db } from '../../../shared/database'; import { logger } from '../../../shared/utils/logger'; import type { Course, CourseWithDetails, CreateCourseInput, UpdateCourseInput, CourseFilters, Category, CreateCategoryInput, Module, ModuleWithLessons, CreateModuleInput, Lesson, CreateLessonInput, PaginatedResult, PaginationOptions, } from '../types/education.types'; // ============================================================================ // Helper Functions // ============================================================================ function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .trim(); } function transformCourse(row: Record): Course { return { id: row.id as string, title: row.title as string, slug: row.slug as string, description: row.description as string | undefined, shortDescription: row.short_description as string | undefined, thumbnailUrl: row.thumbnail_url as string | undefined, previewVideoUrl: row.preview_video_url as string | undefined, categoryId: row.category_id as string | undefined, level: row.level as Course['level'], tags: (row.tags as string[]) || [], isFree: row.is_free as boolean, price: parseFloat(row.price as string) || 0, currency: row.currency as string, requiresSubscription: row.requires_subscription as boolean, minSubscriptionTier: row.min_subscription_tier as string | undefined, durationMinutes: row.duration_minutes as number | undefined, lessonsCount: row.lessons_count as number, enrolledCount: row.enrolled_count as number, averageRating: parseFloat(row.average_rating as string) || 0, ratingsCount: row.ratings_count as number, status: row.status as Course['status'], publishedAt: row.published_at ? new Date(row.published_at as string) : undefined, instructorId: row.instructor_id as string | undefined, aiGenerated: row.ai_generated as boolean, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } function transformLesson(row: Record): Lesson { return { id: row.id as string, moduleId: row.module_id as string, courseId: row.course_id as string, title: row.title as string, slug: row.slug as string, contentType: row.content_type as Lesson['contentType'], videoUrl: row.video_url as string | undefined, videoDurationSeconds: row.video_duration_seconds as number | undefined, videoProvider: row.video_provider as string | undefined, contentMarkdown: row.content_markdown as string | undefined, contentHtml: row.content_html as string | undefined, resources: (row.resources as Lesson['resources']) || [], sortOrder: row.sort_order as number, isPreview: row.is_preview as boolean, aiGenerated: row.ai_generated as boolean, aiSummary: row.ai_summary as string | undefined, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } // ============================================================================ // Course Service Class // ============================================================================ class CourseService { // ========================================================================== // Categories // ========================================================================== async getCategories(): Promise { const result = await db.query>( `SELECT * FROM education.categories ORDER BY sort_order, name` ); return result.rows.map((row) => ({ id: row.id as string, name: row.name as string, slug: row.slug as string, description: row.description as string | undefined, icon: row.icon as string | undefined, parentId: row.parent_id as string | undefined, sortOrder: row.sort_order as number, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), })); } async getCategoryById(id: string): Promise { const result = await db.query>( `SELECT * FROM education.categories WHERE id = $1`, [id] ); if (result.rows.length === 0) return null; const row = result.rows[0]; return { id: row.id as string, name: row.name as string, slug: row.slug as string, description: row.description as string | undefined, icon: row.icon as string | undefined, parentId: row.parent_id as string | undefined, sortOrder: row.sort_order as number, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } async createCategory(input: CreateCategoryInput): Promise { const slug = input.slug || generateSlug(input.name); const result = await db.query>( `INSERT INTO education.categories (name, slug, description, icon, parent_id, sort_order) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [input.name, slug, input.description, input.icon, input.parentId, input.sortOrder || 0] ); const row = result.rows[0]; return { id: row.id as string, name: row.name as string, slug: row.slug as string, description: row.description as string | undefined, icon: row.icon as string | undefined, parentId: row.parent_id as string | undefined, sortOrder: row.sort_order as number, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } // ========================================================================== // Courses // ========================================================================== async getCourses( filters: CourseFilters = {}, pagination: PaginationOptions = {} ): Promise> { const { page = 1, pageSize = 20, sortBy = 'created_at', sortOrder = 'desc' } = pagination; const offset = (page - 1) * pageSize; const conditions: string[] = []; const params: (string | number | boolean | null)[] = []; let paramIndex = 1; if (filters.categoryId) { conditions.push(`category_id = $${paramIndex++}`); params.push(filters.categoryId); } if (filters.level) { conditions.push(`level = $${paramIndex++}`); params.push(filters.level); } if (filters.status) { conditions.push(`status = $${paramIndex++}`); params.push(filters.status); } if (filters.isFree !== undefined) { conditions.push(`is_free = $${paramIndex++}`); params.push(filters.isFree); } if (filters.instructorId) { conditions.push(`instructor_id = $${paramIndex++}`); params.push(filters.instructorId); } if (filters.search) { conditions.push(`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`); params.push(`%${filters.search}%`); paramIndex++; } if (filters.minRating) { conditions.push(`average_rating >= $${paramIndex++}`); params.push(filters.minRating); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const allowedSortColumns = ['created_at', 'title', 'price', 'average_rating', 'enrolled_count']; const safeSort = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at'; const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC'; const countResult = await db.query<{ count: string }>( `SELECT COUNT(*) as count FROM education.courses ${whereClause}`, params ); const total = parseInt(countResult.rows[0].count, 10); params.push(pageSize, offset); const dataResult = await db.query>( `SELECT * FROM education.courses ${whereClause} ORDER BY ${safeSort} ${safeSortOrder} LIMIT $${paramIndex++} OFFSET $${paramIndex}`, params ); return { data: dataResult.rows.map(transformCourse), total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } async getCourseById(id: string): Promise { const result = await db.query>( `SELECT * FROM education.courses WHERE id = $1`, [id] ); if (result.rows.length === 0) return null; return transformCourse(result.rows[0]); } async getCourseBySlug(slug: string): Promise { const result = await db.query>( `SELECT * FROM education.courses WHERE slug = $1`, [slug] ); if (result.rows.length === 0) return null; return transformCourse(result.rows[0]); } async getCourseWithDetails(id: string): Promise { const course = await this.getCourseById(id); if (!course) return null; // Get category let category: Category | undefined; if (course.categoryId) { category = (await this.getCategoryById(course.categoryId)) || undefined; } // Get modules with lessons const modules = await this.getCourseModules(id); return { ...course, category, modules, }; } async createCourse(input: CreateCourseInput): Promise { const slug = input.slug || generateSlug(input.title); const result = await db.query>( `INSERT INTO education.courses ( title, slug, description, short_description, thumbnail_url, preview_video_url, category_id, level, tags, is_free, price, currency, requires_subscription, min_subscription_tier, duration_minutes, instructor_id, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'draft') RETURNING *`, [ input.title, slug, input.description, input.shortDescription, input.thumbnailUrl, input.previewVideoUrl, input.categoryId, input.level || 'beginner', input.tags || [], input.isFree ?? false, input.price || 0, input.currency || 'USD', input.requiresSubscription ?? false, input.minSubscriptionTier, input.durationMinutes, input.instructorId, ] ); logger.info('[CourseService] Course created:', { courseId: result.rows[0].id, title: input.title }); return transformCourse(result.rows[0]); } async updateCourse(id: string, input: UpdateCourseInput): Promise { const updates: string[] = []; const params: (string | number | boolean | string[] | null)[] = []; let paramIndex = 1; if (input.title !== undefined) { updates.push(`title = $${paramIndex++}`); params.push(input.title); } if (input.slug !== undefined) { updates.push(`slug = $${paramIndex++}`); params.push(input.slug); } if (input.description !== undefined) { updates.push(`description = $${paramIndex++}`); params.push(input.description); } if (input.shortDescription !== undefined) { updates.push(`short_description = $${paramIndex++}`); params.push(input.shortDescription); } if (input.thumbnailUrl !== undefined) { updates.push(`thumbnail_url = $${paramIndex++}`); params.push(input.thumbnailUrl); } if (input.categoryId !== undefined) { updates.push(`category_id = $${paramIndex++}`); params.push(input.categoryId); } if (input.level !== undefined) { updates.push(`level = $${paramIndex++}`); params.push(input.level); } if (input.tags !== undefined) { updates.push(`tags = $${paramIndex++}`); params.push(input.tags); } if (input.isFree !== undefined) { updates.push(`is_free = $${paramIndex++}`); params.push(input.isFree); } if (input.price !== undefined) { updates.push(`price = $${paramIndex++}`); params.push(input.price); } if (input.status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(input.status); if (input.status === 'published') { updates.push(`published_at = CURRENT_TIMESTAMP`); } } if (updates.length === 0) return this.getCourseById(id); params.push(id); const result = await db.query>( `UPDATE education.courses SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); if (result.rows.length === 0) return null; logger.info('[CourseService] Course updated:', { courseId: id }); return transformCourse(result.rows[0]); } async deleteCourse(id: string): Promise { const result = await db.query(`DELETE FROM education.courses WHERE id = $1`, [id]); logger.info('[CourseService] Course deleted:', { courseId: id }); return (result.rowCount ?? 0) > 0; } async publishCourse(id: string): Promise { return this.updateCourse(id, { status: 'published' }); } async archiveCourse(id: string): Promise { return this.updateCourse(id, { status: 'archived' }); } // ========================================================================== // Modules // ========================================================================== async getCourseModules(courseId: string): Promise { const modulesResult = await db.query>( `SELECT * FROM education.modules WHERE course_id = $1 ORDER BY sort_order`, [courseId] ); const modules: ModuleWithLessons[] = []; for (const row of modulesResult.rows) { const lessons = await this.getModuleLessons(row.id as string); modules.push({ id: row.id as string, courseId: row.course_id as string, title: row.title as string, description: row.description as string | undefined, sortOrder: row.sort_order as number, unlockAfterModuleId: row.unlock_after_module_id as string | undefined, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), lessons, }); } return modules; } async getModuleById(id: string): Promise { const result = await db.query>( `SELECT * FROM education.modules WHERE id = $1`, [id] ); if (result.rows.length === 0) return null; const row = result.rows[0]; return { id: row.id as string, courseId: row.course_id as string, title: row.title as string, description: row.description as string | undefined, sortOrder: row.sort_order as number, unlockAfterModuleId: row.unlock_after_module_id as string | undefined, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } async createModule(input: CreateModuleInput): Promise { const result = await db.query>( `INSERT INTO education.modules (course_id, title, description, sort_order, unlock_after_module_id) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [input.courseId, input.title, input.description, input.sortOrder || 0, input.unlockAfterModuleId] ); const row = result.rows[0]; return { id: row.id as string, courseId: row.course_id as string, title: row.title as string, description: row.description as string | undefined, sortOrder: row.sort_order as number, unlockAfterModuleId: row.unlock_after_module_id as string | undefined, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } async deleteModule(id: string): Promise { const result = await db.query(`DELETE FROM education.modules WHERE id = $1`, [id]); return (result.rowCount ?? 0) > 0; } // ========================================================================== // Lessons // ========================================================================== async getModuleLessons(moduleId: string): Promise { const result = await db.query>( `SELECT * FROM education.lessons WHERE module_id = $1 ORDER BY sort_order`, [moduleId] ); return result.rows.map(transformLesson); } async getLessonById(id: string): Promise { const result = await db.query>( `SELECT * FROM education.lessons WHERE id = $1`, [id] ); if (result.rows.length === 0) return null; return transformLesson(result.rows[0]); } async createLesson(input: CreateLessonInput): Promise { const slug = input.slug || generateSlug(input.title); const result = await db.query>( `INSERT INTO education.lessons ( module_id, course_id, title, slug, content_type, video_url, video_duration_seconds, video_provider, content_markdown, resources, sort_order, is_preview ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ input.moduleId, input.courseId, input.title, slug, input.contentType || 'video', input.videoUrl, input.videoDurationSeconds, input.videoProvider, input.contentMarkdown, JSON.stringify(input.resources || []), input.sortOrder || 0, input.isPreview ?? false, ] ); // Update course lessons count await db.query( `UPDATE education.courses SET lessons_count = lessons_count + 1 WHERE id = $1`, [input.courseId] ); return transformLesson(result.rows[0]); } async deleteLesson(id: string): Promise { // Get lesson to update course count const lesson = await this.getLessonById(id); if (!lesson) return false; const result = await db.query(`DELETE FROM education.lessons WHERE id = $1`, [id]); if ((result.rowCount ?? 0) > 0) { await db.query( `UPDATE education.courses SET lessons_count = GREATEST(lessons_count - 1, 0) WHERE id = $1`, [lesson.courseId] ); } return (result.rowCount ?? 0) > 0; } // ========================================================================== // Statistics // ========================================================================== async updateCourseStats(courseId: string): Promise { await db.query( `UPDATE education.courses SET lessons_count = (SELECT COUNT(*) FROM education.lessons WHERE course_id = $1), duration_minutes = (SELECT COALESCE(SUM(video_duration_seconds) / 60, 0) FROM education.lessons WHERE course_id = $1) WHERE id = $1`, [courseId] ); } async getPopularCourses(limit: number = 10): Promise { const result = await db.query>( `SELECT * FROM education.courses WHERE status = 'published' ORDER BY enrolled_count DESC, average_rating DESC LIMIT $1`, [limit] ); return result.rows.map(transformCourse); } async getNewCourses(limit: number = 10): Promise { const result = await db.query>( `SELECT * FROM education.courses WHERE status = 'published' ORDER BY published_at DESC LIMIT $1`, [limit] ); return result.rows.map(transformCourse); } } export const courseService = new CourseService();