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 },
|
||||
|
||||
Reference in New Issue
Block a user