fix(lms): support direct R2 map uploads and preserve empty course groups
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { InstructorService } from './instructor.service';
|
||||
import { groupCourseScope } from './group-course-scope';
|
||||
|
||||
describe('group course ownership', () => {
|
||||
const setup = () => {
|
||||
const tx = {
|
||||
studentGroup: { create: jest.fn().mockResolvedValue({ id: 'group' }), update: jest.fn() },
|
||||
studentGroupMember: { deleteMany: jest.fn(), createMany: jest.fn() },
|
||||
};
|
||||
const service = Object.assign(Object.create(InstructorService.prototype), {
|
||||
prisma: { $transaction: async (callback: (client: typeof tx) => Promise<void>) => callback(tx) },
|
||||
getInstructor: jest.fn().mockResolvedValue({
|
||||
authUser: { primaryAffiliation: { organizationId: 'org', organizationName: 'Org' } },
|
||||
instructor: { id: 'teacher' },
|
||||
}),
|
||||
findInstructorCourse: jest.fn().mockResolvedValue({ id: 'course' }),
|
||||
validateActiveStudentIds: jest.fn().mockResolvedValue([]),
|
||||
resolveInstructorPlatformAssignment: jest.fn().mockResolvedValue(null),
|
||||
queuePlatformAccessPolicy: jest.fn(),
|
||||
listGroups: jest.fn().mockResolvedValue({ groups: [{ id: 'group', members: [] }] }),
|
||||
}) as InstructorService;
|
||||
return { service, tx };
|
||||
};
|
||||
|
||||
it('creates a course-owned group even with no students', async () => {
|
||||
const { service, tx } = setup();
|
||||
await expect(service.createGroup('token', { name: 'Empty', memberIds: [], courseId: 'course' }))
|
||||
.resolves.toEqual({ groups: [{ id: 'group', members: [] }] });
|
||||
expect(tx.studentGroup.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ courseId: 'course', name: 'Empty' }),
|
||||
}));
|
||||
expect(tx.studentGroupMember.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a nonexistent or unauthorized course before inserting', async () => {
|
||||
const { service, tx } = setup();
|
||||
jest.spyOn(service as never, 'findInstructorCourse' as never).mockResolvedValue(null as never);
|
||||
await expect(service.createGroup('token', { name: 'Empty', memberIds: [], courseId: 'other' })).rejects.toThrow('Course not found');
|
||||
expect(tx.studentGroup.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not require members for the selected course', () => {
|
||||
const scope = groupCourseScope('teacher', 'course');
|
||||
expect(scope.OR?.[0]).toEqual({ courseId: 'course' });
|
||||
expect(scope.OR?.[1]).toEqual({
|
||||
courseId: null,
|
||||
members: { some: { user: { enrollments: { some: { status: 'ACTIVE', courseId: 'course' } } } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the course association when the last member is removed', async () => {
|
||||
const { service, tx } = setup();
|
||||
Object.assign(service, {
|
||||
findInstructorGroup: jest.fn().mockResolvedValue({ id: 'group' }),
|
||||
getGroupPlatformAssignment: jest.fn().mockResolvedValue({ memberIds: ['student'], instructorPlatformId: null }),
|
||||
prepareGroupPlatformTermination: jest.fn().mockResolvedValue({ sessionIds: [], queueUserIds: [], notices: [] }),
|
||||
notifyPlatformTerminatedSessions: jest.fn(),
|
||||
});
|
||||
await service.updateGroup('token', 'group', { name: 'Empty now', memberIds: [], courseId: 'course' });
|
||||
expect(tx.studentGroup.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ courseId: 'course' }),
|
||||
}));
|
||||
expect(tx.studentGroupMember.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group' } });
|
||||
});
|
||||
|
||||
it('scopes unselected-course queries to the instructor instead of all empty groups', () => {
|
||||
const scope = groupCourseScope('teacher');
|
||||
expect(scope.OR?.[0]).toEqual({ course: { teacherId: 'teacher' } });
|
||||
expect(scope.OR?.[1]).toHaveProperty('courseId', null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
export function groupCourseScope(instructorId: string, courseId?: string | null): Prisma.StudentGroupWhereInput {
|
||||
const enrollmentScope = courseId ? { courseId } : { course: { teacherId: instructorId } };
|
||||
return {
|
||||
OR: [
|
||||
courseId ? { courseId } : { course: { teacherId: instructorId } },
|
||||
{
|
||||
courseId: null,
|
||||
members: { some: { user: { enrollments: { some: { status: 'ACTIVE', ...enrollmentScope } } } } },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { R2PublicFileService } from '../storage/r2-public-file.service';
|
||||
import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto';
|
||||
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
||||
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
||||
import { groupCourseScope } from './group-course-scope';
|
||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||
@@ -1126,6 +1127,8 @@ export class InstructorService {
|
||||
? { courseId: course.id }
|
||||
: { course: { teacherId: instructor.id } };
|
||||
|
||||
if (courseId?.trim() && !course) throw new NotFoundException('Course not found');
|
||||
|
||||
const groups = await this.prisma.studentGroup.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
@@ -1135,20 +1138,7 @@ export class InstructorService {
|
||||
organizationName ? { organizationName } : undefined,
|
||||
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
|
||||
},
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
user: {
|
||||
enrollments: {
|
||||
some: {
|
||||
status: 'ACTIVE',
|
||||
...enrollmentScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
groupCourseScope(instructor.id, course?.id),
|
||||
],
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
@@ -2568,6 +2558,9 @@ export class InstructorService {
|
||||
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 name = input.name.trim();
|
||||
if (!name) throw new BadRequestException('Group name is required');
|
||||
|
||||
@@ -2584,6 +2577,7 @@ export class InstructorService {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const group = await tx.studentGroup.create({
|
||||
data: {
|
||||
courseId: course.id,
|
||||
organizationId,
|
||||
organizationName,
|
||||
name,
|
||||
@@ -2637,7 +2631,9 @@ export class InstructorService {
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
||||
|
||||
const group = await this.findInstructorGroup(groupId, organizationId, organizationName);
|
||||
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
|
||||
if (!course) throw new NotFoundException('Course not found');
|
||||
const group = await this.findInstructorGroup(groupId, organizationId, organizationName, instructor.id, course.id);
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new BadRequestException('Group name is required');
|
||||
const memberIds = await this.validateActiveStudentIds(
|
||||
@@ -2665,6 +2661,7 @@ export class InstructorService {
|
||||
await tx.studentGroup.update({
|
||||
where: { id: group.id },
|
||||
data: {
|
||||
courseId: course.id,
|
||||
name,
|
||||
instructorPlatformId: assignedPlatform?.id ?? null,
|
||||
platformName: assignedPlatform?.displayName ?? null,
|
||||
@@ -2734,12 +2731,14 @@ export class InstructorService {
|
||||
}
|
||||
|
||||
async deleteGroup(accessToken: string | undefined, groupId: string, courseId?: string | null) {
|
||||
const { authUser } = await this.getInstructor(accessToken);
|
||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||
const organizationId = authUser.primaryAffiliation?.organizationId;
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
||||
|
||||
const group = await this.findInstructorGroup(groupId, organizationId, organizationName);
|
||||
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
|
||||
if (!course) throw new NotFoundException('Course not found');
|
||||
const group = await this.findInstructorGroup(groupId, organizationId, organizationName, instructor.id, course.id);
|
||||
const previousAssignment = await this.getGroupPlatformAssignment(group.id);
|
||||
const termination = await this.prepareGroupPlatformTermination({
|
||||
groupId: group.id,
|
||||
@@ -3004,10 +3003,13 @@ export class InstructorService {
|
||||
groupId: string,
|
||||
organizationId: string | undefined,
|
||||
organizationName: string,
|
||||
instructorId: string,
|
||||
courseId: string,
|
||||
) {
|
||||
const group = await this.prisma.studentGroup.findFirst({
|
||||
where: {
|
||||
id: groupId,
|
||||
AND: [groupCourseScope(instructorId, courseId)],
|
||||
OR: [
|
||||
organizationId ? { organizationId } : undefined,
|
||||
{ organizationName },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { R2PublicFileService } from './r2-public-file.service';
|
||||
|
||||
describe('R2 direct upload URL', () => {
|
||||
it('signs a PUT without a checksum for an empty body', async () => {
|
||||
const service = new R2PublicFileService(new ConfigService({
|
||||
R2_BUCKET: 'test', R2_ACCOUNT_ID: 'test',
|
||||
R2_PUBLIC_BASE_URL: 'https://files.example.com',
|
||||
R2_ACCESS_KEY_ID: 'test', R2_SECRET_ACCESS_KEY: 'test',
|
||||
}));
|
||||
const url = new URL((await service.createUploadUrl('studio-files/public/test.db'))!);
|
||||
expect(url.protocol).toBe('https:');
|
||||
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
|
||||
expect(url.searchParams.has('X-Amz-Signature')).toBe(true);
|
||||
expect(url.searchParams.has('x-amz-checksum-crc32')).toBe(false);
|
||||
expect(await service.createUploadUrl('private/test.db')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@@ -86,6 +87,21 @@ export class R2PublicFileService {
|
||||
}));
|
||||
}
|
||||
|
||||
async createUploadUrl(key: string) {
|
||||
if (!this.isConfigured() || !this.isPublicKey(key)) return null;
|
||||
return getSignedUrl(this.getClient(), new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
ContentType: 'application/octet-stream',
|
||||
}), { expiresIn: 3600 });
|
||||
}
|
||||
|
||||
async verifyUploadedFile(key: string) {
|
||||
if (!this.isConfigured() || !this.isPublicKey(key)) return null;
|
||||
const result = await this.getClient().send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
return (result.ContentLength ?? 0) > 0 ? this.publicUrl(key) : null;
|
||||
}
|
||||
|
||||
publicUrl(key: string) {
|
||||
if (!this.publicBaseUrl || !this.isPublicKey(key)) return null;
|
||||
return `${this.publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`;
|
||||
@@ -109,6 +125,8 @@ export class R2PublicFileService {
|
||||
secretAccessKey: this.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false',
|
||||
// Presigned PUT bodies are supplied later by the platform, not this client.
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
});
|
||||
|
||||
return this.client;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { StudioService } from './studio.service';
|
||||
|
||||
describe('remote map database upload', () => {
|
||||
const uploadId = 'de091bb8-9a0a-48f9-82ae-439fa55cc71a';
|
||||
const input = {
|
||||
courseId: 'course', week: '1', yamlPath: '/workspace/maps/test.yaml',
|
||||
pgmPath: '/workspace/maps/test.pgm', databasePath: '/workspace/maps/test.db',
|
||||
sourceMapKey: 'platform-3d', viewerModel: 'mkmini', runtimeKind: 'PLATFORM_3D',
|
||||
databaseUploadId: uploadId,
|
||||
};
|
||||
const files = {
|
||||
yaml: { originalname: 'test.yaml', buffer: Buffer.from('yaml') },
|
||||
pgm: { originalname: 'test.pgm', buffer: Buffer.from('pgm') },
|
||||
};
|
||||
const setup = () => {
|
||||
const prisma = {
|
||||
user: { findUnique: jest.fn().mockResolvedValue({ id: 'student' }) },
|
||||
studentEnrollment: { findFirst: jest.fn().mockResolvedValue({ id: 'enrollment' }) },
|
||||
studentSavedMap: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
upsert: jest.fn().mockImplementation(({ create }) => create),
|
||||
},
|
||||
};
|
||||
const storage = {
|
||||
isConfigured: () => true,
|
||||
createLmsSavedMapFileKey: jest.fn(({ userId, courseId, week, mapId, fileName }) =>
|
||||
`${userId}/${courseId}/${week}/${mapId}/${fileName}`),
|
||||
createUploadUrl: jest.fn().mockResolvedValue('https://example.r2.cloudflarestorage.com/upload'),
|
||||
verifyUploadedFile: jest.fn().mockResolvedValue('https://files.example/test.db'),
|
||||
uploadPublicFile: jest.fn().mockResolvedValue('https://files.example/small-file'),
|
||||
};
|
||||
const service = new StudioService(
|
||||
{ me: jest.fn().mockResolvedValue({ user: { email: 'student@example.com' } }) } as never,
|
||||
new ConfigService(), prisma as never, storage as never,
|
||||
);
|
||||
return { service, storage, prisma };
|
||||
};
|
||||
|
||||
it('issues an upload only for an enrolled student and a flat database filename', async () => {
|
||||
const { service, storage, prisma } = setup();
|
||||
await expect(service.prepareDatabaseUpload('token', { courseId: 'course', week: 1, databasePath: input.databasePath }))
|
||||
.resolves.toHaveProperty('uploadUrl');
|
||||
expect(storage.createLmsSavedMapFileKey).toHaveBeenCalledWith(expect.objectContaining({ userId: 'student', courseId: 'course', week: 1 }));
|
||||
await expect(service.prepareDatabaseUpload('token', { courseId: 'course', week: 1, databasePath: '/workspace/maps/../test.db' })).rejects.toThrow();
|
||||
prisma.studentEnrollment.findFirst.mockResolvedValue(null);
|
||||
await expect(service.prepareDatabaseUpload('token', { courseId: 'other', week: 1, databasePath: input.databasePath })).rejects.toThrow();
|
||||
expect(storage.createUploadUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('verifies an owner-scoped R2 object before recording and uploads only YAML/PGM', async () => {
|
||||
const { service, storage } = setup();
|
||||
const result = await service.uploadSavedMap('token', input, files);
|
||||
expect(storage.verifyUploadedFile).toHaveBeenCalledWith(`student/course/1/${uploadId}/test.db`);
|
||||
expect(storage.uploadPublicFile).toHaveBeenCalledTimes(2);
|
||||
expect(result.databaseUrl).toBe('https://files.example/test.db');
|
||||
});
|
||||
|
||||
it('does not register a missing or incomplete database', async () => {
|
||||
const { service, storage, prisma } = setup();
|
||||
storage.verifyUploadedFile.mockResolvedValue(null);
|
||||
await expect(service.uploadSavedMap('token', input, files)).rejects.toThrow('Database upload is not complete');
|
||||
expect(prisma.studentSavedMap.upsert).not.toHaveBeenCalled();
|
||||
expect(storage.uploadPublicFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains the existing multipart 2D and 3D paths', async () => {
|
||||
const { service, storage } = setup();
|
||||
await service.uploadSavedMap('token', { ...input, databaseUploadId: undefined, databasePath: undefined, runtimeKind: 'PLATFORM_2D' }, files);
|
||||
await service.uploadSavedMap('token', { ...input, databaseUploadId: undefined }, {
|
||||
...files, database: { originalname: 'test.db', buffer: Buffer.from('database') },
|
||||
});
|
||||
expect(storage.uploadPublicFile).toHaveBeenCalledTimes(5);
|
||||
expect(storage.verifyUploadedFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,7 @@ export class StudioController {
|
||||
uploadSavedMap(
|
||||
@Req() request: Request,
|
||||
@UploadedFiles() files: Record<string, Array<{ originalname: string; mimetype?: string; size?: number; buffer?: Buffer }>>,
|
||||
@Body() body: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string },
|
||||
@Body() body: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string; databaseUploadId?: string },
|
||||
) {
|
||||
return this.studioService.uploadSavedMap(this.getBearerToken(request), body, {
|
||||
yaml: files?.yaml?.[0],
|
||||
@@ -106,6 +106,14 @@ export class StudioController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('maps/database-upload')
|
||||
prepareDatabaseUpload(
|
||||
@Req() request: Request,
|
||||
@Body() body: { courseId?: string; week?: number; databasePath?: string },
|
||||
) {
|
||||
return this.studioService.prepareDatabaseUpload(this.getBearerToken(request), body);
|
||||
}
|
||||
|
||||
@Delete('maps/:id')
|
||||
deleteSavedMap(@Req() request: Request, @Param('id') id: string) {
|
||||
return this.studioService.deleteSavedMap(this.getBearerToken(request), id);
|
||||
|
||||
@@ -666,9 +666,37 @@ export class StudioService {
|
||||
});
|
||||
}
|
||||
|
||||
async prepareDatabaseUpload(
|
||||
accessToken: string | undefined,
|
||||
input: { courseId?: string; week?: number; databasePath?: string },
|
||||
) {
|
||||
const { user: authUser } = await this.authService.me(accessToken);
|
||||
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
|
||||
const courseId = input.courseId?.trim();
|
||||
const week = Number(input.week);
|
||||
if (!courseId || !Number.isInteger(week) || week < 1) throw new BadRequestException('courseId and week are required');
|
||||
if (!/^\/workspace\/maps\/[A-Za-z0-9_-][A-Za-z0-9._-]*\.db$/.test(input.databasePath ?? '')) {
|
||||
throw new BadRequestException('databasePath is invalid');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { email: authUser.email } });
|
||||
if (!user) throw new NotFoundException('LMS user not found');
|
||||
const enrollment = await this.prisma.studentEnrollment.findFirst({
|
||||
where: { userId: user.id, courseId, status: 'ACTIVE' },
|
||||
});
|
||||
if (!enrollment) throw new NotFoundException('Student enrollment not found');
|
||||
const databaseUploadId = randomUUID();
|
||||
const key = this.r2PublicFileService.createLmsSavedMapFileKey({
|
||||
userId: user.id, courseId, week, mapId: databaseUploadId,
|
||||
fileName: input.databasePath!.split('/').pop()!,
|
||||
});
|
||||
const uploadUrl = await this.r2PublicFileService.createUploadUrl(key);
|
||||
if (!uploadUrl) throw new BadGatewayException('R2 public storage is not configured');
|
||||
return { databaseUploadId, uploadUrl };
|
||||
}
|
||||
|
||||
async uploadSavedMap(
|
||||
accessToken: string | undefined,
|
||||
input: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string },
|
||||
input: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string; databaseUploadId?: string },
|
||||
files: {
|
||||
yaml?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer };
|
||||
pgm?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer };
|
||||
@@ -701,7 +729,10 @@ export class StudioService {
|
||||
if (!sourceMapKey || !viewerModel) throw new BadRequestException('source map is required');
|
||||
if (!['SIMULATION', 'PLATFORM_2D', 'PLATFORM_3D'].includes(runtimeKind)) throw new BadRequestException('runtimeKind is invalid');
|
||||
if (!files.yaml?.buffer || !files.pgm?.buffer) throw new BadRequestException('yaml and pgm files are required');
|
||||
if (databasePath && !files.database?.buffer) throw new BadRequestException('database file is required');
|
||||
if (databasePath && !files.database?.buffer && !input.databaseUploadId) throw new BadRequestException('database file is required');
|
||||
if (input.databaseUploadId && (!databasePath || files.database?.buffer || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.databaseUploadId))) {
|
||||
throw new BadRequestException('databaseUploadId is invalid');
|
||||
}
|
||||
|
||||
const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } });
|
||||
if (!lmsUser) throw new NotFoundException('LMS user not found');
|
||||
@@ -710,6 +741,17 @@ export class StudioService {
|
||||
});
|
||||
if (!enrollment) throw new NotFoundException('Student enrollment not found');
|
||||
|
||||
// Reconstruct the key from the authenticated owner, never trust a client URL.
|
||||
let uploadedDatabaseUrl: string | null = null;
|
||||
if (input.databaseUploadId && databasePath) {
|
||||
const key = this.r2PublicFileService.createLmsSavedMapFileKey({
|
||||
userId: lmsUser.id, courseId, week, mapId: input.databaseUploadId,
|
||||
fileName: databasePath.split('/').pop()!,
|
||||
});
|
||||
uploadedDatabaseUrl = await this.r2PublicFileService.verifyUploadedFile(key).catch(() => null);
|
||||
if (!uploadedDatabaseUrl) throw new BadRequestException('Database upload is not complete');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.studentSavedMap.findUnique({
|
||||
where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week, yamlPath } },
|
||||
});
|
||||
@@ -737,7 +779,7 @@ export class StudioService {
|
||||
body: files.database.buffer,
|
||||
contentType: files.database.mimetype || 'application/octet-stream',
|
||||
})
|
||||
: null;
|
||||
: uploadedDatabaseUrl;
|
||||
if (!yamlUrl || !pgmUrl || (databasePath && !databaseUrl)) {
|
||||
throw new BadGatewayException('R2 saved map upload failed');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user