569 lines
19 KiB
TypeScript
569 lines
19 KiB
TypeScript
/**
|
|
* 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<string, unknown>): 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<string, unknown>): 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<Category[]> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<Category | null> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<Category> {
|
|
const slug = input.slug || generateSlug(input.name);
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<PaginatedResult<Course>> {
|
|
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<Record<string, unknown>>(
|
|
`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<Course | null> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<Course | null> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<CourseWithDetails | null> {
|
|
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<Course> {
|
|
const slug = input.slug || generateSlug(input.title);
|
|
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<Course | null> {
|
|
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<Record<string, unknown>>(
|
|
`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<boolean> {
|
|
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<Course | null> {
|
|
return this.updateCourse(id, { status: 'published' });
|
|
}
|
|
|
|
async archiveCourse(id: string): Promise<Course | null> {
|
|
return this.updateCourse(id, { status: 'archived' });
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Modules
|
|
// ==========================================================================
|
|
|
|
async getCourseModules(courseId: string): Promise<ModuleWithLessons[]> {
|
|
const modulesResult = await db.query<Record<string, unknown>>(
|
|
`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<Module | null> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<Module> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<boolean> {
|
|
const result = await db.query(`DELETE FROM education.modules WHERE id = $1`, [id]);
|
|
return (result.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Lessons
|
|
// ==========================================================================
|
|
|
|
async getModuleLessons(moduleId: string): Promise<Lesson[]> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`SELECT * FROM education.lessons WHERE module_id = $1 ORDER BY sort_order`,
|
|
[moduleId]
|
|
);
|
|
return result.rows.map(transformLesson);
|
|
}
|
|
|
|
async getLessonById(id: string): Promise<Lesson | null> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<Lesson> {
|
|
const slug = input.slug || generateSlug(input.title);
|
|
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<boolean> {
|
|
// 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<void> {
|
|
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<Course[]> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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<Course[]> {
|
|
const result = await db.query<Record<string, unknown>>(
|
|
`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();
|