kindergarten_java/reading-platform-backend/src/modules/course/course.service.ts
tonytech 7f757b6a63 初始提交:幼儿园阅读平台三端代码
- reading-platform-backend:NestJS 后端
- reading-platform-frontend:Vue3 前端
- reading-platform-java:Spring Boot 服务端
2026-02-28 17:51:15 +08:00

1034 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { CourseValidationService, ValidationResult } from './course-validation.service';
@Injectable()
export class CourseService {
private readonly logger = new Logger(CourseService.name);
constructor(
private prisma: PrismaService,
private validationService: CourseValidationService,
) {}
async findAll(query: any) {
const { page = 1, pageSize = 10, grade, status, keyword } = query;
const skip = (page - 1) * pageSize;
const take = +pageSize;
const where: any = {};
// 筛选条件
if (status) {
where.status = status;
}
if (keyword) {
where.name = { contains: keyword };
}
// 年级筛选 - SQLite使用字符串包含匹配
if (grade) {
// 搜索英文值(数据库存储的是英文)
// 支持大小写不敏感搜索
const gradeUpper = grade.toUpperCase();
where.OR = [
{ gradeTags: { contains: gradeUpper } }, // 大写格式: SMALL, MIDDLE, BIG
{ gradeTags: { contains: grade.toLowerCase() } }, // 小写格式: small, middle, big
];
}
const [items, total] = await Promise.all([
this.prisma.course.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
pictureBookName: true,
gradeTags: true,
status: true,
version: true,
usageCount: true,
teacherCount: true,
avgRating: true,
createdAt: true,
updatedAt: true,
submittedAt: true,
reviewedAt: true,
reviewComment: true,
themeId: true,
coreContent: true,
coverImagePath: true,
domainTags: true,
theme: {
select: { id: true, name: true },
},
},
}),
this.prisma.course.count({ where }),
]);
return {
items,
total,
page: +page,
pageSize: +pageSize,
};
}
async findOne(id: number) {
const course = await this.prisma.course.findUnique({
where: { id },
include: {
theme: {
select: { id: true, name: true },
},
resources: {
orderBy: { sortOrder: 'asc' },
},
scripts: {
orderBy: { sortOrder: 'asc' },
include: {
pages: {
orderBy: { pageNumber: 'asc' },
},
},
},
activities: {
orderBy: { sortOrder: 'asc' },
},
courseLessons: {
orderBy: { sortOrder: 'asc' },
include: {
steps: {
orderBy: { sortOrder: 'asc' },
},
},
},
},
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
return course;
}
async create(createCourseDto: any) {
try {
this.logger.log(`Creating course with data: ${JSON.stringify(createCourseDto)}`);
const result = await this.prisma.course.create({
data: createCourseDto,
});
this.logger.log(`Course created successfully with ID: ${result.id}`);
return result;
} catch (error) {
this.logger.error(`Error creating course: ${error.message}`, error.stack);
throw error;
}
}
async update(id: number, updateCourseDto: any) {
// 需要明确设置为 null 的字段列表(与 schema.prisma 保持一致)
const fieldsToClear = [
'coverImagePath',
'ebookPaths',
'audioPaths',
'videoPaths',
'otherResources',
'pptPath',
'pptName',
'posterPaths',
'tools',
'studentMaterials',
'pictureBookName',
'lessonPlanData',
'activitiesData',
'assessmentData',
// 新增课程介绍字段
'introSummary',
'introHighlights',
'introGoals',
'introSchedule',
'introKeyPoints',
'introMethods',
'introEvaluation',
'introNotes',
'coreContent',
'scheduleRefData',
'environmentConstruction',
];
const cleanedData: any = {};
for (const [key, value] of Object.entries(updateCourseDto)) {
// 对于可以清除的字段,如果是 null 或空字符串,设置为 null
if (fieldsToClear.includes(key) && (value === null || value === '')) {
cleanedData[key] = null;
}
// 对于其他字段,如果有值则添加
else if (value !== undefined) {
cleanedData[key] = value;
}
}
this.logger.log(`Updating course ${id} with data: ${JSON.stringify(Object.keys(cleanedData))}`);
// 使用事务更新课程和关联表数据
return this.prisma.$transaction(async (tx) => {
// 更新课程主表
const updatedCourse = await tx.course.update({
where: { id },
data: cleanedData,
});
// 同步 lessonPlanData 到 course_scripts 关联表
if (updateCourseDto.lessonPlanData !== undefined) {
await this.syncLessonPlanToScripts(tx, id, updateCourseDto.lessonPlanData);
}
// 同步 activitiesData 到 course_activities 关联表
if (updateCourseDto.activitiesData !== undefined) {
await this.syncActivitiesToTable(tx, id, updateCourseDto.activitiesData);
}
return updatedCourse;
});
}
/**
* 将 lessonPlanData 同步到 course_scripts 关联表
* 支持新格式pages在每个phase内部和旧格式scriptPages在顶层
*/
private async syncLessonPlanToScripts(tx: any, courseId: number, lessonPlanData: string | null) {
// 先删除旧的 scripts 和 pages
await tx.courseScriptPage.deleteMany({
where: { script: { courseId } },
});
await tx.courseScript.deleteMany({
where: { courseId },
});
if (!lessonPlanData) {
this.logger.log(`Course ${courseId}: lessonPlanData is null, cleared scripts`);
return;
}
try {
const lessonPlan = JSON.parse(lessonPlanData);
const phases = lessonPlan.phases || [];
// 兼容旧格式:顶层的 scriptPages
const topLevelScriptPages = lessonPlan.scriptPages || [];
// 调试日志
this.logger.log(`=== 同步课程 ${courseId} 的教学脚本 ===`);
this.logger.log(`phases 数量: ${phases.length}`);
this.logger.log(`顶层 scriptPages 数量: ${topLevelScriptPages.length}`);
for (let i = 0; i < phases.length; i++) {
const phase = phases[i];
this.logger.log(`Phase ${i}: name=${phase.name}, pages=${phase.pages?.length || 0}, enablePageScript=${phase.enablePageScript}`);
// 创建 script 记录
const script = await tx.courseScript.create({
data: {
courseId,
stepIndex: i + 1,
stepName: phase.name || `步骤${i + 1}`,
stepType: phase.type || 'CUSTOM',
duration: phase.duration || 5,
objective: phase.objective || null,
teacherScript: phase.content || null,
interactionPoints: null,
resourceIds: phase.resourceIds ? JSON.stringify(phase.resourceIds) : null,
sortOrder: i,
},
});
// 优先使用 phase 内部的 pages新格式
// 如果没有,则兼容旧格式:第一个 phase 使用顶层的 scriptPages
let pagesToCreate = phase.pages || [];
if (pagesToCreate.length === 0 && topLevelScriptPages.length > 0 && i === 0) {
pagesToCreate = topLevelScriptPages;
}
// 创建逐页配置
if (pagesToCreate.length > 0) {
this.logger.log(`为 Phase ${i} 创建 ${pagesToCreate.length} 页逐页脚本`);
for (const page of pagesToCreate) {
await tx.courseScriptPage.create({
data: {
scriptId: script.id,
pageNumber: page.pageNumber,
questions: page.teacherScript || null,
interactionComponent: page.actions ? JSON.stringify(page.actions) : null,
teacherNotes: page.notes || null,
resourceIds: page.resourceIds ? JSON.stringify(page.resourceIds) : null,
},
});
}
}
}
this.logger.log(`Course ${courseId}: synced ${phases.length} scripts from lessonPlanData`);
} catch (error) {
this.logger.error(`Failed to sync lessonPlanData for course ${courseId}: ${error.message}`);
}
}
/**
* 将 activitiesData 同步到 course_activities 关联表
*/
private async syncActivitiesToTable(tx: any, courseId: number, activitiesData: string | null) {
// 先删除旧的活动
await tx.courseActivity.deleteMany({
where: { courseId },
});
if (!activitiesData) {
this.logger.log(`Course ${courseId}: activitiesData is null, cleared activities`);
return;
}
try {
const activities = JSON.parse(activitiesData);
for (let i = 0; i < activities.length; i++) {
const activity = activities[i];
await tx.courseActivity.create({
data: {
courseId,
name: activity.name || `活动${i + 1}`,
domain: activity.domain || null, // 使用单独的 domain 字段,不再错误地使用 type
domainTagId: null,
activityType: this.mapActivityType(activity.type),
duration: activity.duration || 15,
onlineMaterials: activity.content ? JSON.stringify({ content: activity.content }) : null,
offlineMaterials: activity.materials || null,
activityGuide: null,
objectives: null,
sortOrder: i,
},
});
}
this.logger.log(`Course ${courseId}: synced ${activities.length} activities from activitiesData`);
} catch (error) {
this.logger.error(`Failed to sync activitiesData for course ${courseId}: ${error.message}`);
}
}
/**
* 映射活动类型
*/
private mapActivityType(type: string | undefined): string {
const typeMap: Record<string, string> = {
'family': 'FAMILY',
'art': 'ART',
'game': 'GAME',
'outdoor': 'OUTDOOR',
'other': 'OTHER',
'handicraft': 'HANDICRAFT',
'music': 'MUSIC',
'exploration': 'EXPLORATION',
'sports': 'SPORTS',
// 中文兼容
'家庭延伸': 'FAMILY',
'美工活动': 'ART',
'游戏活动': 'GAME',
'户外活动': 'OUTDOOR',
'其他': 'OTHER',
'手工活动': 'HANDICRAFT',
'音乐活动': 'MUSIC',
'探索活动': 'EXPLORATION',
'运动活动': 'SPORTS',
'亲子活动': 'FAMILY',
};
return typeMap[type || ''] || 'OTHER';
}
async remove(id: number) {
// Check if course has usage records
const usageCount = await this.prisma.lesson.count({
where: { courseId: id },
});
if (usageCount > 0) {
throw new BadRequestException(`该课程包已被使用${usageCount}次,无法删除`);
}
// Delete related records in order
// Delete course lessons (if any)
await this.prisma.courseLesson.deleteMany({
where: { courseId: id },
});
// Delete tenant course authorizations (if any)
await this.prisma.tenantCourse.deleteMany({
where: { courseId: id },
});
// Delete course resources (if any)
await this.prisma.courseResource.deleteMany({
where: { courseId: id },
});
// Delete course scripts (if any)
await this.prisma.courseScript.deleteMany({
where: { courseId: id },
});
// Delete course activities (if any)
await this.prisma.courseActivity.deleteMany({
where: { courseId: id },
});
// Delete course versions (if any)
await this.prisma.courseVersion.deleteMany({
where: { courseId: id },
});
// Delete schedule plans (if any)
await this.prisma.schedulePlan.deleteMany({
where: { courseId: id },
});
// Delete schedule templates (if any)
await this.prisma.scheduleTemplate.deleteMany({
where: { courseId: id },
});
// Delete tasks (if any)
await this.prisma.task.deleteMany({
where: { relatedCourseId: id },
});
// Delete task templates (if any)
await this.prisma.taskTemplate.deleteMany({
where: { relatedCourseId: id },
});
// Delete package course relations (if any)
await this.prisma.coursePackageCourse.deleteMany({
where: { courseId: id },
});
// Delete school courses (if any)
await this.prisma.schoolCourse.deleteMany({
where: { sourceCourseId: id },
});
// Finally delete the course
return this.prisma.course.delete({
where: { id },
});
}
/**
* 验证课程完整性
*/
async validate(id: number): Promise<ValidationResult> {
const course = await this.findOne(id);
return this.validationService.validateForSubmit(course);
}
/**
* 提交审核
*/
async submit(id: number, userId: number, copyrightConfirmed: boolean) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
// 检查当前状态是否可以提交
if (course.status !== 'DRAFT' && course.status !== 'REJECTED') {
throw new BadRequestException(`课程状态为 ${course.status},无法提交审核`);
}
// 验证课程完整性
const validationResult = await this.validationService.validateForSubmit(course);
if (!validationResult.valid) {
throw new BadRequestException({
message: '课程内容不完整,请检查以下问题',
errors: validationResult.errors,
warnings: validationResult.warnings,
});
}
// 版权确认
if (!copyrightConfirmed) {
throw new BadRequestException('请确认版权合规');
}
// 更新课程状态
const updatedCourse = await this.prisma.course.update({
where: { id },
data: {
status: 'PENDING',
submittedAt: new Date(),
submittedBy: userId,
},
});
this.logger.log(`Course ${id} submitted for review by user ${userId}`);
return {
...updatedCourse,
validationSummary: this.validationService.getValidationSummary(validationResult),
};
}
/**
* 撤销审核申请
*/
async withdraw(id: number, userId: number) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
if (course.status !== 'PENDING') {
throw new BadRequestException(`课程状态为 ${course.status},无法撤销`);
}
const updatedCourse = await this.prisma.course.update({
where: { id },
data: {
status: 'DRAFT',
submittedAt: null,
submittedBy: null,
},
});
this.logger.log(`Course ${id} review withdrawn by user ${userId}`);
return updatedCourse;
}
/**
* 审核通过并发布
*/
async approve(id: number, reviewerId: number, reviewData: { checklist?: any; comment?: string }) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
if (course.status !== 'PENDING') {
throw new BadRequestException(`课程状态为 ${course.status},无法审核`);
}
// 禁止自审
if (course.submittedBy === reviewerId) {
throw new BadRequestException('不能审核自己提交的课程');
}
// 使用事务更新课程状态并创建版本快照
const result = await this.prisma.$transaction(async (tx) => {
// 更新课程状态
const updatedCourse = await tx.course.update({
where: { id },
data: {
status: 'PUBLISHED',
reviewedAt: new Date(),
reviewedBy: reviewerId,
reviewComment: reviewData.comment || null,
reviewChecklist: reviewData.checklist ? JSON.stringify(reviewData.checklist) : null,
publishedAt: new Date(),
},
});
// 创建版本快照
await tx.courseVersion.create({
data: {
courseId: id,
version: course.version,
snapshotData: JSON.stringify(course),
changeLog: reviewData.comment || '审核通过发布',
publishedBy: reviewerId,
},
});
return updatedCourse;
});
// 授权给所有活跃租户
const activeTenants = await this.prisma.tenant.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
});
this.logger.log(`Publishing course ${id} to ${activeTenants.length} active tenants`);
for (const tenant of activeTenants) {
await this.prisma.tenantCourse.upsert({
where: {
tenantId_courseId: {
tenantId: tenant.id,
courseId: id,
},
},
update: {
authorized: true,
authorizedAt: new Date(),
},
create: {
tenantId: tenant.id,
courseId: id,
authorized: true,
authorizedAt: new Date(),
},
});
}
this.logger.log(`Course ${id} approved and published by reviewer ${reviewerId}`);
return {
...result,
authorizedTenantCount: activeTenants.length,
};
}
/**
* 审核驳回
*/
async reject(id: number, reviewerId: number, reviewData: { checklist?: any; comment: string }) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
if (course.status !== 'PENDING') {
throw new BadRequestException(`课程状态为 ${course.status},无法审核`);
}
// 禁止自审
if (course.submittedBy === reviewerId) {
throw new BadRequestException('不能审核自己提交的课程');
}
if (!reviewData.comment || reviewData.comment.trim().length === 0) {
throw new BadRequestException('请填写驳回原因');
}
const updatedCourse = await this.prisma.course.update({
where: { id },
data: {
status: 'REJECTED',
reviewedAt: new Date(),
reviewedBy: reviewerId,
reviewComment: reviewData.comment,
reviewChecklist: reviewData.checklist ? JSON.stringify(reviewData.checklist) : null,
},
});
this.logger.log(`Course ${id} rejected by reviewer ${reviewerId}: ${reviewData.comment}`);
return updatedCourse;
}
/**
* 直接发布(超级管理员专用)
*/
async directPublish(id: number, userId: number, skipValidation: boolean = false) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
// 检查课程状态
if (course.status === 'PUBLISHED') {
throw new BadRequestException('课程已发布');
}
// 验证课程完整性(即使跳过也要记录)
const validationResult = await this.validationService.validateForSubmit(course);
if (!skipValidation && !validationResult.valid) {
throw new BadRequestException({
message: '课程内容不完整,请检查以下问题',
errors: validationResult.errors,
warnings: validationResult.warnings,
});
}
// 使用事务更新课程状态并创建版本快照
const result = await this.prisma.$transaction(async (tx) => {
const updatedCourse = await tx.course.update({
where: { id },
data: {
status: 'PUBLISHED',
publishedAt: new Date(),
reviewedAt: new Date(),
reviewedBy: userId,
reviewComment: '超级管理员直接发布',
},
});
// 创建版本快照
await tx.courseVersion.create({
data: {
courseId: id,
version: course.version,
snapshotData: JSON.stringify(course),
changeLog: '超级管理员直接发布',
publishedBy: userId,
},
});
return updatedCourse;
});
// 授权给所有活跃租户
const activeTenants = await this.prisma.tenant.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
});
for (const tenant of activeTenants) {
await this.prisma.tenantCourse.upsert({
where: {
tenantId_courseId: {
tenantId: tenant.id,
courseId: id,
},
},
update: {
authorized: true,
authorizedAt: new Date(),
},
create: {
tenantId: tenant.id,
courseId: id,
authorized: true,
authorizedAt: new Date(),
},
});
}
this.logger.log(`Course ${id} directly published by super admin ${userId}`);
return {
...result,
authorizedTenantCount: activeTenants.length,
validationSkipped: skipValidation && !validationResult.valid,
validationWarnings: validationResult.warnings,
};
}
/**
* 发布课程兼容旧API
*/
async publish(id: number) {
// 旧的publish方法改为调用directPublish
return this.directPublish(id, 0, false);
}
/**
* 下架课程
*/
async unpublish(id: number) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
if (course.status !== 'PUBLISHED') {
throw new BadRequestException(`课程状态为 ${course.status},无法下架`);
}
const updatedCourse = await this.prisma.course.update({
where: { id },
data: {
status: 'ARCHIVED',
},
});
// 取消所有租户的授权
await this.prisma.tenantCourse.updateMany({
where: { courseId: id },
data: {
authorized: false,
},
});
this.logger.log(`Course ${id} unpublished`);
return updatedCourse;
}
/**
* 重新发布已下架的课程
*/
async republish(id: number) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
if (course.status !== 'ARCHIVED') {
throw new BadRequestException(`课程状态为 ${course.status},无法重新发布`);
}
const updatedCourse = await this.prisma.course.update({
where: { id },
data: {
status: 'PUBLISHED',
},
});
// 重新授权给所有活跃租户
const activeTenants = await this.prisma.tenant.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
});
for (const tenant of activeTenants) {
await this.prisma.tenantCourse.upsert({
where: {
tenantId_courseId: {
tenantId: tenant.id,
courseId: id,
},
},
update: {
authorized: true,
authorizedAt: new Date(),
},
create: {
tenantId: tenant.id,
courseId: id,
authorized: true,
authorizedAt: new Date(),
},
});
}
this.logger.log(`Course ${id} republished`);
return {
...updatedCourse,
authorizedTenantCount: activeTenants.length,
};
}
async getStats(id: number) {
const course = await this.prisma.course.findUnique({
where: { id },
select: {
id: true,
name: true,
usageCount: true,
teacherCount: true,
avgRating: true,
},
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
const lessons = await this.prisma.lesson.findMany({
where: { courseId: id },
include: {
teacher: {
select: { id: true, name: true },
},
class: {
select: { id: true, name: true },
},
},
orderBy: { createdAt: 'desc' },
take: 10,
});
const feedbacks = await this.prisma.lessonFeedback.findMany({
where: {
lesson: {
courseId: id,
},
},
});
const calculateAverage = (field: string) => {
const validFeedbacks = feedbacks.filter((f: any) => f[field] != null);
if (validFeedbacks.length === 0) return 0;
const sum = validFeedbacks.reduce((acc: number, f: any) => acc + f[field], 0);
return sum / validFeedbacks.length;
};
const studentRecords = await this.prisma.studentRecord.findMany({
where: {
lesson: {
courseId: id,
},
},
});
const calculateStudentAvg = (field: string) => {
const validRecords = studentRecords.filter((r: any) => r[field] != null);
if (validRecords.length === 0) return 0;
const sum = validRecords.reduce((acc: number, r: any) => acc + r[field], 0);
return sum / validRecords.length;
};
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const recentLessons = await this.prisma.lesson.findMany({
where: {
courseId: id,
createdAt: { gte: weekAgo },
},
select: {
createdAt: true,
},
});
const lessonTrend = [];
for (let i = 6; i >= 0; i--) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const dateStr = date.toLocaleDateString('zh-CN', { weekday: 'short' });
const count = recentLessons.filter((lesson: any) => {
const lessonDate = new Date(lesson.createdAt);
return lessonDate.toDateString() === date.toDateString();
}).length;
lessonTrend.push({ date: dateStr, count });
}
const uniqueStudentIds = new Set();
lessons.forEach((lesson: any) => {
uniqueStudentIds.add(lesson.classId);
});
return {
courseName: course.name,
totalLessons: course.usageCount || lessons.length,
totalTeachers: course.teacherCount || new Set(lessons.map((l: any) => l.teacherId)).size,
totalStudents: uniqueStudentIds.size,
avgRating: course.avgRating || 0,
lessonTrend,
feedbackDistribution: {
designQuality: calculateAverage('designQuality'),
participation: calculateAverage('participation'),
goalAchievement: calculateAverage('goalAchievement'),
totalFeedbacks: feedbacks.length,
},
recentLessons: lessons.map((lesson: any) => ({
...lesson,
date: lesson.createdAt,
})),
studentPerformance: {
avgFocus: calculateStudentAvg('focus'),
avgParticipation: calculateStudentAvg('participation'),
avgInterest: calculateStudentAvg('interest'),
avgUnderstanding: calculateStudentAvg('understanding'),
},
};
}
/**
* 获取审核列表
*/
async getReviewList(query: any) {
const { page = 1, pageSize = 10, status, submittedBy } = query;
const skip = (page - 1) * pageSize;
const take = +pageSize;
const where: any = {
status: { in: ['PENDING', 'REJECTED'] },
};
if (status) {
where.status = status;
}
if (submittedBy) {
where.submittedBy = +submittedBy;
}
const [items, total] = await Promise.all([
this.prisma.course.findMany({
where,
skip,
take,
orderBy: { submittedAt: 'desc' },
select: {
id: true,
name: true,
status: true,
submittedAt: true,
submittedBy: true,
reviewedAt: true,
reviewedBy: true,
reviewComment: true,
coverImagePath: true,
gradeTags: true,
},
}),
this.prisma.course.count({ where }),
]);
return {
items,
total,
page: +page,
pageSize: +pageSize,
};
}
/**
* 获取版本历史
*/
async getVersionHistory(id: number) {
const course = await this.prisma.course.findUnique({
where: { id },
});
if (!course) {
throw new NotFoundException(`Course #${id} not found`);
}
const versions = await this.prisma.courseVersion.findMany({
where: { courseId: id },
orderBy: { publishedAt: 'desc' },
});
return versions.map((v) => ({
id: v.id,
version: v.version,
changeLog: v.changeLog,
publishedAt: v.publishedAt,
publishedBy: v.publishedBy,
}));
}
}