feat: add weekly curriculum cover image upload and display

This commit is contained in:
2026-09-23 16:03:52 +09:00
parent 06b1e9ad60
commit 763ff22284
10 changed files with 7507 additions and 3 deletions
+51
View File
@@ -24,6 +24,7 @@ import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
import { groupCourseScope } from './group-course-scope';
import { normalizeCameraUrl, readWeekCamera } from '../curriculum/week-camera';
import { validateWeekCover } from '../curriculum/week-cover';
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
@@ -1473,6 +1474,8 @@ export class InstructorService {
detail: {
overview: override?.overview ?? week.overview,
camera: readWeekCamera(override),
coverImage: week.references.filter((reference) => reference.kind === 'COVER_IMAGE')
.map((reference) => this.serializeWeekReference(reference, week.week))[0] ?? null,
learningSteps,
topicDetails,
topicVideoUrls,
@@ -1995,6 +1998,51 @@ export class InstructorService {
return this.getCurriculum(accessToken, null, course.id);
}
async uploadCurriculumCover(
accessToken: string | undefined,
input: { courseId?: string; week: number },
file: { originalname: string; buffer?: Buffer } | undefined,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
const week = course.weeks.find((item) => item.week === input.week);
if (!week) throw new NotFoundException('Course week not found');
if (!file?.buffer) throw new BadRequestException('이미지가 필요합니다.');
const format = validateWeekCover(file.buffer);
if (!this.r2PublicFileService.isConfigured()) throw new BadRequestException('이미지 저장소가 설정되지 않았습니다.');
const key = this.r2PublicFileService.createLmsCourseFileKey({
courseId: course.id, week: week.week, kind: 'COVER_IMAGE', fileId: randomUUID(), extension: format.extension,
});
await this.r2PublicFileService.uploadPublicFile({ key, body: file.buffer, contentType: format.contentType });
let previous: Array<{ storedFileName: string }> = [];
try {
await this.prisma.$transaction(async (tx) => {
// Serialize replacements so a week cannot acquire multiple covers.
await tx.$queryRaw`SELECT id FROM course_weeks WHERE id = ${week.id}::uuid FOR UPDATE`;
previous = await tx.courseWeekReference.findMany({ where: { weekId: week.id, kind: 'COVER_IMAGE' } });
await tx.courseWeekReference.deleteMany({ where: { weekId: week.id, kind: 'COVER_IMAGE' } });
await tx.courseWeekReference.create({ data: {
weekId: week.id, kind: 'COVER_IMAGE', title: '대표이미지',
originalFileName: this.normalizeUploadedFileName(file.originalname), storedFileName: key,
contentType: format.contentType, fileSizeBytes: file.buffer!.length, sortOrder: 0,
} });
});
} catch (error) {
await this.r2PublicFileService.deletePublicFile(key).catch(() => undefined);
throw error;
}
// Cloned courses may still reference the same object.
for (const old of previous) {
if (!await this.prisma.courseWeekReference.count({ where: { storedFileName: old.storedFileName } })) {
await this.r2PublicFileService.deletePublicFile(old.storedFileName).catch(() => undefined);
}
}
return this.getCurriculum(accessToken, null, course.id);
}
async uploadCurriculumReference(
accessToken: string | undefined,
input: { courseId?: string | null; week: number; title?: string | null; kind?: 'REFERENCE' | 'EXAMPLE_CODE' },
@@ -2076,6 +2124,9 @@ export class InstructorService {
}
await this.prisma.courseWeekReference.delete({ where: { id: reference.id } });
const sharedCover = reference.kind === 'COVER_IMAGE'
&& await this.prisma.courseWeekReference.count({ where: { storedFileName: reference.storedFileName } }) > 0;
if (sharedCover) return this.getCurriculum(accessToken, null, reference.week.courseId);
if (this.r2PublicFileService.isPublicKey(reference.storedFileName)) {
await this.r2PublicFileService.deletePublicFile(reference.storedFileName).catch(() => undefined);
} else {