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>
594 lines
21 KiB
TypeScript
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();
|