trading-platform-backend-v2/src/modules/education/services/course.service.ts
Adrian Flores Cortes c91b8e5419 feat: Update TypeScript types across modules
Updated types:
- audit.types.ts: Enhanced audit trail types
- auth.types.ts: Session and token types
- education.types.ts, reviews.types.ts: Course and enrollment types
- investment.types.ts: Portfolio types
- llm.types.ts: LLM integration types
- market-data.types.ts: Market data types
- financial.types.ts: Payment processing types

New:
- trading/entity.types.ts: Trading entity definitions

Services:
- Update course and enrollment services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:24:45 -06:00

594 lines
21 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,
shortDescription: row.short_description as string | undefined,
fullDescription: row.full_description as string | undefined,
thumbnailUrl: row.thumbnail_url as string | undefined,
trailerUrl: row.trailer_url as string | undefined,
categoryId: (row.category_id as string) || '',
difficultyLevel: (row.difficulty_level || row.level || 'beginner') as Course['difficultyLevel'],
level: (row.difficulty_level || row.level) as Course['level'],
durationMinutes: row.duration_minutes as number | undefined,
prerequisites: (row.prerequisites as string[]) || [],
learningObjectives: (row.learning_objectives as string[]) || [],
instructorId: (row.instructor_id as string) || '',
instructorName: row.instructor_name as string | undefined,
isFree: row.is_free as boolean,
priceUsd: parseFloat(row.price_usd as string) || undefined,
xpReward: (row.xp_reward as number) || 0,
status: row.status as Course['status'],
publishedAt: row.published_at ? new Date(row.published_at as string) : undefined,
totalModules: (row.total_modules as number) || 0,
totalLessons: (row.total_lessons || row.lessons_count) as number || 0,
totalEnrollments: (row.total_enrollments || row.enrolled_count) as number || 0,
avgRating: parseFloat(row.avg_rating as string) || parseFloat(row.average_rating as string) || 0,
totalReviews: (row.total_reviews || row.ratings_count) as number || 0,
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,
description: row.description as string | undefined,
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,
videoId: row.video_id as string | undefined,
articleContent: row.article_content as string | undefined,
attachments: (row.attachments as Lesson['attachments']) || [],
resources: (row.resources as Lesson['resources']) || [],
displayOrder: (row.display_order || row.sort_order) as number || 0,
isPreview: row.is_preview as boolean,
isMandatory: (row.is_mandatory as boolean) ?? true,
xpReward: (row.xp_reward as number) || 0,
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 display_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,
parentId: row.parent_id as string | undefined,
displayOrder: (row.display_order as number) || 0,
iconUrl: row.icon_url as string | undefined,
color: row.color as string | undefined,
isActive: (row.is_active as boolean) ?? true,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
// Backward-compatible aliases
sortOrder: (row.display_order as number) || 0,
icon: row.icon_url as string | undefined,
}));
}
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,
parentId: row.parent_id as string | undefined,
displayOrder: (row.display_order as number) || 0,
iconUrl: row.icon_url as string | undefined,
color: row.color as string | undefined,
isActive: (row.is_active as boolean) ?? true,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
// Backward-compatible aliases
sortOrder: (row.display_order as number) || 0,
icon: row.icon_url as string | undefined,
};
}
async createCategory(input: CreateCategoryInput): Promise<Category> {
const slug = input.slug || generateSlug(input.name);
// Support both new and deprecated field names
const iconUrl = input.iconUrl || input.icon;
const displayOrder = input.displayOrder ?? input.sortOrder ?? 0;
const isActive = input.isActive ?? true;
const result = await db.query<Record<string, unknown>>(
`INSERT INTO education.categories (name, slug, description, icon_url, color, parent_id, display_order, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[input.name, slug, input.description, iconUrl, input.color, input.parentId, displayOrder, isActive]
);
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,
parentId: row.parent_id as string | undefined,
displayOrder: (row.display_order as number) || 0,
iconUrl: row.icon_url as string | undefined,
color: row.color as string | undefined,
isActive: (row.is_active as boolean) ?? true,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
// Backward-compatible aliases
sortOrder: (row.display_order as number) || 0,
icon: row.icon_url as string | undefined,
};
}
// ==========================================================================
// 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 display_order, 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,
displayOrder: (row.display_order || row.sort_order) as number || 0,
sortOrder: row.sort_order as number,
xpReward: (row.xp_reward as number) || 0,
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,
displayOrder: (row.display_order || row.sort_order) as number || 0,
sortOrder: row.sort_order as number,
xpReward: (row.xp_reward as number) || 0,
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, display_order)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[input.courseId, input.title, input.description, input.sortOrder || 0]
);
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,
displayOrder: (row.display_order || row.sort_order) as number || 0,
sortOrder: row.sort_order as number,
xpReward: (row.xp_reward as number) || 0,
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();