diff --git a/frontend/src/app/assignment/assignment.module.ts b/frontend/src/app/assignment/assignment.module.ts index 7d8ca94a6..f6d5a692a 100644 --- a/frontend/src/app/assignment/assignment.module.ts +++ b/frontend/src/app/assignment/assignment.module.ts @@ -31,6 +31,7 @@ import {AssigneeService} from "./services/assignee.service"; import {EvaluationService} from "./services/evaluation.service"; import {EmbeddingService} from "./services/embedding.service"; import {KeycloakBearerInterceptor} from "keycloak-angular"; +import {MemberService} from "./services/member.service"; @NgModule({ declarations: [ @@ -77,6 +78,7 @@ import {KeycloakBearerInterceptor} from "keycloak-angular"; AssigneeService, EvaluationService, EmbeddingService, + MemberService, ], }) export class AssignmentModule { diff --git a/frontend/src/app/assignment/modules/assignment/assignment.module.ts b/frontend/src/app/assignment/modules/assignment/assignment.module.ts index 2b1961280..48be25ee6 100644 --- a/frontend/src/app/assignment/modules/assignment/assignment.module.ts +++ b/frontend/src/app/assignment/modules/assignment/assignment.module.ts @@ -26,8 +26,6 @@ import {SubmitModalComponent} from './submit-modal/submit-modal.component'; import {AssignmentTasksComponent} from './tasks/tasks.component'; import {StatisticsService} from "./statistics.service"; import {SubmitService} from "./submit.service"; -import {MemberService} from "./member.service"; -import {UserModule} from "../../../user/user.module"; @NgModule({ declarations: [ @@ -56,10 +54,8 @@ import {UserModule} from "../../../user/user.module"; NgbAccordionModule, RouteTabsModule, ModalModule, - UserModule, ], providers: [ - MemberService, StatisticsService, SubmitService, ], diff --git a/frontend/src/app/assignment/modules/assignment/share/share.component.html b/frontend/src/app/assignment/modules/assignment/share/share.component.html index 164622981..78d2188d5 100644 --- a/frontend/src/app/assignment/modules/assignment/share/share.component.html +++ b/frontend/src/app/assignment/modules/assignment/share/share.component.html @@ -11,19 +11,7 @@
Share this link with your students. They will be able to submit solutions to this assignment.
-
- Members - - -
-
- -
- Add teaching assistants and other people who should have full access to this assignment. -
-
+
The following fields contain the assignment token, a secret key that can be used to access and grade all submissions. diff --git a/frontend/src/app/assignment/modules/assignment/share/share.component.ts b/frontend/src/app/assignment/modules/assignment/share/share.component.ts index e7c17d7f4..756e1b5c3 100644 --- a/frontend/src/app/assignment/modules/assignment/share/share.component.ts +++ b/frontend/src/app/assignment/modules/assignment/share/share.component.ts @@ -1,14 +1,9 @@ import {DOCUMENT} from '@angular/common'; import {Component, Inject, OnInit} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; -import {switchMap, tap} from 'rxjs/operators'; +import {switchMap} from 'rxjs/operators'; import {AssignmentService} from '../../../services/assignment.service'; import Assignment, {ReadAssignmentDto} from "../../../model/assignment"; -import {MemberService} from "../member.service"; -import {Member} from "../../../../user/member"; -import {User} from "../../../../user/user"; -import {forkJoin} from "rxjs"; -import {UserService} from "../../../../user/user.service"; @Component({ selector: 'app-assignment-share', @@ -17,16 +12,11 @@ import {UserService} from "../../../../user/user.service"; }) export class ShareComponent implements OnInit { assignment?: Assignment | ReadAssignmentDto; - members: Member[]; - - newMember?: User; readonly origin: string; constructor( private assignmentService: AssignmentService, - private memberService: MemberService, - private userService: UserService, private route: ActivatedRoute, @Inject(DOCUMENT) document: Document, ) { @@ -39,14 +29,6 @@ export class ShareComponent implements OnInit { ).subscribe(assignment => { this.assignment = assignment; }); - - this.route.params.pipe( - switchMap(({aid}) => this.memberService.findAll(aid)), - tap(members => this.members = members), - switchMap(members => forkJoin(members.map(member => this.userService.findOne(member.user).pipe( - tap(user => member._user = user), - )))), - ).subscribe(); } regenerateToken() { @@ -59,20 +41,5 @@ export class ShareComponent implements OnInit { }); } - addMember() { - this.memberService.update({ - parent: this.assignment!._id, - user: this.newMember!.id!, - _user: this.newMember!, - }).subscribe(member => { - this.members.push(member); - this.newMember = undefined; - }); - } - - deleteMember(member: Member) { - this.memberService.delete(member).subscribe(() => { - this.members.splice(this.members.indexOf(member), 1); - }); - } + protected readonly switchMap = switchMap; } diff --git a/frontend/src/app/assignment/modules/course/share/share.component.html b/frontend/src/app/assignment/modules/course/share/share.component.html index 7eb4aec03..e5ad5ad8e 100644 --- a/frontend/src/app/assignment/modules/course/share/share.component.html +++ b/frontend/src/app/assignment/modules/course/share/share.component.html @@ -2,12 +2,14 @@
Student Link
-

- You can now give your students the following link: -

- +
+
+ Share this link with your students. They will be able to submit solutions for this course. +
+
+
diff --git a/frontend/src/app/assignment/modules/course/share/share.component.ts b/frontend/src/app/assignment/modules/course/share/share.component.ts index 4f98aa85b..a65200498 100644 --- a/frontend/src/app/assignment/modules/course/share/share.component.ts +++ b/frontend/src/app/assignment/modules/course/share/share.component.ts @@ -1,19 +1,31 @@ import {DOCUMENT} from '@angular/common'; -import {Component, Inject} from '@angular/core'; +import {Component, Inject, OnInit} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; +import {CourseService} from "../../../services/course.service"; +import {switchMap} from "rxjs/operators"; +import Course from "../../../model/course"; @Component({ selector: 'app-assignment-share', templateUrl: './share.component.html', styleUrls: ['./share.component.scss'], }) -export class ShareComponent { +export class ShareComponent implements OnInit { + course?: Course; + readonly origin: string; constructor( - public readonly route: ActivatedRoute, + private courseService: CourseService, + private route: ActivatedRoute, @Inject(DOCUMENT) document: Document, ) { this.origin = document.location.origin; } + + ngOnInit() { + this.route.params.pipe( + switchMap(({cid}) => this.courseService.get(cid)), + ).subscribe(course => this.course = course); + } } diff --git a/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.html b/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.html new file mode 100644 index 000000000..6e12de77c --- /dev/null +++ b/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.html @@ -0,0 +1,13 @@ +
+ Members + + +
+
+ +
+ Add teaching assistants and other people who should have full access to this {{ namespace|slice:0:-1 }}. +
+
diff --git a/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.scss b/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.ts b/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.ts new file mode 100644 index 000000000..ab736a625 --- /dev/null +++ b/frontend/src/app/assignment/modules/shared/edit-member-list/edit-member-list.component.ts @@ -0,0 +1,54 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {Member} from "../../../../user/member"; +import {User} from "../../../../user/user"; +import {switchMap, tap} from "rxjs/operators"; +import {forkJoin} from "rxjs"; +import {MemberService, Namespace} from "../../../services/member.service"; +import {UserService} from "../../../../user/user.service"; + +@Component({ + selector: 'app-edit-member-list', + templateUrl: './edit-member-list.component.html', + styleUrls: ['./edit-member-list.component.scss'] +}) +export class EditMemberListComponent implements OnInit { + @Input({required: true}) namespace: Namespace; + @Input({required: true}) parent: string; + @Input() owner?: string; + + members: Member[]; + + newMember?: User; + + constructor( + private memberService: MemberService, + private userService: UserService, + ) { + } + + ngOnInit() { + this.memberService.findAll(this.namespace, this.parent).pipe( + tap(members => this.members = members), + switchMap(members => forkJoin(members.map(member => this.userService.findOne(member.user).pipe( + tap(user => member._user = user), + )))), + ).subscribe(); + } + + addMember() { + this.memberService.update(this.namespace, { + parent: this.parent, + user: this.newMember!.id!, + _user: this.newMember!, + }).subscribe(member => { + this.members.push(member); + this.newMember = undefined; + }); + } + + deleteMember(member: Member) { + this.memberService.delete(this.namespace, member.parent, member.user).subscribe(() => { + this.members.splice(this.members.indexOf(member), 1); + }); + } +} diff --git a/frontend/src/app/assignment/modules/shared/shared.module.ts b/frontend/src/app/assignment/modules/shared/shared.module.ts index 8d4f68f37..5ba30b89d 100644 --- a/frontend/src/app/assignment/modules/shared/shared.module.ts +++ b/frontend/src/app/assignment/modules/shared/shared.module.ts @@ -16,6 +16,8 @@ import {GithubLinkPipe} from './pipes/github-link.pipe'; import {CloneLinkPipe} from './pipes/clone-link.pipe'; import {AssignmentActionsComponent} from './assignment-actions/assignment-actions.component'; import {AssigneeInputComponent} from './assignee-input/assignee-input.component'; +import {EditMemberListComponent} from './edit-member-list/edit-member-list.component'; +import {UserModule} from "../../../user/user.module"; @NgModule({ imports: [ @@ -26,6 +28,7 @@ import {AssigneeInputComponent} from './assignee-input/assignee-input.component' NgbDropdownModule, NgbTypeaheadModule, FormsModule, + UserModule, ], declarations: [ AssignmentInfoComponent, @@ -40,6 +43,7 @@ import {AssigneeInputComponent} from './assignee-input/assignee-input.component' CloneLinkPipe, AssignmentActionsComponent, AssigneeInputComponent, + EditMemberListComponent, ], exports: [ AssignmentInfoComponent, @@ -54,6 +58,7 @@ import {AssigneeInputComponent} from './assignee-input/assignee-input.component' CloneLinkPipe, AssignmentActionsComponent, AssigneeInputComponent, + EditMemberListComponent, ], }) export class AssignmentSharedModule { diff --git a/frontend/src/app/assignment/services/course.service.ts b/frontend/src/app/assignment/services/course.service.ts index 3f8631494..5e0e458b0 100644 --- a/frontend/src/app/assignment/services/course.service.ts +++ b/frontend/src/app/assignment/services/course.service.ts @@ -73,7 +73,12 @@ export class CourseService { getOwn(): Observable { return this.userService.getCurrent().pipe( - switchMap(user => user ? this.http.get(`${environment.assignmentsApiUrl}/courses`, {params: {createdBy: user.id!}}) : of([])), + switchMap(user => user ? this.http.get(`${environment.assignmentsApiUrl}/courses`, { + params: { + createdBy: user.id!, + members: [user.id!], + }, + }) : of([])), ); } } diff --git a/frontend/src/app/assignment/modules/assignment/member.service.ts b/frontend/src/app/assignment/services/member.service.ts similarity index 52% rename from frontend/src/app/assignment/modules/assignment/member.service.ts rename to frontend/src/app/assignment/services/member.service.ts index 2bfcb818d..8e4591421 100644 --- a/frontend/src/app/assignment/modules/assignment/member.service.ts +++ b/frontend/src/app/assignment/services/member.service.ts @@ -2,8 +2,10 @@ import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {tap} from 'rxjs/operators'; -import {Member} from "../../../user/member"; -import {environment} from "../../../../environments/environment"; +import {Member} from "../../user/member"; +import {environment} from "../../../environments/environment"; + +export type Namespace = 'assignments' | 'courses'; @Injectable() export class MemberService { @@ -12,18 +14,18 @@ export class MemberService { ) { } - findAll(id: string): Observable { - return this.http.get(`${environment.assignmentsApiUrl}/assignments/${id}/members`); + findAll(namespace: Namespace, parent: string): Observable { + return this.http.get(`${environment.assignmentsApiUrl}/${namespace}/${parent}/members`); } - update(member: Member): Observable { + update(namespace: Namespace, member: Member): Observable { const {_user, parent, user, ...rest} = member; - return this.http.put(`${environment.assignmentsApiUrl}/assignments/${parent}/members/${user}`, rest).pipe( + return this.http.put(`${environment.assignmentsApiUrl}/${namespace}/${parent}/members/${user}`, rest).pipe( tap(newMember => newMember._user = _user), ); } - delete({parent, user}: Member): Observable { - return this.http.delete(`${environment.assignmentsApiUrl}/assignments/${parent}/members/${user}`); + delete(namespace: Namespace, parent: string, user: string): Observable { + return this.http.delete(`${environment.assignmentsApiUrl}/${namespace}/${parent}/members/${user}`); } } diff --git a/services/apps/assignments/src/member/member.controller.ts b/services/apps/assignments/src/assignment-member/assignment-member.controller.ts similarity index 98% rename from services/apps/assignments/src/member/member.controller.ts rename to services/apps/assignments/src/assignment-member/assignment-member.controller.ts index 0ad53d627..8bb8a67ce 100644 --- a/services/apps/assignments/src/member/member.controller.ts +++ b/services/apps/assignments/src/assignment-member/assignment-member.controller.ts @@ -12,7 +12,7 @@ const removeOwnerResponse = 'Owner cannot remove themselves as member'; @Controller('assignments/:assignment/members') @ApiTags('Assignment Members') -export class MemberController { +export class AssignmentMemberController { constructor( private readonly memberService: MemberService, ) { diff --git a/services/apps/assignments/src/member/member.handler.ts b/services/apps/assignments/src/assignment-member/assignment-member.handler.ts similarity index 94% rename from services/apps/assignments/src/member/member.handler.ts rename to services/apps/assignments/src/assignment-member/assignment-member.handler.ts index 6f61f0bee..6b79e0054 100644 --- a/services/apps/assignments/src/member/member.handler.ts +++ b/services/apps/assignments/src/assignment-member/assignment-member.handler.ts @@ -4,7 +4,7 @@ import {Injectable} from "@nestjs/common"; import {MemberService} from "@app/member"; @Injectable() -export class MemberHandler { +export class AssignmentMemberHandler { constructor( readonly memberService: MemberService, ) { diff --git a/services/apps/assignments/src/member/member.module.ts b/services/apps/assignments/src/assignment-member/assignment-member.module.ts similarity index 66% rename from services/apps/assignments/src/member/member.module.ts rename to services/apps/assignments/src/assignment-member/assignment-member.module.ts index 653dadb8b..63b86ea33 100644 --- a/services/apps/assignments/src/member/member.module.ts +++ b/services/apps/assignments/src/assignment-member/assignment-member.module.ts @@ -1,8 +1,8 @@ import {forwardRef, Module} from '@nestjs/common'; import {MongooseModule} from '@nestjs/mongoose'; import {Member, MemberAuthGuard, MemberSchema, MemberService} from '@app/member'; -import {MemberController} from './member.controller'; -import {MemberHandler} from "./member.handler"; +import {AssignmentMemberController} from './assignment-member.controller'; +import {AssignmentMemberHandler} from "./assignment-member.handler"; import {AssignmentModule} from "../assignment/assignment.module"; @Module({ @@ -10,16 +10,16 @@ import {AssignmentModule} from "../assignment/assignment.module"; MongooseModule.forFeature([{name: Member.name, schema: MemberSchema}]), forwardRef(() => AssignmentModule), ], - controllers: [MemberController], + controllers: [AssignmentMemberController], providers: [ MemberService, MemberAuthGuard, - MemberHandler, + AssignmentMemberHandler, ], exports: [ MemberService, MemberAuthGuard, ], }) -export class MemberModule { +export class AssignmentMemberModule { } diff --git a/services/apps/assignments/src/assignment/assignment.module.ts b/services/apps/assignments/src/assignment/assignment.module.ts index de326784c..712cd84f4 100644 --- a/services/apps/assignments/src/assignment/assignment.module.ts +++ b/services/apps/assignments/src/assignment/assignment.module.ts @@ -5,7 +5,7 @@ import {AssignmentAuthGuard} from './assignment-auth.guard'; import {AssignmentController} from './assignment.controller'; import {Assignment, AssignmentSchema} from './assignment.schema'; import {AssignmentService} from './assignment.service'; -import {MemberModule} from "../member/member.module"; +import {AssignmentMemberModule} from "../assignment-member/assignment-member.module"; @Module({ imports: [ @@ -16,7 +16,7 @@ import {MemberModule} from "../member/member.module"; }, ]), HttpModule, - MemberModule, + AssignmentMemberModule, ], controllers: [AssignmentController], providers: [ diff --git a/services/apps/assignments/src/assignments.module.ts b/services/apps/assignments/src/assignments.module.ts index 7d804e255..bbcc93626 100644 --- a/services/apps/assignments/src/assignments.module.ts +++ b/services/apps/assignments/src/assignments.module.ts @@ -19,7 +19,8 @@ import {APP_INTERCEPTOR} from "@nestjs/core"; import {EmbeddingModule} from './embedding/embedding.module'; import {MossModule} from './moss/moss.module'; import { FileModule } from './file/file.module'; -import {MemberModule} from "./member/member.module"; +import {AssignmentMemberModule} from "./assignment-member/assignment-member.module"; +import {CourseMemberModule} from "./course-member/course-member.module"; @Module({ imports: [ @@ -38,12 +39,13 @@ import {MemberModule} from "./member/member.module"; }, }), AssignmentModule, - MemberModule, + AssignmentMemberModule, ClassroomModule, SolutionModule, AssigneeModule, CommentModule, CourseModule, + CourseMemberModule, EvaluationModule, SearchModule, StatisticsModule, diff --git a/services/apps/assignments/src/course-member/course-auth.decorator.ts b/services/apps/assignments/src/course-member/course-auth.decorator.ts new file mode 100644 index 000000000..2eb582203 --- /dev/null +++ b/services/apps/assignments/src/course-member/course-auth.decorator.ts @@ -0,0 +1,12 @@ +import {Auth} from '@app/keycloak-auth'; +import {applyDecorators, UseGuards} from '@nestjs/common'; +import {ApiForbiddenResponse} from '@nestjs/swagger'; +import {CourseAuthGuard} from './course-auth.guard'; + +export function CourseAuth(options: { forbiddenResponse: string }) { + return applyDecorators( + Auth(), + ApiForbiddenResponse({description: options.forbiddenResponse}), + UseGuards(CourseAuthGuard), + ); +} diff --git a/services/apps/assignments/src/course-member/course-auth.guard.ts b/services/apps/assignments/src/course-member/course-auth.guard.ts new file mode 100644 index 000000000..e118f8101 --- /dev/null +++ b/services/apps/assignments/src/course-member/course-auth.guard.ts @@ -0,0 +1,31 @@ +import {UserToken} from '@app/keycloak-auth'; +import {CanActivate, ExecutionContext, Injectable} from '@nestjs/common'; +import {Request} from 'express'; +import {Observable} from 'rxjs'; +import {notFound} from '@mean-stream/nestx'; +import {CourseService} from "../course/course.service"; +import {MemberService} from "@app/member"; + +@Injectable() +export class CourseAuthGuard implements CanActivate { + constructor( + private courseService: CourseService, + private memberService: MemberService, + ) { + } + + canActivate(context: ExecutionContext): boolean | Promise | Observable { + const req = context.switchToHttp().getRequest() as Request; + const id = req.params.course ?? req.params.id; + const user = (req as any).user; + return user && this.checkAuth(id, user); + } + + async checkAuth(id: string, user: UserToken): Promise { + const course = await this.courseService.findOne(id) ?? notFound(id); + return course.createdBy === user.sub || !!await this.memberService.findOne({ + parent: course._id, + user: user.sub, + }); + } +} diff --git a/services/apps/assignments/src/course-member/course-member.controller.ts b/services/apps/assignments/src/course-member/course-member.controller.ts new file mode 100644 index 000000000..b980a8df1 --- /dev/null +++ b/services/apps/assignments/src/course-member/course-member.controller.ts @@ -0,0 +1,68 @@ +import {AuthUser, UserToken} from '@app/keycloak-auth'; +import {NotFound, ObjectIdPipe} from '@mean-stream/nestx'; +import {Body, ConflictException, Controller, Delete, Get, Param, Put} from '@nestjs/common'; +import {ApiConflictResponse, ApiOkResponse, ApiTags} from '@nestjs/swagger'; +import {Types} from 'mongoose'; +import {CourseAuth} from './course-auth.decorator'; +import {Member, MemberService, UpdateMemberDto} from '@app/member'; + +const forbiddenResponse = 'Not member of course'; +const notOwnerResponse = 'Not owner of course'; +const removeOwnerResponse = 'Owner cannot remove themselves as member'; + +@Controller('courses/:course/members') +@ApiTags('Course Members') +export class CourseMemberController { + constructor( + private readonly memberService: MemberService, + ) { + } + + @Get() + @CourseAuth({forbiddenResponse}) + @ApiOkResponse({type: [Member]}) + async findAll( + @Param('course', ObjectIdPipe) course: Types.ObjectId, + ): Promise { + return this.memberService.findAll({parent: course}); + } + + @Get(':user') + @CourseAuth({forbiddenResponse}) + @NotFound() + @ApiOkResponse({type: Member}) + async findOne( + @Param('course', ObjectIdPipe) course: Types.ObjectId, + @Param('user') user: string, + ): Promise { + return this.memberService.findOne({parent: course, user}); + } + + @Put(':user') + @CourseAuth({forbiddenResponse: notOwnerResponse}) + @ApiOkResponse({type: Member}) + @NotFound() + async update( + @Param('course', ObjectIdPipe) course: Types.ObjectId, + @Param('user') user: string, + @Body() dto: UpdateMemberDto, + ): Promise { + return this.memberService.upsert({parent: course, user}, dto); + } + + @Delete(':user') + @CourseAuth({forbiddenResponse: notOwnerResponse}) + @NotFound() + @ApiOkResponse({type: Member}) + @ApiConflictResponse({description: removeOwnerResponse}) + async remove( + @Param('course', ObjectIdPipe) course: Types.ObjectId, + @Param('user') user: string, + @AuthUser() token: UserToken, + ): Promise { + if (token.sub === user) { + throw new ConflictException(removeOwnerResponse); + } + return this.memberService.deleteOne({parent: course, user}); + } +} diff --git a/services/apps/assignments/src/course-member/course-member.handler.ts b/services/apps/assignments/src/course-member/course-member.handler.ts new file mode 100644 index 000000000..7e4e5b521 --- /dev/null +++ b/services/apps/assignments/src/course-member/course-member.handler.ts @@ -0,0 +1,23 @@ +import {OnEvent} from "@nestjs/event-emitter"; +import {Course} from "../course/course.schema"; +import {Injectable} from "@nestjs/common"; +import {MemberService} from "@app/member"; + +@Injectable() +export class CourseMemberHandler { + constructor( + readonly memberService: MemberService, + ) { + } + + @OnEvent('courses.*.created') + @OnEvent('courses.*.updated') + async onCourseChanged(course: Course) { + await this.memberService.upsert({parent: course._id, user: course.createdBy}, {}); + } + + @OnEvent('courses.*.deleted') + async onCourseDeleted(course: Course) { + await this.memberService.deleteMany({parent: course._id}); + } +} diff --git a/services/apps/assignments/src/course-member/course-member.module.ts b/services/apps/assignments/src/course-member/course-member.module.ts new file mode 100644 index 000000000..1e85c6a6d --- /dev/null +++ b/services/apps/assignments/src/course-member/course-member.module.ts @@ -0,0 +1,26 @@ +import {forwardRef, Module} from '@nestjs/common'; +import {MongooseModule} from '@nestjs/mongoose'; +import {Member, MemberSchema, MemberService} from '@app/member'; +import {CourseMemberController} from './course-member.controller'; +import {CourseMemberHandler} from "./course-member.handler"; +import {CourseModule} from "../course/course.module"; +import {CourseAuthGuard} from "./course-auth.guard"; + +@Module({ + imports: [ + MongooseModule.forFeature([{name: Member.name, schema: MemberSchema}]), + forwardRef(() => CourseModule), + ], + controllers: [CourseMemberController], + providers: [ + MemberService, + CourseAuthGuard, + CourseMemberHandler, + ], + exports: [ + MemberService, + CourseAuthGuard, + ], +}) +export class CourseMemberModule { +} diff --git a/services/apps/assignments/src/course/course.controller.ts b/services/apps/assignments/src/course/course.controller.ts index 1f95abbf5..779ce5448 100644 --- a/services/apps/assignments/src/course/course.controller.ts +++ b/services/apps/assignments/src/course/course.controller.ts @@ -1,10 +1,13 @@ import {Auth, AuthUser, UserToken} from '@app/keycloak-auth'; -import {NotFound, notFound} from '@mean-stream/nestx'; -import {Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, Query} from '@nestjs/common'; -import {ApiCreatedResponse, ApiForbiddenResponse, ApiOkResponse, ApiOperation, ApiTags} from '@nestjs/swagger'; +import {NotFound} from '@mean-stream/nestx'; +import {Body, Controller, Delete, Get, Param, ParseArrayPipe, Patch, Post, Query} from '@nestjs/common'; +import {ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags} from '@nestjs/swagger'; import {CourseStudent, CreateCourseDto, UpdateCourseDto} from './course.dto'; import {Course} from './course.schema'; import {CourseService} from './course.service'; +import {CourseAuth} from "../course-member/course-auth.decorator"; +import {FilterQuery} from "mongoose"; +import {MemberService} from "@app/member"; const forbiddenResponse = 'Not owner.'; @@ -13,25 +16,35 @@ const forbiddenResponse = 'Not owner.'; export class CourseController { constructor( private readonly courseService: CourseService, + private readonly memberService: MemberService, ) { } @Post() - @Auth({optional: true}) + @Auth() @ApiCreatedResponse({type: Course}) async create( @Body() dto: CreateCourseDto, - @AuthUser() user?: UserToken, + @AuthUser() user: UserToken, ): Promise { - return this.courseService.create(dto, user?.sub); + return this.courseService.create(dto, user.sub); } @Get() @ApiOkResponse({type: [Course]}) async findAll( @Query('createdBy') createdBy?: string, + @Query('members', new ParseArrayPipe({optional: true})) memberIds?: string[], ): Promise { - return this.courseService.findAll({createdBy}); + const filter: FilterQuery = {}; + if (createdBy) { + (filter.$or ||= []).push({createdBy}); + } + if (memberIds) { + const members = await this.memberService.findAll({user: {$in: memberIds}}); + (filter.$or ||= []).push({_id: {$in: members.map(m => m.parent)}}); + } + return this.courseService.findAll(filter); } @Get(':id') @@ -43,50 +56,33 @@ export class CourseController { @Get(':id/students') @ApiOperation({summary: 'Get summary of all students in a course'}) - @Auth() + @CourseAuth({forbiddenResponse}) @NotFound() @ApiOkResponse({type: [CourseStudent]}) - @ApiForbiddenResponse({description: forbiddenResponse}) async getStudents( @Param('id') id: string, - @AuthUser() user: UserToken, ): Promise { - await this.checkAuth(id, user); return this.courseService.getStudents(id); } @Patch(':id') - @Auth() + @CourseAuth({forbiddenResponse}) @NotFound() @ApiOkResponse({type: Course}) - @ApiForbiddenResponse({description: forbiddenResponse}) async update( @Param('id') id: string, @Body() dto: UpdateCourseDto, - @AuthUser() user: UserToken, ): Promise { - await this.checkAuth(id, user); return this.courseService.update(id, dto); } @Delete(':id') - @Auth() + @CourseAuth({forbiddenResponse}) @NotFound() @ApiOkResponse({type: Course}) - @ApiForbiddenResponse({description: forbiddenResponse}) async remove( @Param('id') id: string, - @Body() dto: UpdateCourseDto, - @AuthUser() user: UserToken, ): Promise { - await this.checkAuth(id, user); return this.courseService.remove(id); } - - private async checkAuth(id: string, user: UserToken) { - const course = await this.courseService.findOne(id) ?? notFound(id); - if (course.createdBy !== user.sub) { - throw new ForbiddenException(forbiddenResponse); - } - } } diff --git a/services/apps/assignments/src/course/course.dto.ts b/services/apps/assignments/src/course/course.dto.ts index e1f5132c7..ed4c62c7a 100644 --- a/services/apps/assignments/src/course/course.dto.ts +++ b/services/apps/assignments/src/course/course.dto.ts @@ -3,13 +3,12 @@ import {AuthorInfo, Solution} from '../solution/solution.schema'; import {Course} from './course.schema'; export class CreateCourseDto extends OmitType(Course, [ + '_id', 'createdBy', ] as const) { } -export class UpdateCourseDto extends PartialType(OmitType(Course, [ - 'createdBy', -] as const)) { +export class UpdateCourseDto extends PartialType(CreateCourseDto) { } export class SolutionSummary extends PickType(Solution, [ diff --git a/services/apps/assignments/src/course/course.module.ts b/services/apps/assignments/src/course/course.module.ts index ec59739e8..75d9034a5 100644 --- a/services/apps/assignments/src/course/course.module.ts +++ b/services/apps/assignments/src/course/course.module.ts @@ -5,6 +5,7 @@ import {SolutionModule} from '../solution/solution.module'; import {CourseController} from './course.controller'; import {Course, CourseSchema} from './course.schema'; import {CourseService} from './course.service'; +import {CourseMemberModule} from "../course-member/course-member.module"; @Module({ imports: [ @@ -16,9 +17,15 @@ import {CourseService} from './course.service'; ]), SolutionModule, AssigneeModule, + CourseMemberModule, ], controllers: [CourseController], - providers: [CourseService], + providers: [ + CourseService, + ], + exports: [ + CourseService, + ], }) export class CourseModule { } diff --git a/services/apps/assignments/src/course/course.schema.ts b/services/apps/assignments/src/course/course.schema.ts index 089505c77..24a99c066 100644 --- a/services/apps/assignments/src/course/course.schema.ts +++ b/services/apps/assignments/src/course/course.schema.ts @@ -1,10 +1,13 @@ import {Prop, Schema, SchemaFactory} from '@nestjs/mongoose'; import {ApiProperty, ApiPropertyOptional} from '@nestjs/swagger'; import {IsArray, IsMongoId, IsNotEmpty, IsOptional, IsString, IsUUID} from 'class-validator'; -import {Document} from 'mongoose'; +import {Document, Types} from 'mongoose'; @Schema() export class Course { + @ApiProperty() + _id: Types.ObjectId; + @Prop() @ApiPropertyOptional() @IsOptional()