feat: add weekly curriculum cover image upload and display
This commit is contained in:
@@ -147,6 +147,17 @@ export class InstructorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('curriculum/weeks/:week/cover-image')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 8 * 1024 * 1024 } }))
|
||||
uploadCurriculumCover(
|
||||
@Req() request: Request,
|
||||
@Param('week', ParseIntPipe) week: number,
|
||||
@UploadedFile() file: { originalname: string; buffer?: Buffer },
|
||||
@Body('courseId') courseId?: string,
|
||||
) {
|
||||
return this.instructorService.uploadCurriculumCover(this.getBearerToken(request), { courseId, week }, file);
|
||||
}
|
||||
|
||||
@Post('curriculum/weeks/:week/example-codes')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadCurriculumExampleCode(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { InstructorService } from './instructor.service';
|
||||
import { validateWeekCover, WEEK_COVER_MAX_BYTES } from '../curriculum/week-cover';
|
||||
|
||||
const png = Buffer.from('89504e470d0a1a0a0000000d494844520000000100000001', 'hex');
|
||||
describe('week cover images', () => {
|
||||
function setup() {
|
||||
const references = {
|
||||
findMany: jest.fn().mockResolvedValue([{ storedFileName: 'old-cover' }]),
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
create: jest.fn().mockResolvedValue({ id: 'cover' }),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const tx = { courseWeekReference: references, $queryRaw: jest.fn() };
|
||||
const prisma = { courseWeekReference: references, $transaction: jest.fn(async (fn) => fn(tx)) };
|
||||
const storage = {
|
||||
isConfigured: () => true,
|
||||
createLmsCourseFileKey: jest.fn().mockReturnValue('new-cover'),
|
||||
uploadPublicFile: jest.fn().mockResolvedValue('https://files.example/new-cover'),
|
||||
deletePublicFile: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new InstructorService({} as never, {} as never, prisma as never, {} as never, {} as never, storage as never);
|
||||
const internals = service as any;
|
||||
internals.getInstructor = jest.fn().mockResolvedValue({ authUser: { primaryAffiliation: { organizationName: 'org' } }, instructor: { id: 'teacher' } });
|
||||
internals.findInstructorCourse = jest.fn().mockResolvedValue({ id: 'course', weeks: [{ id: 'week', week: 2 }] });
|
||||
internals.getCurriculum = jest.fn().mockResolvedValue({ weeks: [] });
|
||||
return { service, internals, prisma, references, storage, tx };
|
||||
}
|
||||
const input = { courseId: 'course', week: 2 };
|
||||
const file = { originalname: 'photo.png', buffer: png };
|
||||
|
||||
it('accepts supported signatures and rejects SVG, empty and oversized uploads', () => {
|
||||
expect(validateWeekCover(png).contentType).toBe('image/png');
|
||||
expect(validateWeekCover(Buffer.from([255, 216, 255, 224])).extension).toBe('.jpg');
|
||||
expect(validateWeekCover(Buffer.from('RIFF0000WEBPVP8 ')).extension).toBe('.webp');
|
||||
for (const buffer of [Buffer.alloc(0), Buffer.from('<svg/>'), Buffer.alloc(WEEK_COVER_MAX_BYTES + 1)]) {
|
||||
expect(() => validateWeekCover(buffer)).toThrow();
|
||||
}
|
||||
});
|
||||
it('authorizes course ownership before uploading', async () => {
|
||||
const { service, internals, storage } = setup();
|
||||
internals.findInstructorCourse.mockResolvedValue(null);
|
||||
await expect(service.uploadCurriculumCover('token', input, file)).rejects.toThrow('Course not found');
|
||||
expect(storage.uploadPublicFile).not.toHaveBeenCalled();
|
||||
});
|
||||
it('replaces only the cover under a week lock and stores R2 metadata', async () => {
|
||||
const { service, tx, references, storage } = setup();
|
||||
await service.uploadCurriculumCover('token', input, file);
|
||||
expect(tx.$queryRaw).toHaveBeenCalled();
|
||||
expect(references.deleteMany).toHaveBeenCalledWith({ where: { weekId: 'week', kind: 'COVER_IMAGE' } });
|
||||
expect(references.create).toHaveBeenCalledWith({ data: expect.objectContaining({ kind: 'COVER_IMAGE', storedFileName: 'new-cover', contentType: 'image/png' }) });
|
||||
expect(storage.deletePublicFile).toHaveBeenCalledWith('old-cover');
|
||||
});
|
||||
it('retains an old object used by a cloned course', async () => {
|
||||
const { service, references, storage } = setup();
|
||||
references.count.mockResolvedValue(1);
|
||||
await service.uploadCurriculumCover('token', input, file);
|
||||
expect(storage.deletePublicFile).not.toHaveBeenCalled();
|
||||
});
|
||||
it('cleans up the new object if database persistence fails', async () => {
|
||||
const { service, prisma, storage } = setup();
|
||||
prisma.$transaction.mockRejectedValue(new Error('db failed'));
|
||||
await expect(service.uploadCurriculumCover('token', input, file)).rejects.toThrow('db failed');
|
||||
expect(storage.deletePublicFile).toHaveBeenCalledWith('new-cover');
|
||||
expect(storage.deletePublicFile).not.toHaveBeenCalledWith('old-cover');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user