From 6c1cf2e58f906484fcc294c88296a7bc181e0b73 Mon Sep 17 00:00:00 2001 From: Martin Schuhmacher <55735359+MartinSchuhmacher@users.noreply.github.com> Date: Fri, 29 Nov 2024 10:19:58 +0100 Subject: [PATCH 01/17] BC-8213 - validate room dates (#5347) * fixing room date validation on create and update --- .../modules/room/domain/do/room.do.spec.ts | 52 ------------------- .../src/modules/room/domain/do/room.do.ts | 14 ----- .../room/domain/service/room.service.spec.ts | 17 ++++++ .../room/domain/service/room.service.ts | 11 ++++ 4 files changed, 28 insertions(+), 66 deletions(-) diff --git a/apps/server/src/modules/room/domain/do/room.do.spec.ts b/apps/server/src/modules/room/domain/do/room.do.spec.ts index bd213e52259..465fc4b95e8 100644 --- a/apps/server/src/modules/room/domain/do/room.do.spec.ts +++ b/apps/server/src/modules/room/domain/do/room.do.spec.ts @@ -1,4 +1,3 @@ -import { ValidationError } from '@shared/common'; import { EntityId } from '@shared/domain/types'; import { roomFactory } from '../../testing'; import { RoomColor } from '../type'; @@ -71,55 +70,4 @@ describe('Room', () => { const expectedUpdatedAt = new Date('2024-01-01'); expect(room.updatedAt).toEqual(expectedUpdatedAt); }); - - describe('time frame validation', () => { - const setup = () => { - const props: RoomProps = { - id: roomId, - name: 'Conference Room', - color: RoomColor.BLUE, - startDate: new Date('2024-01-01'), - endDate: new Date('2024-12-31'), - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }; - - return { props }; - }; - - describe('when costructor is called with invalid time frame', () => { - it('should throw validation error', () => { - const buildInvalid = () => { - const { props } = setup(); - props.startDate = new Date('2024-12-31'); - props.endDate = new Date('2024-01-01'); - // eslint-disable-next-line no-new - new Room(props); - }; - expect(buildInvalid).toThrowError(ValidationError); - }); - }); - - describe('when setting start date after end date', () => { - it('should throw validation error', () => { - const setInvalidStartDate = () => { - const { props } = setup(); - const inValidRoom = new Room(props); - inValidRoom.startDate = new Date('2025-01-01'); - }; - expect(setInvalidStartDate).toThrowError(ValidationError); - }); - }); - - describe('when setting end date before start date', () => { - it('should throw validation error', () => { - const setInvalidEndDate = () => { - const { props } = setup(); - const inValidRoom = new Room(props); - inValidRoom.endDate = new Date('2023-12-31'); - }; - expect(setInvalidEndDate).toThrowError(ValidationError); - }); - }); - }); }); diff --git a/apps/server/src/modules/room/domain/do/room.do.ts b/apps/server/src/modules/room/domain/do/room.do.ts index 05feb9d07d4..1cdd5093a63 100644 --- a/apps/server/src/modules/room/domain/do/room.do.ts +++ b/apps/server/src/modules/room/domain/do/room.do.ts @@ -1,4 +1,3 @@ -import { ValidationError } from '@shared/common'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { EntityId } from '@shared/domain/types'; import { RoomColor } from '../type'; @@ -19,7 +18,6 @@ export type RoomUpdateProps = RoomCreateProps; // will probably change in the fu export class Room extends DomainObject { public constructor(props: RoomProps) { super(props); - this.validateTimeSpan(); } public getProps(): RoomProps { @@ -54,7 +52,6 @@ export class Room extends DomainObject { public set startDate(value: Date) { this.props.startDate = value; - this.validateTimeSpan(); } public get endDate(): Date | undefined { @@ -63,7 +60,6 @@ export class Room extends DomainObject { public set endDate(value: Date) { this.props.endDate = value; - this.validateTimeSpan(); } public get createdAt(): Date { @@ -73,14 +69,4 @@ export class Room extends DomainObject { public get updatedAt(): Date { return this.props.updatedAt; } - - private validateTimeSpan() { - if (this.props.startDate != null && this.props.endDate != null && this.props.startDate > this.props.endDate) { - throw new ValidationError( - `Invalid room timespan. Start date '${this.props.startDate.toISOString()}' has to be before end date: '${this.props.endDate.toISOString()}'. Room id='${ - this.id - }'` - ); - } - } } diff --git a/apps/server/src/modules/room/domain/service/room.service.spec.ts b/apps/server/src/modules/room/domain/service/room.service.spec.ts index 5edb5710a03..2eb114a4f1f 100644 --- a/apps/server/src/modules/room/domain/service/room.service.spec.ts +++ b/apps/server/src/modules/room/domain/service/room.service.spec.ts @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; +import { ValidationError } from '@shared/common'; import { RoomRepo } from '../../repo'; import { roomFactory } from '../../testing'; import { Room, RoomCreateProps, RoomUpdateProps } from '../do'; @@ -80,6 +81,14 @@ describe('RoomService', () => { expect(roomRepo.save).toHaveBeenCalledWith(expect.objectContaining(props)); }); + + it('should throw validation error if start date is after end date', async () => { + const { props } = setup(); + props.startDate = new Date('2024-12-31'); + props.endDate = new Date('2024-01-01'); + + await expect(service.createRoom(props)).rejects.toThrowError(ValidationError); + }); }); describe('getSingleRoom', () => { @@ -137,6 +146,14 @@ describe('RoomService', () => { expect(roomRepo.save).toHaveBeenCalledWith(room); }); + + it('should throw validation error if start date is after end date', async () => { + const { props, room } = setup(); + props.startDate = new Date('2024-12-31'); + props.endDate = new Date('2024-01-01'); + + await expect(service.updateRoom(room, props)).rejects.toThrowError(ValidationError); + }); }); describe('deleteRoom', () => { diff --git a/apps/server/src/modules/room/domain/service/room.service.ts b/apps/server/src/modules/room/domain/service/room.service.ts index 9f0a974ae5b..4ec2c62c779 100644 --- a/apps/server/src/modules/room/domain/service/room.service.ts +++ b/apps/server/src/modules/room/domain/service/room.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { ValidationError } from '@shared/common'; import { RoomRepo } from '../../repo'; import { Room, RoomCreateProps, RoomProps, RoomUpdateProps } from '../do'; @@ -29,6 +30,7 @@ export class RoomService { createdAt: new Date(), updatedAt: new Date(), }; + this.validateTimeSpan(props, roomProps.id); const room = new Room(roomProps); await this.roomRepo.save(room); @@ -43,6 +45,7 @@ export class RoomService { } public async updateRoom(room: Room, props: RoomUpdateProps): Promise { + this.validateTimeSpan(props, room.id); Object.assign(room, props); await this.roomRepo.save(room); @@ -51,4 +54,12 @@ export class RoomService { public async deleteRoom(room: Room): Promise { await this.roomRepo.delete(room); } + + private validateTimeSpan(props: RoomCreateProps | RoomUpdateProps, roomId: string): void { + if (props.startDate != null && props.endDate != null && props.startDate > props.endDate) { + throw new ValidationError( + `Invalid room timespan. Start date '${props.startDate.toISOString()}' has to be before end date: '${props.endDate.toISOString()}'. Room id='${roomId}'` + ); + } + } } From 7fff38c124fcb5705118c16a294c18f1527a2595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Mon, 2 Dec 2024 09:26:58 +0100 Subject: [PATCH 02/17] N21-2264 Remove unused tools from courses (#5361) --- .../mikro-orm/Migration20241127134513.ts | 29 +++++++++++++++++++ backup/setup/migrations.json | 9 ++++++ 2 files changed, 38 insertions(+) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241127134513.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20241127134513.ts b/apps/server/src/migrations/mikro-orm/Migration20241127134513.ts new file mode 100644 index 00000000000..18369aadf4a --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241127134513.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ContextExternalToolType } from '@modules/tool/context-external-tool/entity/context-external-tool-type.enum'; +import type { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity/context-external-tool.entity'; + +export class Migration20241127134513 extends Migration { + async up(): Promise { + let deleteCount = 0; + const cursor = this.getCollection('context-external-tools').find({ + contextType: ContextExternalToolType.COURSE, + }); + + for await (const doc of cursor) { + const course = await this.getCollection('courses').findOne({ _id: new ObjectId(doc.contextId) }); + + if (course === null) { + await this.getCollection('context-external-tools').deleteOne({ _id: doc._id }); + deleteCount += 1; + } + } + + console.info(`Deleted ${deleteCount} context-external-tools without a reference to an existing course context.`); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down(): Promise { + console.info('This migration cannot be rolled back.'); + } +} diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 762b54a8e40..f31be452adb 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -260,6 +260,15 @@ "$date": "2024-10-30T17:03:31.473Z" } }, + { + "_id": { + "$oid": "674847ac3c76d17b0c01c155" + }, + "name": "Migration20241127134513", + "created_at": { + "$date": "2024-11-28T10:37:51.515Z" + } + }, { "_id": { "$oid": "674444262ba8186272dc8abd" From fcca17c91f62e17e9df737dbad9d923988972c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:44:20 +0100 Subject: [PATCH 03/17] N21-2309 Fix validation and add support for unknown schulconnex group roles (#5367) --- .../response/schulconnex-gruppenzugehoerigkeit-response.ts | 7 +++---- .../schulconnex-sonstige-gruppenzugehoerige-response.ts | 7 +++---- .../strategy/sanis/schulconnex-response-mapper.ts | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppenzugehoerigkeit-response.ts b/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppenzugehoerigkeit-response.ts index 653201ade6a..a641be93220 100644 --- a/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppenzugehoerigkeit-response.ts +++ b/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppenzugehoerigkeit-response.ts @@ -1,9 +1,8 @@ -import { IsArray, IsEnum, IsOptional } from 'class-validator'; -import { SchulconnexGroupRole } from './schulconnex-group-role'; +import { IsArray, IsOptional, IsString } from 'class-validator'; export class SchulconnexGruppenzugehoerigkeitResponse { @IsOptional() @IsArray() - @IsEnum(SchulconnexGroupRole, { each: true }) - rollen?: SchulconnexGroupRole[]; + @IsString({ each: true }) + rollen?: string[]; } diff --git a/apps/server/src/infra/schulconnex-client/response/schulconnex-sonstige-gruppenzugehoerige-response.ts b/apps/server/src/infra/schulconnex-client/response/schulconnex-sonstige-gruppenzugehoerige-response.ts index e9c297d8c30..6c90101f9dc 100644 --- a/apps/server/src/infra/schulconnex-client/response/schulconnex-sonstige-gruppenzugehoerige-response.ts +++ b/apps/server/src/infra/schulconnex-client/response/schulconnex-sonstige-gruppenzugehoerige-response.ts @@ -1,5 +1,4 @@ -import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; -import { SchulconnexGroupRole } from './schulconnex-group-role'; +import { IsArray, IsOptional, IsString } from 'class-validator'; export class SchulconnexSonstigeGruppenzugehoerigeResponse { @IsString() @@ -7,6 +6,6 @@ export class SchulconnexSonstigeGruppenzugehoerigeResponse { @IsOptional() @IsArray() - @IsEnum(SchulconnexGroupRole, { each: true }) - rollen?: SchulconnexGroupRole[]; + @IsString({ each: true }) + rollen?: string[]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts index 5961ae19917..cd15c272342 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts @@ -34,7 +34,7 @@ const RoleMapping: Record = { [SchulconnexRole.ORGADMIN]: RoleName.ADMINISTRATOR, }; -const GroupRoleMapping: Partial> = { +const GroupRoleMapping: Partial> = { [SchulconnexGroupRole.TEACHER]: RoleName.TEACHER, [SchulconnexGroupRole.STUDENT]: RoleName.STUDENT, }; @@ -146,7 +146,7 @@ export class SchulconnexResponseMapper { otherUsers = group.sonstige_gruppenzugehoerige ? group.sonstige_gruppenzugehoerige .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) - .filter((otherUser: ExternalGroupUserDto | null) => otherUser !== null) + .filter((otherUser: ExternalGroupUserDto | null): otherUser is ExternalGroupUserDto => otherUser !== null) : []; } From 9c7d5014bdb01cd2891a07149632daec5a434108 Mon Sep 17 00:00:00 2001 From: Phillip Date: Mon, 2 Dec 2024 13:17:18 +0100 Subject: [PATCH 04/17] cleanup ancient and false information (#5357) --- .codacy.yml | 10 - .github/PULL_REQUEST_TEMPLATE.md | 38 +-- .github/autolabeler.yml | 1 - SECURITY.md | 9 - scripts/copy-legacy-tool-to-ctl.js | 451 ----------------------------- scripts/migrate-legacy-bbb.js | 109 ------- 6 files changed, 4 insertions(+), 614 deletions(-) delete mode 100644 .codacy.yml delete mode 100644 .github/autolabeler.yml delete mode 100644 SECURITY.md delete mode 100644 scripts/copy-legacy-tool-to-ctl.js delete mode 100644 scripts/migrate-legacy-bbb.js diff --git a/.codacy.yml b/.codacy.yml deleted file mode 100644 index ece54c71579..00000000000 --- a/.codacy.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -exclude_paths: - - ".idea" - - ".vscode" - - "**/backup/**" - - "**/migrations/**" - - "**/tests/**" - - "**.js" - - "**.spec.js" - - "**.spec.ts" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7b19d3a17e7..0f19bc7837e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,13 @@ # Description @@ -19,45 +18,16 @@ Base links to copy - https://ticketsystem.dbildungscloud.de/browse/BC-???? --> -## Changes -## Datasecurity - - -## Deployment - - -## New Repos, NPM pakages or vendor scripts - - ## Approval for review - [ ] DEV: If api was changed - `generate-client:server` was executed in vue frontend and changes were tested and put in a PR with the same branch name. - [ ] QA: In addition to review, the code has been manually tested (if manual testing is possible) - [ ] All points were discussed with the ticket creator, support-team or product owner. The code upholds all quality guidelines from the PR-template. -> Notice: Please remove the WIP label if the PR is ready to review, otherwise nobody will review it. diff --git a/.github/autolabeler.yml b/.github/autolabeler.yml deleted file mode 100644 index ebc2bbbfc48..00000000000 --- a/.github/autolabeler.yml +++ /dev/null @@ -1 +0,0 @@ -wip: * \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 43cbf08dcab..00000000000 --- a/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Policy - -## Supported Versions - -Please always use the latest release. - -## Reporting a Vulnerability - -Plase check https://dbildungscloud.de/security on how to report security issues. Thanks for your support. diff --git a/scripts/copy-legacy-tool-to-ctl.js b/scripts/copy-legacy-tool-to-ctl.js deleted file mode 100644 index 4f2455bd955..00000000000 --- a/scripts/copy-legacy-tool-to-ctl.js +++ /dev/null @@ -1,451 +0,0 @@ -/* eslint-disable no-await-in-loop */ -const mongoose = require('mongoose'); - -const { Schema } = mongoose; -const { program } = require('commander'); -const { v4: uuidv4 } = require('uuid'); - -program.requiredOption('-u, --url ', '(Required) URL of the MongoDB instance'); -program.parse(); - -const options = program.opts(); -const mongodbUrl = options.url; - -const close = async () => mongoose.connection.close(); - -const connect = async () => { - const mongooseOptions = { - useNewUrlParser: true, - useUnifiedTopology: true, - }; - - return mongoose.connect(mongodbUrl, mongooseOptions); -}; - -const customParameterEntrySchema = new Schema( - { - name: String, - value: String, - }, - { _id: false } -); - -const LtiTool = mongoose.model( - 'ltiTool0906202311481', - new Schema( - { - name: { type: String }, - url: { type: String, required: true }, - key: { type: String }, - secret: { type: String, required: true }, - logo_url: { type: String }, - lti_message_type: { type: String }, - lti_version: { type: String }, - resource_link_id: { type: String }, - roles: { - type: [ - { - type: String, - enum: ['Learner', 'Instructor', 'ContentDeveloper', 'Administrator', 'Mentor', 'TeachingAssistant'], - }, - ], - }, - privacy_permission: { - type: String, - enum: ['anonymous', 'e-mail', 'name', 'public', 'pseudonymous'], - default: 'anonymous', - }, - customs: { type: [{ key: { type: String }, value: { type: String } }] }, - isTemplate: { type: Boolean }, - isLocal: { type: Boolean }, - createdAt: { type: Date, default: Date.now }, - updatedAt: { type: Date, default: Date.now }, - originTool: { type: Schema.Types.ObjectId, ref: 'ltiTool0906202311481' }, - oAuthClientId: { type: String }, - friendlyUrl: { type: String, unique: true, sparse: true }, - skipConsent: { type: Boolean }, - openNewTab: { type: Boolean, default: false }, - frontchannel_logout_uri: { type: String }, - isHidden: { type: Boolean, default: false }, - }, - { - timestamps: true, - } - ), - 'ltitools' -); - -const ExternalTool = mongoose.model( - 'external_tool0906202311482', - new Schema( - { - name: { type: String, unique: true }, - url: String, - logoUrl: String, - config_type: String, - config_baseUrl: String, - config_clientId: String, - config_skipConsent: Boolean, - config_key: String, - config_secret: String, - config_lti_message_type: { - type: String, - enum: ['basic-lti-launch-request'], - }, - config_privacy_permission: { - type: String, - enum: ['anonymous', 'e-mail', 'name', 'public', 'pseudonymous'], - }, - parameters: [ - { - type: { - name: String, - displayName: String, - description: String, - default: String, - regex: String, - regexComment: String, - scope: { - type: String, - enum: ['global', 'school', 'context'], - }, - location: { - type: String, - enum: ['path', 'body', 'query'], - }, - type: { - type: String, - enum: ['string', 'number', 'boolean', 'auto_contextid', 'auto_contextname', 'auto_schoolid'], - }, - isOptional: Boolean, - isProtected: Boolean, - }, - }, - ], - - isHidden: Boolean, - openNewTab: Boolean, - version: Number, - isDeactivated: Boolean, - restrictToContexts: [], - }, - { - timestamps: true, - } - ), - 'external-tools' -); - -const SchoolExternalTool = mongoose.model( - 'school_external_tool0906202311483', - new Schema( - { - tool: { type: Schema.Types.ObjectId, ref: 'external_tool0906202311482' }, - school: { type: Schema.Types.ObjectId }, - schoolParameters: [customParameterEntrySchema], - toolVersion: Number, - isDeactivated: Boolean, - }, - { - timestamps: true, - } - ), - 'school-external-tools' -); - -const ContextExternalTool = mongoose.model( - 'context_external_tool0906202311484', - new Schema( - { - schoolTool: { type: Schema.Types.ObjectId, ref: 'school_external_tool0906202311483' }, - contextId: String, - contextType: { type: String, enum: ['course'] }, - displayName: String, - parameters: [customParameterEntrySchema], - toolVersion: Number, - }, - { - timestamps: true, - } - ), - 'context-external-tools' -); - -const Course = mongoose.model( - 'course0906202311485', - new mongoose.Schema( - { - school: Schema.Types.ObjectId, - ltiToolIds: [{ type: Schema.Types.ObjectId, ref: 'ltiTool0906202311481' }], - }, - { - timestamps: true, - } - ), - 'courses' -); - -const Pseudonym = mongoose.model( - 'pseudonym0906202311486', - new Schema( - { - userId: { type: Schema.Types.ObjectId }, - toolId: { type: Schema.Types.ObjectId, ref: 'ltiTool0906202311481' }, - pseudonym: { - type: String, - required: true, - unique: true, - default: uuidv4, - }, - }, - { - timestamps: true, - } - ), - 'pseudonyms' -); - -const Pseudonym2 = mongoose.model( - 'external-tool-pseudonyms0906202311486', - new Schema( - { - userId: { type: Schema.Types.ObjectId }, - toolId: { type: Schema.Types.ObjectId, ref: 'external_tool0906202311482' }, - pseudonym: { - type: String, - required: true, - unique: true, - default: uuidv4, - }, - }, - { - timestamps: true, - } - ), - 'external-tool-pseudonyms' -); - -function toolConfigMapper(ltiToolTemplate) { - let toolConfig = { - config_baseUrl: ltiToolTemplate.url, - config_type: 'basic', - }; - - if (ltiToolTemplate.oAuthClientId) { - toolConfig = { - ...toolConfig, - config_type: 'oauth2', - config_clientId: ltiToolTemplate.oAuthClientId, - config_skipConsent: ltiToolTemplate.skipConsent, - }; - } else if (ltiToolTemplate.key && ltiToolTemplate.key !== 'none') { - toolConfig = { - ...toolConfig, - config_type: 'lti11', - config_key: ltiToolTemplate.key, - config_secret: ltiToolTemplate.secret, - config_lti_message_type: ltiToolTemplate.lti_message_type, - config_privacy_permission: ltiToolTemplate.privacy_permission || 'anonymous', - }; - } - - return toolConfig; -} - -function mapToExternalTool(ltiToolTemplate) { - return { - name: ltiToolTemplate.name, - url: ltiToolTemplate.url, - logoUrl: ltiToolTemplate.logo_url, - parameters: [], - isHidden: ltiToolTemplate.isHidden, - openNewTab: ltiToolTemplate.openNewTab, - version: 1, - restrictToContexts: [], - isDeactivated: false, - ...toolConfigMapper(ltiToolTemplate), - }; -} - -function mapToSchoolExternalTool(externalTool, course) { - return { - tool: externalTool._id, - school: course.schoolId, - schoolParameters: [], - toolVersion: externalTool.version, - isDeactivated: false, - }; -} - -function mapToContextExternalTool(schoolExternalTool, course, externalToolName) { - return { - schoolTool: schoolExternalTool._id, - contextId: course._id, - contextType: 'course', - parameters: [], - toolVersion: schoolExternalTool.toolVersion, - displayName: externalToolName, - }; -} - -function mapPseudonyms(pseudonym, externalTool) { - return { - pseudonym: pseudonym.pseudonym, - toolId: externalTool._id, - userId: pseudonym.userId, - }; -} - -async function createPseudonyms(toolTemplate, externalTool) { - const pseudonymsLegacyTools = await Pseudonym.find({ - toolId: toolTemplate._id, - }) - .lean() - .exec(); - - const newPseudonyms = []; - for (const legacyPseudonym of pseudonymsLegacyTools) { - const existingPseudonym = await Pseudonym2.findOne({ - pseudonym: legacyPseudonym.pseudonym, - }) - .lean() - .exec(); - - if (!existingPseudonym) { - newPseudonyms.push({ - userId: legacyPseudonym.userId, - toolId: externalTool._id, - pseudonym: legacyPseudonym.pseudonym, - }); - } - } - await Pseudonym2.insertMany(newPseudonyms); -} - -async function createExternalTool(toolTemplate) { - let externalTool = await ExternalTool.findOne({ - name: { $regex: `${toolTemplate.name}` }, - }) - .lean() - .exec(); - - if (!externalTool) { - externalTool = mapToExternalTool(toolTemplate); - externalTool = (await ExternalTool.insertMany(externalTool))[0]; - } - - createPseudonyms(toolTemplate, externalTool); - - return externalTool; -} - -async function createSchoolExternalTool(externalTool, course) { - let schoolExternalTool = await SchoolExternalTool.findOne({ - school: course.schoolId, - tool: externalTool._id, - }) - .lean() - .exec(); - - // CHECK IF SCHOOLEXTERNALTOOL EXISTS - if (!schoolExternalTool) { - schoolExternalTool = mapToSchoolExternalTool(externalTool, course); - schoolExternalTool = (await SchoolExternalTool.insertMany(schoolExternalTool))[0]; - } - - return schoolExternalTool; -} - -async function createContextExternalTool(schoolExternalTool, course, externalToolName) { - const contextExternalTools = await ContextExternalTool.find({ - schoolTool: schoolExternalTool._id, - contextId: course._id, - contextType: 'course', - }) - .lean() - .exec(); - - // CHECK IF CONTEXTEXTERNALTOOL EXISTS - if ((contextExternalTools || []).length === 0) { - const contextExternalTool = mapToContextExternalTool(schoolExternalTool, course, externalToolName); - await ContextExternalTool.insertMany(contextExternalTool); - } -} - -const up = async () => { - await connect(); - - // FIND ALL LTI TOOL TEMPLATES - const ltiToolTemplates = await LtiTool.find({ - $or: [{ name: { $regex: /Bettermarks/i } }, { name: { $regex: /Nextcloud/i } }], - isTemplate: true, - }) - .lean() - .exec(); - - if ((ltiToolTemplates || []).length === 0) { - return Promise.reject(new Error('No LtiTool Template found.')); - } - - const ltiToolExternalToolIdTupelList = []; - - // FIND EXTERNAL TOOLS - const externalTools = await Promise.all( - ltiToolTemplates.map(async (toolTemplate) => { - const externalTool = await createExternalTool(toolTemplate); - ltiToolExternalToolIdTupelList.push({ - templateId: toolTemplate._id, - externalToolId: externalTool._id, - }); - - return externalTool; - }) - ); - - // FIND ALL LEGACY TOOLS - const ltiTools = await LtiTool.find({ - $or: [{ name: { $regex: /Bettermarks/i } }, { name: { $regex: /Nextcloud/i } }], - isTemplate: false, - }) - .lean() - .exec(); - - for (const ltiTool of ltiTools) { - // GET COURSE - const course = await Course.findOne({ - ltiToolIds: { $in: [ltiTool._id] }, - }) - .lean() - .exec(); - - if (!course) { - console.info(`No course found with LtiToolId: ${ltiTool._id}.`); - // eslint-disable-next-line no-continue - continue; - } - - // GET EXTERNALTOOL - const ltiToolExternalToolIdTupel = ltiToolExternalToolIdTupelList.find( - (idTupel) => idTupel.templateId.toString() === ltiTool.originTool.toString() - ); - const externalTool = externalTools.find((tool) => tool._id === ltiToolExternalToolIdTupel.externalToolId); - - // GET SCHOOLEXTERNALTOOL - const schoolExternalTool = await createSchoolExternalTool(externalTool, course); - - // CREATE CONTEXTEXTERNALTOOL - await createContextExternalTool(schoolExternalTool, course, externalTool.name); - } - - await close(); - return Promise.resolve(); -}; - -(async () => { - try { - await up(); - } catch (e) { - console.error(e); - process.exit(1); - } -})(); diff --git a/scripts/migrate-legacy-bbb.js b/scripts/migrate-legacy-bbb.js deleted file mode 100644 index 32d5a0c888e..00000000000 --- a/scripts/migrate-legacy-bbb.js +++ /dev/null @@ -1,109 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars - -const { Schema } = mongoose; - -const { program } = require('commander'); - -program.requiredOption('-u, --url ', '(Required) URL of the MongoDB instance'); -program.parse(); - -const options = program.opts(); -const mongodbUrl = options.url; - -const close = async () => mongoose.connection.close(); - -const connect = async () => { - const mongooseOptions = { - useNewUrlParser: true, - useUnifiedTopology: true, - }; - - return mongoose.connect(mongodbUrl, mongooseOptions); -}; - -const COURSE_FEATURES = { - VIDEOCONFERENCE: 'videoconference', -}; - -const LtiTool = mongoose.model( - 'ltiTools1688028372783', - new mongoose.Schema( - { - isTemplate: { type: Boolean }, - name: { type: String }, - }, - { - timestamps: true, - } - ), - 'ltitools' -); - -const Course = mongoose.model( - 'course1688028372783', - new mongoose.Schema( - { - ltiToolIds: [{ type: Schema.Types.ObjectId, required: true, ref: 'ltiTools1688028372783' }], - features: [{ type: String, enum: Object.values(COURSE_FEATURES) }], - }, - { - timestamps: true, - } - ), - 'courses' -); - -const up = async () => { - await connect(); - - // find all non-template bbb tools - const bbbTools = await LtiTool.find({ - $and: [{ name: 'Video-Konferenz mit BigBlueButton' }, { isTemplate: false }], - }) - .lean() - .exec(); - - if ((bbbTools || []).length === 0) { - console.error('No non-template videoconferences found. Nothing to migrate.'); - return; - } - - console.log(`Found ${bbbTools.length} tool(s) to migrate.`); - - // find all courses that use BBB - const coursesWithBbb = await Course.find({ ltiToolIds: { $in: bbbTools } }) - .lean() - .exec(); - - if ((coursesWithBbb || []).length === 0) { - console.error('No courses with BBB found. Nothing to migrate.'); - return; - } - - console.log(`Found ${coursesWithBbb.length} course(s) to update.`); - - // add videoconference feature to courses that use bbb - const addFeature = async () => { - for (const course of coursesWithBbb) { - await Course.updateOne( - { _id: course._id }, - { $addToSet: { features: { $each: [COURSE_FEATURES.VIDEOCONFERENCE] } } } - ).exec(); - } - }; - await addFeature(); - - console.log(`Updated ${coursesWithBbb.length} courses.`); - - await close(); -}; - -(async () => { - try { - await up(); - } catch (e) { - console.error(e); - process.exit(1); - } -})(); From 408f1fe90f6bb5f6fc26b7e5ae6f6da4e2063898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:37:27 +0100 Subject: [PATCH 05/17] N21-2248 Create LTI 1.1 deep links with external tools (#5349) --- .../api-test/media-board.api.spec.ts | 6 +- .../board-node-copy-specific.service.spec.ts | 1 + .../oauth-provider.controller.api.spec.ts | 2 +- .../modules/server/admin-api-server.config.ts | 1 + .../src/modules/server/server.config.ts | 1 + .../modules/tool/common/common-tool.module.ts | 9 +- .../src/modules/tool/common/service/index.ts | 1 + .../service/lti11-encryption.service.spec.ts | 117 ++ .../service/lti11-encryption.service.ts | 13 +- .../context-external-tool.module.ts | 19 +- .../api-test/tool-deep-link.api.spec.ts | 132 ++ .../api-test/tool-reference.api.spec.ts | 68 +- .../dto/context-external-tool.response.ts | 4 - .../controller/dto/index.ts | 1 + .../controller/dto/lti11-deep-link/index.ts | 6 + .../lti11-deep-link/lti-deep-link.response.ts | 34 + .../lti11-content-item-type.ts | 5 + ...-deep-link-content-item-duration.params.ts | 14 + ...ti11-deep-link-content-item-list.params.ts | 17 + .../lti11-deep-link-content-item.params.ts | 50 + .../lti11-deep-link-raw.params.ts | 51 + .../lti11-deep-link/lti11-deep-link.params.ts | 56 + .../controller/dto/tool-reference.response.ts | 21 +- .../context-external-tool/controller/index.ts | 1 + .../controller/tool-deep-link.controller.ts | 26 + .../domain/context-external-tool.do.ts | 15 + .../domain/error/index.ts | 4 + ...oauth-signature.loggable-exception.spec.ts | 25 + ...alid-oauth-signature.loggable-exception.ts | 14 + ...valid-tool-type.loggable-exception.spec.ts | 35 + .../invalid-tool-type.loggable-exception.ts | 23 + ...k-token-missing.loggable-exception.spec.ts | 36 + ...p-link-token-missing.loggable-exception.ts | 23 + ...not-implemented.loggable-exception.spec.ts | 31 + ...type-not-implemented.loggable-exception.ts | 21 + .../context-external-tool/domain/index.ts | 4 +- .../domain/lti-deep-link-token.ts | 24 + .../domain/lti-deep-link.ts | 33 + .../domain/tool-reference.ts | 7 + .../entity/context-external-tool.entity.ts | 7 + .../context-external-tool/entity/index.ts | 2 + .../entity/lti-deep-link-token.entity.ts | 38 + .../entity/lti-deep-link.embeddable.ts | 44 + .../context-external-tool-response.mapper.ts | 19 +- .../context-external-tool/mapper/index.ts | 1 + .../mapper/lti-deep-link-request.mapper.ts | 32 + .../mapper/tool-reference.mapper.ts | 2 + .../tool/context-external-tool/repo/index.ts | 2 + .../lti-deep-link-token.repo.interface.ts | 11 + .../repo/mikro-orm/index.ts | 1 + .../lti-deep-link-token.repo.spec.ts | 133 ++ .../mikro-orm/lti-deep-link-token.repo.ts | 33 + .../repo/mikro-orm/mapper/index.ts | 1 + .../lti-deep-link-token-entity.mapper.ts | 31 + ...t-external-tool-validation.service.spec.ts | 116 +- ...ontext-external-tool-validation.service.ts | 22 - .../context-external-tool/service/index.ts | 2 + .../lti-deep-link-token.service.spec.ts | 108 ++ .../service/lti-deep-link-token.service.ts | 37 + .../service/lti-deep-linking.service.spec.ts | 62 + .../service/lti-deep-linking.service.ts | 19 + .../service/tool-reference.service.spec.ts | 23 +- .../context-external-tool/testing/index.ts | 5 + .../lti-deep-link-embeddable.factory.ts | 20 + .../lti-deep-link-token-entity.factory.ts | 18 + .../testing/lti-deep-link-token.factory.ts | 18 + .../testing/lti-deep-link.factory.ts | 17 + .../testing/lti11-deep-link-params.factory.ts | 88 + .../uc/context-external-tool.uc.spec.ts | 277 ++- .../uc/context-external-tool.uc.ts | 88 +- .../uc/tool-reference.uc.spec.ts | 2 + .../external-tool/domain/external-tool.do.ts | 9 +- ...al-tool-datasheet-template-data.factory.ts | 4 +- .../testing/external-tool-entity.factory.ts | 75 +- .../testing/external-tool.factory.ts | 4 +- .../testing/file-record-ref.factory.ts | 21 +- .../tool/external-tool/testing/index.ts | 8 +- .../src/modules/tool/tool-api.module.ts | 3 +- apps/server/src/modules/tool/tool-config.ts | 1 + .../tool-launch.controller.api.spec.ts | 6 +- .../dto/tool-launch-request.response.ts | 10 +- .../mapper/tool-launch.mapper.spec.ts | 6 +- .../modules/tool/tool-launch/service/index.ts | 1 - .../abstract-launch.strategy.spec.ts | 10 +- .../abstract-launch.strategy.ts | 25 +- .../basic-tool-launch.strategy.spec.ts | 31 +- .../basic-tool-launch.strategy.ts | 6 +- .../lti11-tool-launch.strategy.spec.ts | 1725 +++++++++++------ .../lti11-tool-launch.strategy.ts | 138 +- .../oauth2-tool-launch.strategy.spec.ts | 14 +- .../oauth2-tool-launch.strategy.ts | 6 +- .../service/lti11-encryption.service.spec.ts | 56 - .../service/tool-launch.service.spec.ts | 27 +- .../modules/tool/tool-launch/testing/index.ts | 1 + .../testing/tool-launch-request.factory.ts | 13 + .../tool/tool-launch/tool-launch.module.ts | 3 +- .../modules/tool/tool-launch/types/index.ts | 1 + .../tool-launch/types/launch-type.enum.ts | 6 + .../tool-launch/types/tool-launch-request.ts | 5 +- .../tool-launch/uc/tool-launch.uc.spec.ts | 19 +- .../shared/controller/transformer/index.ts | 1 + .../string-to-object.transformer.spec.ts | 75 + .../string-to-object.transformer.ts | 15 + .../src/shared/controller/validator/index.ts | 1 + .../validate-record.validator.spec.ts | 96 + .../validator/validate-record.validator.ts | 23 + .../src/shared/domain/entity/all-entities.ts | 5 +- .../context-external-tool.repo.spec.ts | 22 +- .../context-external-tool.repo.ts | 36 +- .../externaltool/external-tool.repo.spec.ts | 8 +- .../src/shared/testing/date-to-string.ts | 1 + .../src/shared/testing/dates-to-strings.ts | 3 - apps/server/src/shared/testing/index.ts | 2 +- backup/setup/external-tools.json | 4 +- 114 files changed, 3779 insertions(+), 977 deletions(-) create mode 100644 apps/server/src/modules/tool/common/service/lti11-encryption.service.spec.ts rename apps/server/src/modules/tool/{tool-launch => common}/service/lti11-encryption.service.ts (62%) create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-deep-link.api.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/index.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti-deep-link.response.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-content-item-type.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-duration.params.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-list.params.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item.params.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-raw.params.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link.params.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/tool-deep-link.controller.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link-token.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link-token.entity.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link.embeddable.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/mapper/lti-deep-link-request.mapper.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/repo/index.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/repo/lti-deep-link-token.repo.interface.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/index.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/index.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/lti-deep-link-token-entity.mapper.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-embeddable.factory.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token-entity.factory.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token.factory.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link.factory.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/testing/lti11-deep-link-params.factory.ts delete mode 100644 apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.spec.ts create mode 100644 apps/server/src/modules/tool/tool-launch/testing/index.ts create mode 100644 apps/server/src/modules/tool/tool-launch/testing/tool-launch-request.factory.ts create mode 100644 apps/server/src/modules/tool/tool-launch/types/launch-type.enum.ts create mode 100644 apps/server/src/shared/controller/transformer/string-to-object.transformer.spec.ts create mode 100644 apps/server/src/shared/controller/transformer/string-to-object.transformer.ts create mode 100644 apps/server/src/shared/controller/validator/validate-record.validator.spec.ts create mode 100644 apps/server/src/shared/controller/validator/validate-record.validator.ts create mode 100644 apps/server/src/shared/testing/date-to-string.ts delete mode 100644 apps/server/src/shared/testing/dates-to-strings.ts diff --git a/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts b/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts index 9c047992067..99d45ad89ee 100644 --- a/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts +++ b/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts @@ -7,7 +7,7 @@ import { MediaUserLicenseEntity } from '@modules/user-license/entity'; import { mediaSourceEntityFactory, mediaUserLicenseEntityFactory } from '@modules/user-license/testing'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { type DatesToStrings, fileRecordFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { DateToString, fileRecordFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { BoardExternalReferenceType, BoardLayout, MediaBoardColors } from '../../../domain'; import { BoardNodeEntity } from '../../../repo'; import { @@ -85,7 +85,7 @@ describe('Media Board (API)', () => { const response = await studentClient.get('me'); - expect(response.body).toEqual>({ + expect(response.body).toEqual>({ id: mediaBoard.id, timestamps: { createdAt: mediaBoard.createdAt.toISOString(), @@ -205,7 +205,7 @@ describe('Media Board (API)', () => { const response = await studentClient.post(`${mediaBoard.id}/media-lines`); - expect(response.body).toEqual>({ + expect(response.body).toEqual>({ id: expect.any(String), timestamps: { createdAt: expect.any(String), diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index 1a82f545a59..5c05465ad7e 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -56,6 +56,7 @@ describe(BoardNodeCopyService.name, () => { FILES_STORAGE__SERVICE_BASE_URL: '', CTL_TOOLS__PREFERRED_TOOLS_LIMIT: 10, FEATURE_PREFERRED_CTL_TOOLS_ENABLED: false, + PUBLIC_BACKEND_URL: '', }; let contextExternalToolService: DeepMocked; let copyHelperService: DeepMocked; diff --git a/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts b/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts index 5e8b9099488..911864a7622 100644 --- a/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts +++ b/apps/server/src/modules/oauth-provider/api/test/oauth-provider.controller.api.spec.ts @@ -536,7 +536,7 @@ describe(OauthProviderController.name, () => { const setup = async () => { const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); const consentListResponse: ProviderConsentSessionResponse = providerConsentSessionResponseFactory.build(); - const externalTool = externalToolEntityFactory.withOauth2Config('clientId').buildWithId(); + const externalTool = externalToolEntityFactory.withOauth2Config().buildWithId(); const pseudonym = externalToolPseudonymEntityFactory.buildWithId({ toolId: externalTool.id, userId: studentUser.id, diff --git a/apps/server/src/modules/server/admin-api-server.config.ts b/apps/server/src/modules/server/admin-api-server.config.ts index d0656f53483..4e889b24015 100644 --- a/apps/server/src/modules/server/admin-api-server.config.ts +++ b/apps/server/src/modules/server/admin-api-server.config.ts @@ -67,6 +67,7 @@ const config: AdminApiServerConfig = { TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION: Configuration.get( 'TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION' ) as string, + PUBLIC_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string, }; export const adminApiServerConfig = () => config; diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index f4e5eb6ec5d..3c8f9d89708 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -323,6 +323,7 @@ const config: ServerConfig = { AES_KEY: Configuration.get('AES_KEY') as string, FEATURE_OAUTH_LOGIN: Configuration.get('FEATURE_OAUTH_LOGIN') as boolean, FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED: Configuration.get('FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED') as boolean, + PUBLIC_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string, }; export const serverConfig = () => config; diff --git a/apps/server/src/modules/tool/common/common-tool.module.ts b/apps/server/src/modules/tool/common/common-tool.module.ts index a48b814900f..cb6b7e20ff5 100644 --- a/apps/server/src/modules/tool/common/common-tool.module.ts +++ b/apps/server/src/modules/tool/common/common-tool.module.ts @@ -4,7 +4,12 @@ import { CqrsModule } from '@nestjs/cqrs'; import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { SchoolModule } from '@src/modules/school'; -import { CommonToolDeleteService, CommonToolService, CommonToolValidationService } from './service'; +import { + CommonToolDeleteService, + CommonToolService, + CommonToolValidationService, + Lti11EncryptionService, +} from './service'; import { CommonToolMetadataService } from './service/common-tool-metadata.service'; @Module({ @@ -18,6 +23,7 @@ import { CommonToolMetadataService } from './service/common-tool-metadata.servic ContextExternalToolRepo, CommonToolMetadataService, CommonToolDeleteService, + Lti11EncryptionService, ], exports: [ CommonToolService, @@ -27,6 +33,7 @@ import { CommonToolMetadataService } from './service/common-tool-metadata.servic ContextExternalToolRepo, CommonToolMetadataService, CommonToolDeleteService, + Lti11EncryptionService, ], }) export class CommonToolModule {} diff --git a/apps/server/src/modules/tool/common/service/index.ts b/apps/server/src/modules/tool/common/service/index.ts index 9a6567dbbcf..43595c76ba7 100644 --- a/apps/server/src/modules/tool/common/service/index.ts +++ b/apps/server/src/modules/tool/common/service/index.ts @@ -1,3 +1,4 @@ export * from './common-tool.service'; export { CommonToolValidationService, ToolParameterTypeValidationUtil } from './validation'; export { CommonToolDeleteService } from './common-tool-delete.service'; +export { Lti11EncryptionService } from './lti11-encryption.service'; diff --git a/apps/server/src/modules/tool/common/service/lti11-encryption.service.spec.ts b/apps/server/src/modules/tool/common/service/lti11-encryption.service.spec.ts new file mode 100644 index 00000000000..ef8baadfaa3 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/lti11-encryption.service.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Authorization } from 'oauth-1.0a'; +import { Lti11EncryptionService } from './lti11-encryption.service'; + +describe(Lti11EncryptionService.name, () => { + let module: TestingModule; + let service: Lti11EncryptionService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [Lti11EncryptionService], + }).compile(); + + service = module.get(Lti11EncryptionService); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('sign', () => { + describe('when signing with OAuth1', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const mockUrl = 'https://mockurl.com/'; + const testPayload: Record = { + param1: 'test1', + }; + + return { + mockKey, + mockSecret, + mockUrl, + testPayload, + }; + }; + + it('should sign the payload with OAuth1', () => { + const { mockKey, mockSecret, mockUrl, testPayload } = setup(); + + const result: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); + + expect(result).toEqual({ + oauth_consumer_key: mockKey, + oauth_nonce: expect.any(String), + oauth_signature: expect.any(String), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: expect.any(Number), + oauth_version: '1.0', + ...testPayload, + }); + }); + }); + }); + + describe('verify', () => { + describe('when the OAuth1 signature is valid', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const mockUrl = 'https://mockurl.com/'; + const testPayload: Record = { + param1: 'test1', + }; + + const signedPayload: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); + + return { + mockKey, + mockSecret, + mockUrl, + testPayload, + signedPayload, + }; + }; + + it('should return true', () => { + const { mockKey, mockSecret, mockUrl, signedPayload } = setup(); + + const result = service.verify(mockKey, mockSecret, mockUrl, signedPayload); + + expect(result).toEqual(true); + }); + }); + + describe('when the OAuth1 signature is invalid', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const mockUrl = 'https://mockurl.com/'; + const testPayload: Record = { + param1: 'test1', + }; + + const signedPayload: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); + const tamperedPayload = { ...signedPayload, param1: 'test2' }; + + return { + mockKey, + mockSecret, + mockUrl, + testPayload, + tamperedPayload, + }; + }; + + it('should return false', () => { + const { mockKey, mockSecret, mockUrl, tamperedPayload } = setup(); + + const result = service.verify(mockKey, mockSecret, mockUrl, tamperedPayload); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.ts b/apps/server/src/modules/tool/common/service/lti11-encryption.service.ts similarity index 62% rename from apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.ts rename to apps/server/src/modules/tool/common/service/lti11-encryption.service.ts index 22f3bb9bee6..41e3b0c2581 100644 --- a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.ts +++ b/apps/server/src/modules/tool/common/service/lti11-encryption.service.ts @@ -4,7 +4,7 @@ import OAuth, { Authorization, RequestOptions } from 'oauth-1.0a'; @Injectable() export class Lti11EncryptionService { - public sign(key: string, secret: string, url: string, payload: Record): Authorization { + public sign(key: string, secret: string, url: string, payload: unknown): Authorization { const requestData: RequestOptions = { url, method: 'POST', @@ -25,4 +25,15 @@ export class Lti11EncryptionService { return authorization; } + + public verify(key: string, secret: string, url: string, payload: Authorization): boolean { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { oauth_signature, ...validationPayload } = payload; + + const authorization: Authorization = this.sign(key, secret, url, validationPayload); + + const isValid = oauth_signature === authorization.oauth_signature; + + return isValid; + } } diff --git a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts index 79e6cc63adc..9e5d4547989 100644 --- a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts +++ b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts @@ -6,9 +6,16 @@ import { CommonToolModule } from '../common'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; import { ContextExternalToolRule } from './authorisation/context-external-tool.rule'; -import { ContextExternalToolAuthorizableService, ContextExternalToolService, ToolReferenceService } from './service'; +import { LTI_DEEP_LINK_TOKEN_REPO, LtiDeepLinkTokenMikroOrmRepo } from './repo'; +import { + ContextExternalToolAuthorizableService, + ContextExternalToolService, + LtiDeepLinkingService, + LtiDeepLinkTokenService, + ToolConfigurationStatusService, + ToolReferenceService, +} from './service'; import { ContextExternalToolValidationService } from './service/context-external-tool-validation.service'; -import { ToolConfigurationStatusService } from './service/tool-configuration-status.service'; @Module({ imports: [ @@ -26,12 +33,20 @@ import { ToolConfigurationStatusService } from './service/tool-configuration-sta ToolReferenceService, ToolConfigurationStatusService, ContextExternalToolRule, + LtiDeepLinkTokenService, + LtiDeepLinkingService, + { + provide: LTI_DEEP_LINK_TOKEN_REPO, + useClass: LtiDeepLinkTokenMikroOrmRepo, + }, ], exports: [ ContextExternalToolService, ContextExternalToolValidationService, ToolReferenceService, ToolConfigurationStatusService, + LtiDeepLinkTokenService, + LtiDeepLinkingService, ], }) export class ContextExternalToolModule {} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-deep-link.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-deep-link.api.spec.ts new file mode 100644 index 00000000000..bb71215cf33 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-deep-link.api.spec.ts @@ -0,0 +1,132 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import crypto from 'crypto-js'; +import { externalToolEntityFactory, lti11ToolConfigEntityFactory } from '../../../external-tool/testing'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; +import { ContextExternalToolEntity, ContextExternalToolType, LtiDeepLinkEmbeddable } from '../../entity'; +import { + contextExternalToolEntityFactory, + Lti11DeepLinkParamsFactory, + ltiDeepLinkTokenEntityFactory, +} from '../../testing'; +import { Lti11DeepLinkContentItemParams } from '../dto'; + +describe('ToolDeepLinkController (API)', () => { + let app: INestApplication; + let em: EntityManager; + let orm: MikroORM; + let testApiClient: TestApiClient; + + const basePath = '/tools/context-external-tools'; + const decryptedSecret = 'secret'; + const encryptedSecret = crypto.AES.encrypt(decryptedSecret, Configuration.get('AES_KEY') as string).toString(); + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + orm = app.get(MikroORM); + testApiClient = new TestApiClient(app, basePath); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await orm.getSchemaGenerator().clearDatabase(); + }); + + describe('[POST] tools/context-external-tools/:contextExternalToolId/lti11-deep-link-callback', () => { + describe('when the lti deep linking callback is successfully', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const ltiDeepLinkToken = ltiDeepLinkTokenEntityFactory.build({ user: teacherUser }); + const course = courseFactory.buildWithId({ + teachers: [teacherUser], + }); + + const lti11Config = lti11ToolConfigEntityFactory.build({ + secret: encryptedSecret, + }); + const externalTool = externalToolEntityFactory.buildWithId({ config: lti11Config }); + const schoolExternalTool = schoolExternalToolEntityFactory.buildWithId({ + tool: externalTool, + school: teacherUser.school, + }); + const contextExternalTool = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalTool, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + }); + + const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; + const callbackUrl = `${publicBackendUrl}/v3${basePath}/${contextExternalTool.id}/lti11-deep-link-callback`; + const requestFactory = new Lti11DeepLinkParamsFactory(callbackUrl, lti11Config.key, decryptedSecret); + const postParams = requestFactory.buildRaw({ + data: ltiDeepLinkToken.state, + }); + + await em.persistAndFlush([ + teacherAccount, + teacherUser, + ltiDeepLinkToken, + course, + externalTool, + schoolExternalTool, + contextExternalTool, + ]); + em.clear(); + + const targetContent = requestFactory.build({ + data: ltiDeepLinkToken.state, + }).content_items?.['@graph'][0] as Lti11DeepLinkContentItemParams; + + return { + postParams, + contextExternalTool, + targetContent, + }; + }; + + it('should create a lti deep link with the context external tool', async () => { + const { postParams, contextExternalTool, targetContent } = await setup(); + + const response = await testApiClient + .post(`/${contextExternalTool.id}/lti11-deep-link-callback`) + .send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(response.text).toEqual( + 'Window can be closedThis window can be closed' + ); + const dbContextExternalTool = await em.findOneOrFail(ContextExternalToolEntity, contextExternalTool.id); + expect(dbContextExternalTool.ltiDeepLink).toMatchObject({ + mediaType: targetContent.mediaType, + title: targetContent.title, + url: targetContent.url, + text: targetContent.text, + parameters: [ + { + name: 'dl_param', + value: targetContent.custom?.dl_param, + }, + ], + availableFrom: targetContent.available?.startDatetime, + availableUntil: targetContent.available?.endDatetime, + submissionFrom: targetContent.submission?.startDatetime, + submissionUntil: targetContent.submission?.endDatetime, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts index 5803971a64b..180fcab6715 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts @@ -7,19 +7,24 @@ import { Permission } from '@shared/domain/interface'; import { cleanupCollections, courseFactory, + DateToString, fileRecordFactory, schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; import { Response } from 'supertest'; -import { CustomParameterLocation, CustomParameterScope, ToolContextType } from '../../../common/enum'; +import { CustomParameterLocation, CustomParameterScope, LtiMessageType, ToolContextType } from '../../../common/enum'; import { ExternalToolEntity } from '../../../external-tool/entity'; import { customParameterFactory, externalToolEntityFactory } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; -import { contextExternalToolConfigurationStatusResponseFactory, contextExternalToolEntityFactory } from '../../testing'; +import { + contextExternalToolConfigurationStatusResponseFactory, + contextExternalToolEntityFactory, + ltiDeepLinkEmbeddableFactory, +} from '../../testing'; import { ContextExternalToolContextParams, ToolReferenceListResponse, ToolReferenceResponse } from '../dto'; describe('ToolReferenceController (API)', () => { @@ -188,6 +193,7 @@ describe('ToolReferenceController (API)', () => { thumbnailUrl: `/api/v3/file/preview/${thumbnailFileRecord.id}/${encodeURIComponent( thumbnailFileRecord.name )}`, + isLtiDeepLinkingTool: false, }, ], }); @@ -257,34 +263,40 @@ describe('ToolReferenceController (API)', () => { ]); const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); const thumbnailFileRecord = fileRecordFactory.build(); - const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ - logoBase64: 'logoBase64', - parameters: [ - customParameterFactory.build({ - name: 'schoolMockParameter', - scope: CustomParameterScope.SCHOOL, - location: CustomParameterLocation.PATH, - }), - customParameterFactory.build({ - name: 'contextMockParameter', - scope: CustomParameterScope.CONTEXT, - location: CustomParameterLocation.PATH, - }), - ], - thumbnail: { - uploadUrl: 'https://uploadurl.com', - fileRecord: thumbnailFileRecord, - }, - }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory + .withLti11Config({ + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + }) + .buildWithId({ + logoBase64: 'logoBase64', + parameters: [ + customParameterFactory.build({ + name: 'schoolMockParameter', + scope: CustomParameterScope.SCHOOL, + location: CustomParameterLocation.PATH, + }), + customParameterFactory.build({ + name: 'contextMockParameter', + scope: CustomParameterScope.CONTEXT, + location: CustomParameterLocation.PATH, + }), + ], + thumbnail: { + uploadUrl: 'https://uploadurl.com', + fileRecord: thumbnailFileRecord, + }, + }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, tool: externalToolEntity, }); + const ltiDeepLinkEmbeddable = ltiDeepLinkEmbeddableFactory.build(); const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, displayName: 'This is a test tool', + ltiDeepLink: ltiDeepLinkEmbeddable, }); await em.persistAndFlush([ @@ -307,6 +319,7 @@ describe('ToolReferenceController (API)', () => { contextExternalToolEntity, externalToolEntity, thumbnailFileRecord, + ltiDeepLinkEmbeddable, }; }; @@ -317,12 +330,13 @@ describe('ToolReferenceController (API)', () => { contextExternalToolEntity, externalToolEntity, thumbnailFileRecord, + ltiDeepLinkEmbeddable, } = await setup(); const response: Response = await loggedInClient.get(`context-external-tools/${contextExternalToolId}`); expect(response.statusCode).toEqual(HttpStatus.OK); - expect(response.body).toEqual({ + expect(response.body).toEqual>({ contextToolId: contextExternalToolEntity.id, description: externalToolEntity.description, displayName: contextExternalToolEntity.displayName as string, @@ -335,6 +349,16 @@ describe('ToolReferenceController (API)', () => { thumbnailUrl: `/api/v3/file/preview/${thumbnailFileRecord.id}/${encodeURIComponent( thumbnailFileRecord.name )}`, + isLtiDeepLinkingTool: true, + ltiDeepLink: { + mediaType: ltiDeepLinkEmbeddable.mediaType, + title: ltiDeepLinkEmbeddable.title, + text: ltiDeepLinkEmbeddable.text, + availableFrom: ltiDeepLinkEmbeddable.availableFrom?.toISOString(), + availableUntil: ltiDeepLinkEmbeddable.availableUntil?.toISOString(), + submissionFrom: ltiDeepLinkEmbeddable.submissionFrom?.toISOString(), + submissionUntil: ltiDeepLinkEmbeddable.submissionUntil?.toISOString(), + }, }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts index 60145a6d140..0584d6a981d 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool.response.ts @@ -21,9 +21,6 @@ export class ContextExternalToolResponse { @ApiProperty({ type: [CustomParameterEntryResponse] }) parameters: CustomParameterEntryResponse[] = []; - @ApiPropertyOptional() - logoUrl?: string; - constructor(response: ContextExternalToolResponse) { this.id = response.id; this.schoolToolId = response.schoolToolId; @@ -31,6 +28,5 @@ export class ContextExternalToolResponse { this.contextType = response.contextType; this.displayName = response.displayName; this.parameters = response.parameters; - this.logoUrl = response.logoUrl; } } diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts index 89922cda255..a41423e9d72 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts @@ -5,3 +5,4 @@ export * from './context-external-tool-context.params'; export * from './context-external-tool.response'; export * from './tool-reference-list.response'; export * from './tool-reference.response'; +export * from './lti11-deep-link'; diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/index.ts new file mode 100644 index 00000000000..1053bafe92a --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/index.ts @@ -0,0 +1,6 @@ +export { Lti11DeepLinkContentItemParams } from './lti11-deep-link-content-item.params'; +export { Lti11DeepLinkContentItemListParams } from './lti11-deep-link-content-item-list.params'; +export { Lti11ContentItemType } from './lti11-content-item-type'; +export { Lti11DeepLinkParams } from './lti11-deep-link.params'; +export { LtiDeepLinkResponse } from './lti-deep-link.response'; +export { Lti11DeepLinkContentItemDurationParams } from './lti11-deep-link-content-item-duration.params'; diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti-deep-link.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti-deep-link.response.ts new file mode 100644 index 00000000000..5962dd333fb --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti-deep-link.response.ts @@ -0,0 +1,34 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class LtiDeepLinkResponse { + @ApiProperty() + mediaType: string; + + @ApiPropertyOptional() + title?: string; + + @ApiPropertyOptional() + text?: string; + + @ApiPropertyOptional() + availableFrom?: Date; + + @ApiPropertyOptional() + availableUntil?: Date; + + @ApiPropertyOptional() + submissionFrom?: Date; + + @ApiPropertyOptional() + submissionUntil?: Date; + + constructor(props: LtiDeepLinkResponse) { + this.mediaType = props.mediaType; + this.title = props.title; + this.text = props.text; + this.availableFrom = props.availableFrom; + this.availableUntil = props.availableUntil; + this.submissionFrom = props.submissionFrom; + this.submissionUntil = props.submissionUntil; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-content-item-type.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-content-item-type.ts new file mode 100644 index 00000000000..5764510b9db --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-content-item-type.ts @@ -0,0 +1,5 @@ +export enum Lti11ContentItemType { + CONTENT_ITEM = 'ContentItem', + LTI_LINK_ITEM = 'LtiLinkItem', + FILE_ITEM = 'FileItem', +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-duration.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-duration.params.ts new file mode 100644 index 00000000000..102cc5eb147 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-duration.params.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDate, IsOptional } from 'class-validator'; + +export class Lti11DeepLinkContentItemDurationParams { + @IsOptional() + @IsDate() + @ApiPropertyOptional() + startDatetime?: Date; + + @IsOptional() + @IsDate() + @ApiPropertyOptional() + endDatetime?: Date; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-list.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-list.params.ts new file mode 100644 index 00000000000..1ebb6b7f9d8 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item-list.params.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsString, ValidateNested } from 'class-validator'; +import { Lti11DeepLinkContentItemParams } from './lti11-deep-link-content-item.params'; + +export class Lti11DeepLinkContentItemListParams { + @IsString() + @ApiProperty() + '@context'!: string; + + @IsArray() + @ArrayMaxSize(1) + @ValidateNested({ each: true }) + @Type(() => Lti11DeepLinkContentItemParams) + @ApiProperty() + '@graph'!: Lti11DeepLinkContentItemParams[]; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item.params.ts new file mode 100644 index 00000000000..402eb2f376c --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-content-item.params.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ValidateRecord } from '@shared/controller'; +import { Type } from 'class-transformer'; +import { IsEnum, IsObject, IsOptional, isString, IsString, ValidateNested } from 'class-validator'; +import { Lti11ContentItemType } from './lti11-content-item-type'; +import { Lti11DeepLinkContentItemDurationParams } from './lti11-deep-link-content-item-duration.params'; + +export class Lti11DeepLinkContentItemParams { + @IsEnum(Lti11ContentItemType) + @ApiProperty() + '@type'!: Lti11ContentItemType; + + @IsString() + @ApiProperty() + mediaType!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + url?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + title?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + text?: string; + + @IsOptional() + @ValidateNested() + @Type(() => Lti11DeepLinkContentItemDurationParams) + @ApiPropertyOptional() + available?: Lti11DeepLinkContentItemDurationParams; + + @IsOptional() + @ValidateNested() + @Type(() => Lti11DeepLinkContentItemDurationParams) + @ApiPropertyOptional() + submission?: Lti11DeepLinkContentItemDurationParams; + + @IsOptional() + @IsObject() + @ValidateRecord(isString) + @Type(() => Object) + @ApiPropertyOptional() + custom?: Record; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-raw.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-raw.params.ts new file mode 100644 index 00000000000..847f151cac5 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link-raw.params.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Equals, IsJSON, IsNumber, IsOptional, IsString } from 'class-validator'; +import { Authorization } from 'oauth-1.0a'; + +export class Lti11DeepLinkParamsRaw implements Authorization { + @Equals('ContentItemSelection') + @ApiProperty() + lti_message_type!: string; + + @Equals('LTI-1p0') + @ApiProperty() + lti_version!: string; + + @IsOptional() + @IsJSON() + @ApiPropertyOptional() + content_items?: string; + + @IsString() + @ApiProperty() + data!: string; + + @Equals('1.0') + @ApiProperty() + oauth_version!: string; + + @IsString() + @ApiProperty() + oauth_nonce!: string; + + @IsNumber() + @ApiProperty() + oauth_timestamp!: number; + + @Equals('HMAC-SHA1') + @ApiProperty() + oauth_signature_method!: string; + + @IsString() + @ApiProperty() + oauth_consumer_key!: string; + + @IsString() + @ApiProperty() + oauth_signature!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + oauth_callback?: string; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link.params.ts new file mode 100644 index 00000000000..047e44d91b5 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/lti11-deep-link/lti11-deep-link.params.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StringToObject } from '@shared/controller'; +import { Type } from 'class-transformer'; +import { Equals, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { Lti11DeepLinkContentItemListParams } from './lti11-deep-link-content-item-list.params'; + +export class Lti11DeepLinkParams { + @Equals('ContentItemSelection') + @ApiProperty() + lti_message_type!: string; + + @Equals('LTI-1p0') + @ApiProperty() + lti_version!: string; + + @IsOptional() + @IsObject() + @ValidateNested() + @StringToObject(Lti11DeepLinkContentItemListParams) + @Type(() => Lti11DeepLinkContentItemListParams) + @ApiPropertyOptional() + content_items?: Lti11DeepLinkContentItemListParams; + + @IsString() + @ApiProperty() + data!: string; + + @Equals('1.0') + @ApiProperty() + oauth_version!: string; + + @IsString() + @ApiProperty() + oauth_nonce!: string; + + @IsNumber() + @ApiProperty() + oauth_timestamp!: number; + + @Equals('HMAC-SHA1') + @ApiProperty() + oauth_signature_method!: string; + + @IsString() + @ApiProperty() + oauth_consumer_key!: string; + + @IsString() + @ApiProperty() + oauth_signature!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + oauth_callback?: string; +} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts index 9f66a020c3d..834ca4ad69a 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts @@ -1,41 +1,42 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ContextExternalToolConfigurationStatusResponse } from '../../../common/controller/dto'; +import { LtiDeepLinkResponse } from './lti11-deep-link'; export class ToolReferenceResponse { - @ApiProperty({ nullable: false, required: true, description: 'The id of the tool in the context' }) + @ApiProperty({ description: 'The id of the tool in the context' }) contextToolId: string; @ApiPropertyOptional({ description: 'The description of the tool' }) description?: string; @ApiPropertyOptional({ - nullable: false, - required: false, description: 'The url of the logo which is stored in the db', }) logoUrl?: string; @ApiPropertyOptional({ - nullable: false, - required: false, description: 'The url of the thumbnail which is stored in the db', }) thumbnailUrl?: string; - @ApiProperty({ nullable: false, required: true, description: 'The display name of the tool' }) + @ApiProperty({ description: 'The display name of the tool' }) displayName: string; - @ApiProperty({ nullable: false, required: true, description: 'Whether the tool should be opened in a new tab' }) + @ApiProperty({ description: 'Whether the tool should be opened in a new tab' }) openInNewTab: boolean; @ApiProperty({ type: ContextExternalToolConfigurationStatusResponse, - nullable: false, - required: true, description: 'The status of the tool', }) status: ContextExternalToolConfigurationStatusResponse; + @ApiProperty({ description: 'Whether the tool is a lti deep linking tool' }) + isLtiDeepLinkingTool: boolean; + + @ApiPropertyOptional({ type: LtiDeepLinkResponse }) + ltiDeepLink?: LtiDeepLinkResponse; + constructor(toolReferenceResponse: ToolReferenceResponse) { this.contextToolId = toolReferenceResponse.contextToolId; this.description = toolReferenceResponse.description; @@ -44,5 +45,7 @@ export class ToolReferenceResponse { this.displayName = toolReferenceResponse.displayName; this.openInNewTab = toolReferenceResponse.openInNewTab; this.status = toolReferenceResponse.status; + this.isLtiDeepLinkingTool = toolReferenceResponse.isLtiDeepLinkingTool; + this.ltiDeepLink = toolReferenceResponse.ltiDeepLink; } } diff --git a/apps/server/src/modules/tool/context-external-tool/controller/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/index.ts index 6927a20482c..1a0addf31d9 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/index.ts @@ -1,2 +1,3 @@ export * from './tool-context.controller'; export { AdminApiContextExternalToolController } from './admin-api-context-external-tool.controller'; +export { ToolDeepLinkController } from './tool-deep-link.controller'; diff --git a/apps/server/src/modules/tool/context-external-tool/controller/tool-deep-link.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/tool-deep-link.controller.ts new file mode 100644 index 00000000000..95339a12eb9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/tool-deep-link.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { LtiDeepLink } from '../domain'; +import { LtiDeepLinkRequestMapper } from '../mapper'; +import { ContextExternalToolUc } from '../uc'; +import { ContextExternalToolIdParams, Lti11DeepLinkParams } from './dto'; +import { Lti11DeepLinkParamsRaw } from './dto/lti11-deep-link/lti11-deep-link-raw.params'; + +@ApiTags('Tool') +@Controller('tools/context-external-tools') +export class ToolDeepLinkController { + constructor(private readonly contextExternalToolUc: ContextExternalToolUc) {} + + @Post(':contextExternalToolId/lti11-deep-link-callback') + public async deepLink( + @Param() params: ContextExternalToolIdParams, + @Body() rawBody: Lti11DeepLinkParamsRaw, + @Body() body: Lti11DeepLinkParams + ): Promise { + const deepLink: LtiDeepLink | undefined = LtiDeepLinkRequestMapper.mapRequestToDO(body); + + await this.contextExternalToolUc.updateLtiDeepLink(params.contextExternalToolId, rawBody, body.data, deepLink); + + return 'Window can be closedThis window can be closed'; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts index 0b3a2bf7335..dbf649fc92e 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts @@ -2,6 +2,7 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { CustomParameterEntry } from '../../common/domain'; import { SchoolExternalToolRef } from '../../school-external-tool/domain'; import { ContextRef } from './context-ref'; +import { LtiDeepLink } from './lti-deep-link'; export interface ContextExternalToolLaunchable { id?: string; @@ -11,6 +12,8 @@ export interface ContextExternalToolLaunchable { contextRef: ContextRef; parameters: CustomParameterEntry[]; + + ltiDeepLink?: LtiDeepLink; } export interface ContextExternalToolProps extends AuthorizableObject, ContextExternalToolLaunchable { @@ -32,7 +35,19 @@ export class ContextExternalTool extends DomainObject return this.props.displayName; } + set displayName(value: string | undefined) { + this.props.displayName = value; + } + get parameters(): CustomParameterEntry[] { return this.props.parameters; } + + get ltiDeepLink(): LtiDeepLink | undefined { + return this.props.ltiDeepLink; + } + + set ltiDeepLink(value: LtiDeepLink | undefined) { + this.props.ltiDeepLink = value; + } } diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts index 75d3370f475..ca3eeb7e8e4 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/index.ts @@ -1 +1,5 @@ export { RestrictedContextMismatchLoggableException } from './restricted-context-mismatch-loggabble'; +export { LtiDeepLinkTokenMissingLoggableException } from './lti-deep-link-token-missing.loggable-exception'; +export { LtiMessageTypeNotImplementedLoggableException } from './lti-message-type-not-implemented.loggable-exception'; +export { InvalidToolTypeLoggableException } from './invalid-tool-type.loggable-exception'; +export { InvalidOauthSignatureLoggableException } from './invalid-oauth-signature.loggable-exception'; diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.spec.ts new file mode 100644 index 00000000000..2374ee52909 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.spec.ts @@ -0,0 +1,25 @@ +import { InvalidOauthSignatureLoggableException } from './invalid-oauth-signature.loggable-exception'; + +describe(InvalidOauthSignatureLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const loggable = new InvalidOauthSignatureLoggableException(); + + return { + loggable, + }; + }; + + it('should return a loggable message', () => { + const { loggable } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_OAUTH_SIGNATURE', + message: 'The oauth signature is invalid.', + stack: loggable.stack, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.ts new file mode 100644 index 00000000000..0a4966999e6 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-oauth-signature.loggable-exception.ts @@ -0,0 +1,14 @@ +import { BadRequestException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class InvalidOauthSignatureLoggableException extends BadRequestException implements Loggable { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'INVALID_OAUTH_SIGNATURE', + message: 'The oauth signature is invalid.', + stack: this.stack, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.spec.ts new file mode 100644 index 00000000000..9183c772095 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { ToolConfigType } from '../../../common/enum'; +import { InvalidToolTypeLoggableException } from './invalid-tool-type.loggable-exception'; + +describe(InvalidToolTypeLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const expected = ToolConfigType.LTI11; + const received = ToolConfigType.OAUTH2; + + const loggable = new InvalidToolTypeLoggableException(expected, received); + + return { + loggable, + expected, + received, + }; + }; + + it('should return a loggable message', () => { + const { loggable, expected, received } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_TOOL_TYPE', + message: 'The external tool has the wrong tool type.', + stack: loggable.stack, + data: { + expected, + received, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.ts new file mode 100644 index 00000000000..7974634fde2 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/invalid-tool-type.loggable-exception.ts @@ -0,0 +1,23 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ToolConfigType } from '../../../common/enum'; + +export class InvalidToolTypeLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly expected: ToolConfigType, private readonly received: ToolConfigType) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'INVALID_TOOL_TYPE', + message: 'The external tool has the wrong tool type.', + stack: this.stack, + data: { + expected: this.expected, + received: this.received, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.spec.ts new file mode 100644 index 00000000000..0822150fe0c --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.spec.ts @@ -0,0 +1,36 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { UUID } from 'bson'; +import { LtiDeepLinkTokenMissingLoggableException } from './lti-deep-link-token-missing.loggable-exception'; + +describe(LtiDeepLinkTokenMissingLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const state = new UUID().toString(); + const contextExternalToolId = new ObjectId().toHexString(); + + const loggable = new LtiDeepLinkTokenMissingLoggableException(state, contextExternalToolId); + + return { + loggable, + state, + contextExternalToolId, + }; + }; + + it('should return a loggable message', () => { + const { loggable, state, contextExternalToolId } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'UNAUTHORIZED_EXCEPTION', + message: 'Unable to find lti deep link token for this state. It might have expired.', + stack: loggable.stack, + data: { + state, + contextExternalToolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.ts new file mode 100644 index 00000000000..8fe180a21b6 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-deep-link-token-missing.loggable-exception.ts @@ -0,0 +1,23 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class LtiDeepLinkTokenMissingLoggableException extends UnauthorizedException implements Loggable { + constructor(private readonly state: string, private readonly contextExternalToolId: EntityId) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'UNAUTHORIZED_EXCEPTION', + message: 'Unable to find lti deep link token for this state. It might have expired.', + stack: this.stack, + data: { + state: this.state, + contextExternalToolId: this.contextExternalToolId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.spec.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.spec.ts new file mode 100644 index 00000000000..1d612307fc9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { LtiMessageTypeNotImplementedLoggableException } from './lti-message-type-not-implemented.loggable-exception'; + +describe(LtiMessageTypeNotImplementedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const unknownMessageType = 'unknownMessageType'; + + const loggable = new LtiMessageTypeNotImplementedLoggableException(unknownMessageType); + + return { + loggable, + unknownMessageType, + }; + }; + + it('should return a loggable message', () => { + const { loggable, unknownMessageType } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'LTI_MESSAGE_TYPE_NOT_IMPLEMENTED', + message: 'The lti message type is not implemented.', + stack: loggable.stack, + data: { + lti_message_type: unknownMessageType, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.ts b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.ts new file mode 100644 index 00000000000..31202563978 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/error/lti-message-type-not-implemented.loggable-exception.ts @@ -0,0 +1,21 @@ +import { NotImplementedException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class LtiMessageTypeNotImplementedLoggableException extends NotImplementedException implements Loggable { + constructor(private readonly ltiMessageType: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'LTI_MESSAGE_TYPE_NOT_IMPLEMENTED', + message: 'The lti message type is not implemented.', + stack: this.stack, + data: { + lti_message_type: this.ltiMessageType, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/index.ts index bb51be61682..e0b8c357724 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/index.ts @@ -2,4 +2,6 @@ export * from './context-external-tool.do'; export * from './context-ref'; export * from './tool-reference'; export * from './event'; -export { RestrictedContextMismatchLoggableException } from './error'; +export * from './error'; +export { LtiDeepLink } from './lti-deep-link'; +export { LtiDeepLinkToken, LtiDeepLinkTokenProps } from './lti-deep-link-token'; diff --git a/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link-token.ts b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link-token.ts new file mode 100644 index 00000000000..3ad685a9f36 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link-token.ts @@ -0,0 +1,24 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; + +export interface LtiDeepLinkTokenProps extends AuthorizableObject { + state: string; + + userId: EntityId; + + expiresAt: Date; +} + +export class LtiDeepLinkToken extends DomainObject { + get state(): string { + return this.props.state; + } + + get userId(): EntityId { + return this.props.userId; + } + + get expiresAt(): Date { + return this.props.expiresAt; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link.ts b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link.ts new file mode 100644 index 00000000000..f2695e92391 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/lti-deep-link.ts @@ -0,0 +1,33 @@ +import { CustomParameterEntry } from '../../common/domain'; + +export class LtiDeepLink { + mediaType: string; + + url?: string; + + title?: string; + + text?: string; + + parameters: CustomParameterEntry[]; + + availableFrom?: Date; + + availableUntil?: Date; + + submissionFrom?: Date; + + submissionUntil?: Date; + + constructor(props: LtiDeepLink) { + this.mediaType = props.mediaType; + this.url = props.url; + this.title = props.title; + this.text = props.text; + this.parameters = props.parameters; + this.availableFrom = props.availableFrom; + this.availableUntil = props.availableUntil; + this.submissionFrom = props.submissionFrom; + this.submissionUntil = props.submissionUntil; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts b/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts index 4db5fe8f120..76e857c3ece 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts @@ -1,4 +1,5 @@ import { ContextExternalToolConfigurationStatus } from '../../common/domain'; +import { LtiDeepLink } from './lti-deep-link'; export class ToolReference { contextToolId: string; @@ -15,6 +16,10 @@ export class ToolReference { status: ContextExternalToolConfigurationStatus; + isLtiDeepLinkingTool: boolean; + + ltiDeepLink?: LtiDeepLink; + constructor(toolReference: ToolReference) { this.contextToolId = toolReference.contextToolId; this.description = toolReference.description; @@ -23,5 +28,7 @@ export class ToolReference { this.displayName = toolReference.displayName; this.openInNewTab = toolReference.openInNewTab; this.status = toolReference.status; + this.isLtiDeepLinkingTool = toolReference.isLtiDeepLinkingTool; + this.ltiDeepLink = toolReference.ltiDeepLink; } } diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts index 17e415eb7d3..98581c15a41 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts @@ -5,6 +5,7 @@ import { EntityId } from '@shared/domain/types'; import { CustomParameterEntryEntity } from '../../common/entity'; import { SchoolExternalToolEntity } from '../../school-external-tool/entity'; import { ContextExternalToolType } from './context-external-tool-type.enum'; +import { LtiDeepLinkEmbeddable } from './lti-deep-link.embeddable'; export interface ContextExternalToolEntityProps { id?: EntityId; @@ -18,6 +19,8 @@ export interface ContextExternalToolEntityProps { displayName?: string; parameters?: CustomParameterEntryEntity[]; + + ltiDeepLink?: LtiDeepLinkEmbeddable; } @Entity({ tableName: 'context-external-tools' }) @@ -37,6 +40,9 @@ export class ContextExternalToolEntity extends BaseEntityWithTimestamps { @Embedded(() => CustomParameterEntryEntity, { array: true }) parameters: CustomParameterEntryEntity[]; + @Embedded(() => LtiDeepLinkEmbeddable, { nullable: true, object: true }) + ltiDeepLink?: LtiDeepLinkEmbeddable; + constructor(props: ContextExternalToolEntityProps) { super(); if (props.id) { @@ -47,5 +53,6 @@ export class ContextExternalToolEntity extends BaseEntityWithTimestamps { this.contextType = props.contextType; this.displayName = props.displayName; this.parameters = props.parameters ?? []; + this.ltiDeepLink = props.ltiDeepLink; } } diff --git a/apps/server/src/modules/tool/context-external-tool/entity/index.ts b/apps/server/src/modules/tool/context-external-tool/entity/index.ts index cc6609164e4..9399a382d9c 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/index.ts @@ -1,2 +1,4 @@ export * from './context-external-tool.entity'; export * from './context-external-tool-type.enum'; +export { LtiDeepLinkEmbeddable } from './lti-deep-link.embeddable'; +export { LtiDeepLinkTokenEntity, LtiDeepLinkTokenEntityProps } from './lti-deep-link-token.entity'; diff --git a/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link-token.entity.ts b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link-token.entity.ts new file mode 100644 index 00000000000..ebc8396a7ca --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link-token.entity.ts @@ -0,0 +1,38 @@ +import { Entity, Index, ManyToOne, Property, Unique } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { User } from '@shared/domain/entity/user.entity'; +import { EntityId } from '@shared/domain/types'; + +export interface LtiDeepLinkTokenEntityProps { + id?: EntityId; + + state: string; + + user: User; + + expiresAt: Date; +} + +@Entity({ tableName: 'lti-deep-link-token' }) +export class LtiDeepLinkTokenEntity extends BaseEntityWithTimestamps { + @Unique() + @Property() + state: string; + + @ManyToOne(() => User) + user: User; + + @Index({ options: { expireAfterSeconds: 0 } }) + @Property() + expiresAt: Date; + + constructor(props: LtiDeepLinkTokenEntityProps) { + super(); + if (props.id) { + this.id = props.id; + } + this.state = props.state; + this.user = props.user; + this.expiresAt = props.expiresAt; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link.embeddable.ts b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link.embeddable.ts new file mode 100644 index 00000000000..31889a2aee1 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/entity/lti-deep-link.embeddable.ts @@ -0,0 +1,44 @@ +import { Embeddable, Embedded, Property } from '@mikro-orm/core'; +import { CustomParameterEntryEntity } from '../../common/entity'; + +@Embeddable() +export class LtiDeepLinkEmbeddable { + @Property() + mediaType: string; + + @Property({ nullable: true }) + url?: string; + + @Property({ nullable: true }) + title?: string; + + @Property({ nullable: true }) + text?: string; + + @Embedded(() => CustomParameterEntryEntity, { array: true }) + parameters: CustomParameterEntryEntity[]; + + @Property({ nullable: true }) + availableFrom?: Date; + + @Property({ nullable: true }) + availableUntil?: Date; + + @Property({ nullable: true }) + submissionFrom?: Date; + + @Property({ nullable: true }) + submissionUntil?: Date; + + constructor(props: LtiDeepLinkEmbeddable) { + this.mediaType = props.mediaType; + this.title = props.title; + this.url = props.url; + this.parameters = props.parameters; + this.text = props.text; + this.availableFrom = props.availableFrom; + this.availableUntil = props.availableUntil; + this.submissionFrom = props.submissionFrom; + this.submissionUntil = props.submissionUntil; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts index 0da038d6124..b9d0d5fa18d 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts @@ -1,6 +1,6 @@ -import { ToolStatusResponseMapper } from '../../common/mapper/tool-status-response.mapper'; +import { ToolStatusResponseMapper } from '../../common/mapper'; import { CustomParameterEntryParam, CustomParameterEntryResponse } from '../../school-external-tool/controller/dto'; -import { ContextExternalToolResponse, ToolReferenceResponse } from '../controller/dto'; +import { ContextExternalToolResponse, LtiDeepLinkResponse, ToolReferenceResponse } from '../controller/dto'; import { ContextExternalTool, ToolReference } from '../domain'; export class ContextExternalToolResponseMapper { @@ -43,6 +43,19 @@ export class ContextExternalToolResponseMapper { } static mapToToolReferenceResponse(toolReference: ToolReference): ToolReferenceResponse { + const { ltiDeepLink } = toolReference; + const ltiDeepLinkResponse: LtiDeepLinkResponse | undefined = ltiDeepLink + ? new LtiDeepLinkResponse({ + mediaType: ltiDeepLink.mediaType, + title: ltiDeepLink.title, + text: ltiDeepLink.text, + availableFrom: ltiDeepLink.availableFrom, + availableUntil: ltiDeepLink.availableUntil, + submissionFrom: ltiDeepLink.submissionFrom, + submissionUntil: ltiDeepLink.submissionUntil, + }) + : undefined; + const response = new ToolReferenceResponse({ contextToolId: toolReference.contextToolId, description: toolReference.description, @@ -51,6 +64,8 @@ export class ContextExternalToolResponseMapper { thumbnailUrl: toolReference.thumbnailUrl, openInNewTab: toolReference.openInNewTab, status: ToolStatusResponseMapper.mapToResponse(toolReference.status), + isLtiDeepLinkingTool: toolReference.isLtiDeepLinkingTool, + ltiDeepLink: ltiDeepLinkResponse, }); return response; diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/index.ts b/apps/server/src/modules/tool/context-external-tool/mapper/index.ts index 427f02a713a..31c1924280e 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/index.ts @@ -1,3 +1,4 @@ export * from './context-external-tool-request.mapper'; export * from './context-external-tool-response.mapper'; export * from './tool-reference.mapper'; +export { LtiDeepLinkRequestMapper } from './lti-deep-link-request.mapper'; diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/lti-deep-link-request.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/lti-deep-link-request.mapper.ts new file mode 100644 index 00000000000..92ec4342528 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/mapper/lti-deep-link-request.mapper.ts @@ -0,0 +1,32 @@ +import { CustomParameterEntry } from '../../common/domain'; +import { Lti11DeepLinkContentItemParams, Lti11DeepLinkParams } from '../controller/dto'; +import { LtiDeepLink } from '../domain'; + +export class LtiDeepLinkRequestMapper { + public static mapRequestToDO(params: Lti11DeepLinkParams): LtiDeepLink | undefined { + const contentItem: Lti11DeepLinkContentItemParams | undefined = params.content_items?.['@graph'][0]; + + let parameters: CustomParameterEntry[] = []; + if (contentItem?.custom) { + parameters = Object.entries(contentItem.custom).map( + ([key, value]: [string, string]) => new CustomParameterEntry({ name: key, value }) + ); + } + + const deepLink: LtiDeepLink | undefined = contentItem + ? new LtiDeepLink({ + mediaType: contentItem.mediaType, + title: contentItem.title, + text: contentItem.text, + url: contentItem.url, + parameters, + availableFrom: contentItem.available?.startDatetime, + availableUntil: contentItem.available?.endDatetime, + submissionFrom: contentItem.submission?.startDatetime, + submissionUntil: contentItem.submission?.endDatetime, + }) + : undefined; + + return deepLink; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts index b75c6759dda..3d3aeaf2cf3 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts @@ -16,6 +16,8 @@ export class ToolReferenceMapper { displayName: contextExternalTool.displayName ?? externalTool.name, status, openInNewTab: externalTool.openNewTab, + isLtiDeepLinkingTool: externalTool.isLtiDeepLinkingTool(), + ltiDeepLink: contextExternalTool.ltiDeepLink, }); return toolReference; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/index.ts b/apps/server/src/modules/tool/context-external-tool/repo/index.ts new file mode 100644 index 00000000000..a3f2b765f9f --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/index.ts @@ -0,0 +1,2 @@ +export { LtiDeepLinkTokenRepo, LTI_DEEP_LINK_TOKEN_REPO } from './lti-deep-link-token.repo.interface'; +export { LtiDeepLinkTokenMikroOrmRepo } from './mikro-orm'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/lti-deep-link-token.repo.interface.ts b/apps/server/src/modules/tool/context-external-tool/repo/lti-deep-link-token.repo.interface.ts new file mode 100644 index 00000000000..6f2e6356fdd --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/lti-deep-link-token.repo.interface.ts @@ -0,0 +1,11 @@ +import { LtiDeepLinkToken } from '../domain'; + +export interface LtiDeepLinkTokenRepo { + save(domainObject: LtiDeepLinkToken): Promise; + + delete(domainObject: LtiDeepLinkToken): Promise; + + findByState(state: string): Promise; +} + +export const LTI_DEEP_LINK_TOKEN_REPO = 'LTI_DEEP_LINK_TOKEN_REPO'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/index.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/index.ts new file mode 100644 index 00000000000..61fc9fc4ab9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/index.ts @@ -0,0 +1 @@ +export { LtiDeepLinkTokenMikroOrmRepo } from './lti-deep-link-token.repo'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.spec.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.spec.ts new file mode 100644 index 00000000000..7bef843ce8b --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.spec.ts @@ -0,0 +1,133 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { LtiDeepLinkToken } from '../../domain'; +import { LtiDeepLinkTokenEntity } from '../../entity'; +import { ltiDeepLinkTokenEntityFactory, ltiDeepLinkTokenFactory } from '../../testing'; +import { LTI_DEEP_LINK_TOKEN_REPO } from '../lti-deep-link-token.repo.interface'; +import { LtiDeepLinkTokenMikroOrmRepo } from './lti-deep-link-token.repo'; +import { LtiDeepLinkTokenEntityMapper } from './mapper'; + +describe(LtiDeepLinkTokenMikroOrmRepo.name, () => { + let module: TestingModule; + let repo: LtiDeepLinkTokenMikroOrmRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [{ provide: LTI_DEEP_LINK_TOKEN_REPO, useClass: LtiDeepLinkTokenMikroOrmRepo }], + }).compile(); + + repo = module.get(LTI_DEEP_LINK_TOKEN_REPO); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('save', () => { + describe('when a new object is provided', () => { + const setup = () => { + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build(); + + return { + ltiDeepLinkToken, + }; + }; + + it('should create a new entity', async () => { + const { ltiDeepLinkToken } = setup(); + + await repo.save(ltiDeepLinkToken); + + await expect(em.findOneOrFail(LtiDeepLinkTokenEntity, ltiDeepLinkToken.id)).resolves.toBeDefined(); + }); + + it('should return the object', async () => { + const { ltiDeepLinkToken } = setup(); + + const result = await repo.save(ltiDeepLinkToken); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + + describe('when an entity with the id exists', () => { + const setup = async () => { + const ltiDeepLinkTokenEntity = ltiDeepLinkTokenEntityFactory.build({ + state: 'token1', + }); + + await em.persistAndFlush(ltiDeepLinkTokenEntity); + em.clear(); + + const ltiDeepLinkToken = new LtiDeepLinkToken({ + ...LtiDeepLinkTokenEntityMapper.mapEntityToDo(ltiDeepLinkTokenEntity).getProps(), + state: 'token2', + }); + + return { + ltiDeepLinkToken, + }; + }; + + it('should update the entity', async () => { + const { ltiDeepLinkToken } = await setup(); + + await repo.save(ltiDeepLinkToken); + + await expect(em.findOneOrFail(LtiDeepLinkTokenEntity, ltiDeepLinkToken.id)).resolves.toEqual( + expect.objectContaining({ state: 'token2' }) + ); + }); + + it('should return the object', async () => { + const { ltiDeepLinkToken } = await setup(); + + const result = await repo.save(ltiDeepLinkToken); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); + + describe('findByState', () => { + describe('when a state without a saved token is provided', () => { + it('should return null', async () => { + const result = await repo.findByState('state'); + + expect(result).toBeNull(); + }); + }); + + describe('when a state with a saved token is provided', () => { + const setup = async () => { + const ltiDeepLinkTokenEntity = ltiDeepLinkTokenEntityFactory.buildWithId(); + + await em.persistAndFlush([ltiDeepLinkTokenEntity]); + em.clear(); + + const ltiDeepLinkToken = LtiDeepLinkTokenEntityMapper.mapEntityToDo(ltiDeepLinkTokenEntity); + + return { + ltiDeepLinkToken, + }; + }; + + it('should return the latest session token domain object', async () => { + const { ltiDeepLinkToken } = await setup(); + + const result = await repo.findByState(ltiDeepLinkToken.state); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.ts new file mode 100644 index 00000000000..ff74a88b4f4 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/lti-deep-link-token.repo.ts @@ -0,0 +1,33 @@ +import { EntityData, EntityName } from '@mikro-orm/core'; +import { Injectable } from '@nestjs/common'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; +import { LtiDeepLinkToken } from '../../domain'; +import { LtiDeepLinkTokenEntity } from '../../entity'; +import { LtiDeepLinkTokenRepo } from '../lti-deep-link-token.repo.interface'; +import { LtiDeepLinkTokenEntityMapper } from './mapper'; + +@Injectable() +export class LtiDeepLinkTokenMikroOrmRepo + extends BaseDomainObjectRepo + implements LtiDeepLinkTokenRepo +{ + protected get entityName(): EntityName { + return LtiDeepLinkTokenEntity; + } + + protected mapDOToEntityProperties(entityDO: LtiDeepLinkToken): EntityData { + return LtiDeepLinkTokenEntityMapper.mapDOToEntityProperties(entityDO, this.em); + } + + async findByState(state: string): Promise { + const sessionTokenEntity: LtiDeepLinkTokenEntity | null = await this.em.findOne(this.entityName, { state }); + + if (!sessionTokenEntity) { + return null; + } + + const sessionToken: LtiDeepLinkToken = LtiDeepLinkTokenEntityMapper.mapEntityToDo(sessionTokenEntity); + + return sessionToken; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/index.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/index.ts new file mode 100644 index 00000000000..aac11e9acb9 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/index.ts @@ -0,0 +1 @@ +export { LtiDeepLinkTokenEntityMapper } from './lti-deep-link-token-entity.mapper'; diff --git a/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/lti-deep-link-token-entity.mapper.ts b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/lti-deep-link-token-entity.mapper.ts new file mode 100644 index 00000000000..9a9d54cb4ab --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/repo/mikro-orm/mapper/lti-deep-link-token-entity.mapper.ts @@ -0,0 +1,31 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { User } from '@shared/domain/entity'; +import { LtiDeepLinkToken } from '../../../domain'; +import { LtiDeepLinkTokenEntity, LtiDeepLinkTokenEntityProps } from '../../../entity'; + +export class LtiDeepLinkTokenEntityMapper { + public static mapDOToEntityProperties( + domainObject: LtiDeepLinkToken, + em: EntityManager + ): LtiDeepLinkTokenEntityProps { + const entityProps: LtiDeepLinkTokenEntityProps = { + id: domainObject.id, + state: domainObject.state, + user: em.getReference(User, domainObject.userId), + expiresAt: domainObject.expiresAt, + }; + + return entityProps; + } + + public static mapEntityToDo(entity: LtiDeepLinkTokenEntity): LtiDeepLinkToken { + const domainObject = new LtiDeepLinkToken({ + id: entity.id, + userId: entity.user.id, + state: entity.state, + expiresAt: entity.expiresAt, + }); + + return domainObject; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts index 0f5a7f19601..a73bfe44a20 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts @@ -1,23 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ContextExternalToolNameAlreadyExistsLoggableException } from '@modules/tool/common/domain'; -import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ValidationError } from '@shared/common'; import { CommonToolValidationService } from '../../common/service'; -import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ContextExternalTool } from '../domain'; +import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { contextExternalToolFactory } from '../testing'; import { ContextExternalToolValidationService } from './context-external-tool-validation.service'; -import { ContextExternalToolService } from './context-external-tool.service'; describe('ContextExternalToolValidationService', () => { let module: TestingModule; let service: ContextExternalToolValidationService; - let contextExternalToolService: DeepMocked; let externalToolService: DeepMocked; let schoolExternalToolService: DeepMocked; let commonToolValidationService: DeepMocked; @@ -26,10 +21,6 @@ describe('ContextExternalToolValidationService', () => { module = await Test.createTestingModule({ providers: [ ContextExternalToolValidationService, - { - provide: ContextExternalToolService, - useValue: createMock(), - }, { provide: ExternalToolService, useValue: createMock(), @@ -46,7 +37,6 @@ describe('ContextExternalToolValidationService', () => { }).compile(); service = module.get(ContextExternalToolValidationService); - contextExternalToolService = module.get(ContextExternalToolService); externalToolService = module.get(ExternalToolService); schoolExternalToolService = module.get(SchoolExternalToolService); commonToolValidationService = module.get(CommonToolValidationService); @@ -61,17 +51,21 @@ describe('ContextExternalToolValidationService', () => { }); describe('validate', () => { - describe('when no tool with the name exists in the context', () => { + describe('when a tool is valid', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - externalToolService.findById.mockResolvedValue(externalTool); - - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + }, displayName: 'Tool 1', }); - contextExternalToolService.findContextExternalTools.mockResolvedValue([ - contextExternalToolFactory.buildWithId({ displayName: 'Tool 2' }), - ]); + + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); commonToolValidationService.validateParameters.mockReturnValue([]); return { @@ -80,17 +74,6 @@ describe('ContextExternalToolValidationService', () => { }; }; - it('should call contextExternalToolService.findContextExternalTools', async () => { - const { contextExternalTool } = setup(); - - await service.validate(contextExternalTool); - - expect(contextExternalToolService.findContextExternalTools).toBeCalledWith({ - schoolToolRef: contextExternalTool.schoolToolRef, - context: contextExternalTool.contextRef, - }); - }); - it('should call schoolExternalToolService.getSchoolExternalToolById', async () => { const { contextExternalTool } = setup(); @@ -107,83 +90,32 @@ describe('ContextExternalToolValidationService', () => { expect(commonToolValidationService.validateParameters).toBeCalledWith(externalTool, contextExternalTool); }); - it('should not throw UnprocessableEntityException', async () => { + it('should not throw', async () => { const { contextExternalTool } = setup(); const func = () => service.validate(contextExternalTool); - await expect(func()).resolves.not.toThrowError(UnprocessableEntityException); - }); - }); - - describe('when a tool with the same name already exists in that context', () => { - describe('when the displayName is undefined', () => { - const setup = () => { - const contextExternalTool1 = contextExternalToolFactory.buildWithId({ displayName: undefined }); - const contextExternalTool2 = contextExternalToolFactory.buildWithId({ displayName: undefined }); - - contextExternalToolService.findContextExternalTools.mockResolvedValue([contextExternalTool2]); - - return { - contextExternalTool1, - }; - }; - - it('should throw ValidationError', async () => { - const { contextExternalTool1 } = setup(); - - const func = () => service.validate(contextExternalTool1); - - await expect(func()).rejects.toThrowError( - new ContextExternalToolNameAlreadyExistsLoggableException( - contextExternalTool1.id, - contextExternalTool1.displayName - ) - ); - }); - }); - - describe('when the displayName is the same', () => { - const setup = () => { - const contextExternalTool1 = contextExternalToolFactory.buildWithId({ displayName: 'Existing Tool' }); - const contextExternalTool2 = contextExternalToolFactory.buildWithId({ displayName: 'Existing Tool' }); - - contextExternalToolService.findContextExternalTools.mockResolvedValue([contextExternalTool2]); - - return { - contextExternalTool1, - }; - }; - - it('should throw ValidationError', async () => { - const { contextExternalTool1 } = setup(); - - const func = () => service.validate(contextExternalTool1); - - await expect(func()).rejects.toThrowError( - new ContextExternalToolNameAlreadyExistsLoggableException( - contextExternalTool1.id, - contextExternalTool1.displayName - ) - ); - }); + await expect(func()).resolves.not.toThrow(); }); }); describe('when the parameter validation fails', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.build({ + toolId: externalTool.id, + }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + }, displayName: 'Tool 1', }); const error: ValidationError = new ValidationError(''); + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); - contextExternalToolService.findContextExternalTools.mockResolvedValue([ - contextExternalToolFactory.buildWithId({ displayName: 'Tool 2' }), - ]); commonToolValidationService.validateParameters.mockReturnValue([error]); return { diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts index 97bb49e1882..f83c6d2c597 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts @@ -1,26 +1,21 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { ContextExternalToolNameAlreadyExistsLoggableException } from '@modules/tool/common/domain/error/context-external-tool-name-already-exists.loggable-exception'; import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ContextExternalTool } from '../domain'; -import { ContextExternalToolService } from './context-external-tool.service'; @Injectable() export class ContextExternalToolValidationService { constructor( - private readonly contextExternalToolService: ContextExternalToolService, private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly commonToolValidationService: CommonToolValidationService ) {} async validate(contextExternalTool: ContextExternalTool): Promise { - await this.checkDuplicateUsesInContext(contextExternalTool); - const loadedSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId ); @@ -36,21 +31,4 @@ export class ContextExternalToolValidationService { throw errors[0]; } } - - private async checkDuplicateUsesInContext(contextExternalTool: ContextExternalTool) { - let duplicate: ContextExternalTool[] = await this.contextExternalToolService.findContextExternalTools({ - schoolToolRef: contextExternalTool.schoolToolRef, - context: contextExternalTool.contextRef, - }); - - // Only leave tools that are not the currently handled tool itself (for updates) or ones with the same name - duplicate = duplicate.filter( - (duplicateTool) => - duplicateTool.id !== contextExternalTool.id && duplicateTool.displayName === contextExternalTool.displayName - ); - - if (duplicate.length > 0) { - throw new ContextExternalToolNameAlreadyExistsLoggableException(duplicate[0].id, duplicate[0].displayName); - } - } } diff --git a/apps/server/src/modules/tool/context-external-tool/service/index.ts b/apps/server/src/modules/tool/context-external-tool/service/index.ts index ca5dd69b3f3..d6c56cef556 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/index.ts @@ -2,3 +2,5 @@ export * from './context-external-tool.service'; export * from './context-external-tool-authorizable.service'; export * from './tool-reference.service'; export { ToolConfigurationStatusService } from './tool-configuration-status.service'; +export { LtiDeepLinkingService } from './lti-deep-linking.service'; +export { LtiDeepLinkTokenService } from './lti-deep-link-token.service'; diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.spec.ts new file mode 100644 index 00000000000..d217bad54cb --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.spec.ts @@ -0,0 +1,108 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ToolConfig } from '../../tool-config'; +import { LtiDeepLinkToken } from '../domain'; +import { LTI_DEEP_LINK_TOKEN_REPO, LtiDeepLinkTokenRepo } from '../repo'; +import { ltiDeepLinkTokenFactory } from '../testing'; +import { LtiDeepLinkTokenService } from './lti-deep-link-token.service'; + +describe(LtiDeepLinkTokenService.name, () => { + let module: TestingModule; + let service: LtiDeepLinkTokenService; + + let ltiDeepLinkTokenRepo: DeepMocked; + let configService: DeepMocked>; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LtiDeepLinkTokenService, + { + provide: LTI_DEEP_LINK_TOKEN_REPO, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(LtiDeepLinkTokenService); + ltiDeepLinkTokenRepo = module.get(LTI_DEEP_LINK_TOKEN_REPO); + configService = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('generateToken', () => { + describe('when generating a token', () => { + const setup = () => { + jest.useFakeTimers().setSystemTime(new Date('2024-01-01')); + const tokenDuration = 2000; + + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ + expiresAt: new Date(Date.now() + tokenDuration), + }); + + configService.get.mockReturnValueOnce(tokenDuration); + ltiDeepLinkTokenRepo.save.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + ltiDeepLinkToken, + }; + }; + + it('should save a token', async () => { + const { ltiDeepLinkToken } = setup(); + + await service.generateToken(ltiDeepLinkToken.userId); + + expect(ltiDeepLinkTokenRepo.save).toHaveBeenCalledWith( + new LtiDeepLinkToken({ + ...ltiDeepLinkToken.getProps(), + id: expect.any(String), + state: expect.any(String), + }) + ); + }); + + it('should return a token', async () => { + const { ltiDeepLinkToken } = setup(); + + const result = await service.generateToken(ltiDeepLinkToken.userId); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); + + describe('findByState', () => { + describe('when searching a token by state', () => { + const setup = () => { + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build(); + + ltiDeepLinkTokenRepo.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + ltiDeepLinkToken, + }; + }; + + it('should return the token', async () => { + const { ltiDeepLinkToken } = setup(); + + const result = await service.findByState(ltiDeepLinkToken.state); + + expect(result).toEqual(ltiDeepLinkToken); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.ts new file mode 100644 index 00000000000..81cc64e5ab7 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-link-token.service.ts @@ -0,0 +1,37 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EntityId } from '@shared/domain/types'; +import { UUID } from 'bson'; +import { ToolConfig } from '../../tool-config'; +import { LtiDeepLinkToken } from '../domain'; +import { LTI_DEEP_LINK_TOKEN_REPO, LtiDeepLinkTokenRepo } from '../repo'; + +@Injectable() +export class LtiDeepLinkTokenService { + constructor( + @Inject(LTI_DEEP_LINK_TOKEN_REPO) private readonly ltiDeepLinkTokenRepo: LtiDeepLinkTokenRepo, + private readonly configService: ConfigService + ) {} + + public async generateToken(userId: EntityId): Promise { + const tokenDurationMs = this.configService.get('CTL_TOOLS_RELOAD_TIME_MS'); + + const ltiDeepLinkToken: LtiDeepLinkToken = await this.ltiDeepLinkTokenRepo.save( + new LtiDeepLinkToken({ + id: new ObjectId().toHexString(), + userId, + state: new UUID().toString(), + expiresAt: new Date(Date.now() + tokenDurationMs), + }) + ); + + return ltiDeepLinkToken; + } + + public async findByState(state: string): Promise { + const ltiDeepLinkToken: LtiDeepLinkToken | null = await this.ltiDeepLinkTokenRepo.findByState(state); + + return ltiDeepLinkToken; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.spec.ts new file mode 100644 index 00000000000..e0f2348de72 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.spec.ts @@ -0,0 +1,62 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ToolConfig } from '../../tool-config'; +import { LtiDeepLinkingService } from './lti-deep-linking.service'; + +describe(LtiDeepLinkingService.name, () => { + let module: TestingModule; + let service: LtiDeepLinkingService; + + let configService: DeepMocked>; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LtiDeepLinkingService, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(LtiDeepLinkingService); + configService = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getCallbackUrl', () => { + describe('when requesting the callback url for lti 1.1 deep linking', () => { + const setup = () => { + const contextExternalToolId = new ObjectId().toHexString(); + const publicBackendUrl = 'https://test.com/api'; + + configService.get.mockReturnValueOnce(publicBackendUrl); + + return { + contextExternalToolId, + publicBackendUrl, + }; + }; + + it('should return the callback url', () => { + const { contextExternalToolId, publicBackendUrl } = setup(); + + const result = service.getCallbackUrl(contextExternalToolId); + + expect(result).toEqual( + `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalToolId}/lti11-deep-link-callback` + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.ts b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.ts new file mode 100644 index 00000000000..4404d5c6204 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/lti-deep-linking.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EntityId } from '@shared/domain/types'; +import { ToolConfig } from '../../tool-config'; + +@Injectable() +export class LtiDeepLinkingService { + constructor(private readonly configService: ConfigService) {} + + public getCallbackUrl(contextExternalToolId: EntityId): string { + const publicBackendUrl: string = this.configService.get('PUBLIC_BACKEND_URL'); + + const callbackUrl = new URL( + `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalToolId}/lti11-deep-link-callback` + ); + + return callbackUrl.toString(); + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts index e62888c22d8..5b081335e20 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts @@ -1,12 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { LtiMessageType } from '../../common/enum'; import { ExternalToolLogoService, ExternalToolService } from '../../external-tool/service'; -import { externalToolFactory, toolConfigurationStatusFactory } from '../../external-tool/testing'; +import { externalToolFactory, fileRecordRefFactory, toolConfigurationStatusFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ToolReference } from '../domain'; -import { contextExternalToolFactory } from '../testing'; +import { contextExternalToolFactory, ltiDeepLinkFactory } from '../testing'; import { ContextExternalToolService } from './context-external-tool.service'; import { ToolConfigurationStatusService } from './tool-configuration-status.service'; import { ToolReferenceService } from './tool-reference.service'; @@ -69,13 +70,24 @@ describe('ToolReferenceService', () => { const setup = () => { const userId: string = new ObjectId().toHexString(); const contextExternalToolId = new ObjectId().toHexString(); - const externalTool = externalToolFactory.buildWithId(); + const externalTool = externalToolFactory + .withLti11Config({ + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + }) + .buildWithId({ + thumbnail: fileRecordRefFactory.build(), + }); const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id, }); const contextExternalTool = contextExternalToolFactory .withSchoolExternalToolRef(schoolExternalTool.id) - .buildWithId(undefined, contextExternalToolId); + .buildWithId( + { + ltiDeepLink: ltiDeepLinkFactory.build(), + }, + contextExternalToolId + ); const logoUrl = 'logoUrl'; contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); @@ -135,6 +147,9 @@ describe('ToolReferenceService', () => { }), contextToolId: contextExternalToolId, description: externalTool.description, + thumbnailUrl: externalTool.thumbnail?.getPreviewUrl(), + isLtiDeepLinkingTool: true, + ltiDeepLink: contextExternalTool.ltiDeepLink, }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/index.ts b/apps/server/src/modules/tool/context-external-tool/testing/index.ts index fe6d6040ded..a4ee8fd3198 100644 --- a/apps/server/src/modules/tool/context-external-tool/testing/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/testing/index.ts @@ -1,3 +1,8 @@ export { contextExternalToolEntityFactory } from './context-external-tool-entity.factory'; export { contextExternalToolFactory } from './context-external-tool.factory'; export { contextExternalToolConfigurationStatusResponseFactory } from './context-external-tool-configuration-status-response.factory'; +export { ltiDeepLinkFactory } from './lti-deep-link.factory'; +export { ltiDeepLinkTokenFactory } from './lti-deep-link-token.factory'; +export { ltiDeepLinkTokenEntityFactory } from './lti-deep-link-token-entity.factory'; +export { Lti11DeepLinkParamsFactory } from './lti11-deep-link-params.factory'; +export { ltiDeepLinkEmbeddableFactory } from './lti-deep-link-embeddable.factory'; diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-embeddable.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-embeddable.factory.ts new file mode 100644 index 00000000000..cdffaa33480 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-embeddable.factory.ts @@ -0,0 +1,20 @@ +import { BaseFactory } from '@shared/testing'; +import { CustomParameterEntry } from '../../common/domain'; +import { LtiDeepLinkEmbeddable } from '../entity'; + +export const ltiDeepLinkEmbeddableFactory = BaseFactory.define( + LtiDeepLinkEmbeddable, + ({ sequence }) => { + return { + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + title: `Deep Link Content ${sequence}`, + url: 'https://lti.deep.link', + text: 'Deep link description', + parameters: [new CustomParameterEntry({ name: 'dl_param', value: 'dl_value' })], + availableFrom: new Date(), + availableUntil: new Date(), + submissionFrom: new Date(), + submissionUntil: new Date(), + }; + } +); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token-entity.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token-entity.factory.ts new file mode 100644 index 00000000000..2261d29ca68 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token-entity.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory, userFactory } from '@shared/testing'; +import { UUID } from 'bson'; +import { LtiDeepLinkTokenEntity, LtiDeepLinkTokenEntityProps } from '../entity'; + +export const ltiDeepLinkTokenEntityFactory = BaseFactory.define( + LtiDeepLinkTokenEntity, + () => { + const expiryTimestampMs = Date.now() + 1000000; + + return { + id: new ObjectId().toHexString(), + state: new UUID().toString(), + user: userFactory.buildWithId(), + expiresAt: new Date(expiryTimestampMs), + }; + } +); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token.factory.ts new file mode 100644 index 00000000000..8df64cfc115 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link-token.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { UUID } from 'bson'; +import { LtiDeepLinkToken, LtiDeepLinkTokenProps } from '../domain'; + +export const ltiDeepLinkTokenFactory = BaseFactory.define( + LtiDeepLinkToken, + () => { + const expiryTimestampMs = Date.now() + 1000000; + + return { + id: new ObjectId().toHexString(), + state: new UUID().toString(), + userId: new ObjectId().toHexString(), + expiresAt: new Date(expiryTimestampMs), + }; + } +); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link.factory.ts new file mode 100644 index 00000000000..ddaa5216702 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti-deep-link.factory.ts @@ -0,0 +1,17 @@ +import { BaseFactory } from '@shared/testing'; +import { CustomParameterEntry } from '../../common/domain'; +import { LtiDeepLink } from '../domain'; + +export const ltiDeepLinkFactory = BaseFactory.define(LtiDeepLink, ({ sequence }) => { + return { + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + title: `Deep Link Content ${sequence}`, + url: 'https://lti.deep.link', + text: 'Deep link description', + parameters: [new CustomParameterEntry({ name: 'dl_param', value: 'dl_value' })], + availableFrom: new Date(), + availableUntil: new Date(), + submissionFrom: new Date(), + submissionUntil: new Date(), + }; +}); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/lti11-deep-link-params.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/lti11-deep-link-params.factory.ts new file mode 100644 index 00000000000..21b697a96e0 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/lti11-deep-link-params.factory.ts @@ -0,0 +1,88 @@ +import { UUID } from 'bson'; +import CryptoJS from 'crypto-js'; +import { DeepPartial, Factory } from 'fishery'; +import OAuth, { Authorization, RequestOptions } from 'oauth-1.0a'; +import { Lti11ContentItemType, Lti11DeepLinkParams } from '../controller/dto'; +import { Lti11DeepLinkParamsRaw } from '../controller/dto/lti11-deep-link/lti11-deep-link-raw.params'; + +type Lti11DeepLinkParamsPayload = Omit; + +export const lti11DeepLinkParamsPayloadFactory = Factory.define(() => { + return { + lti_message_type: 'ContentItemSelection', + lti_version: 'LTI-1p0', + data: new UUID().toString(), + content_items: { + '@context': 'context', + '@graph': [ + { + '@type': Lti11ContentItemType.CONTENT_ITEM, + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + title: 'Deep Link Content', + text: 'descriptive text', + url: 'https://lti.deep.link', + available: { + startDatetime: new Date('2024-01'), + endDatetime: new Date('2024-02'), + }, + submission: { + startDatetime: new Date('2024-01'), + endDatetime: new Date('2024-02'), + }, + custom: { + dl_param: 'dl_value', + }, + }, + ], + }, + oauth_callback: 'about:blank', + }; +}); + +export class Lti11DeepLinkParamsFactory { + private readonly consumer: OAuth; + + constructor( + private readonly url: string = 'https://default.deep-link.url/callback', + private readonly key: string = 'defaultKey', + private readonly secret: string = 'defaultSecret' + ) { + this.consumer = new OAuth({ + consumer: { + key: this.key, + secret: this.secret, + }, + signature_method: 'HMAC-SHA1', + hash_function: (base_string: string, hashKey: string) => + CryptoJS.HmacSHA1(base_string, hashKey).toString(CryptoJS.enc.Base64), + }); + } + + build(params?: DeepPartial): Lti11DeepLinkParams { + const payload: Lti11DeepLinkParamsPayload = lti11DeepLinkParamsPayloadFactory.build(params); + + const requestData: RequestOptions = { + url: this.url, + method: 'POST', + data: payload, + }; + + const authorization: Authorization = this.consumer.authorize(requestData); + + return authorization as Lti11DeepLinkParams; + } + + buildRaw(params?: DeepPartial): Lti11DeepLinkParamsRaw { + const payload: Lti11DeepLinkParamsPayload = lti11DeepLinkParamsPayloadFactory.build(params); + + const requestData: RequestOptions = { + url: this.url, + method: 'POST', + data: { ...payload, content_items: JSON.stringify(payload.content_items) }, + }; + + const authorization: Authorization = this.consumer.authorize(requestData); + + return authorization as Lti11DeepLinkParamsRaw; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts index cda6453c71d..fc4933dafeb 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { ObjectId } from '@mikro-orm/mongodb'; import { Action, @@ -14,31 +15,55 @@ import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { setupEntities, userFactory } from '@shared/testing'; -import { ToolContextType } from '../../common/enum'; +import { UUID } from 'bson'; +import { LtiMessageType, ToolContextType } from '../../common/enum'; +import { Lti11EncryptionService } from '../../common/service'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ExternalToolService } from '../../external-tool'; +import { externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; -import { ContextExternalTool, ContextExternalToolProps } from '../domain'; -import { ContextExternalToolService } from '../service'; +import { + ContextExternalTool, + ContextExternalToolProps, + InvalidOauthSignatureLoggableException, + InvalidToolTypeLoggableException, + LtiDeepLinkTokenMissingLoggableException, +} from '../domain'; +import { ContextExternalToolService, LtiDeepLinkingService, LtiDeepLinkTokenService } from '../service'; import { ContextExternalToolValidationService } from '../service/context-external-tool-validation.service'; -import { contextExternalToolFactory } from '../testing'; +import { + contextExternalToolFactory, + Lti11DeepLinkParamsFactory, + ltiDeepLinkFactory, + ltiDeepLinkTokenFactory, +} from '../testing'; import { ContextExternalToolUc } from './context-external-tool.uc'; describe(ContextExternalToolUc.name, () => { let module: TestingModule; let uc: ContextExternalToolUc; + let externalToolService: DeepMocked; let schoolExternalToolService: DeepMocked; let contextExternalToolService: DeepMocked; let contextExternalToolValidationService: DeepMocked; let toolPermissionHelper: DeepMocked; let authorizationService: DeepMocked; + let ltiDeepLinkTokenService: DeepMocked; + let ltiDeepLinkingService: DeepMocked; + let lti11EncryptionService: DeepMocked; + let encryptionService: DeepMocked; beforeAll(async () => { await setupEntities(); module = await Test.createTestingModule({ providers: [ ContextExternalToolUc, + { + provide: ExternalToolService, + useValue: createMock(), + }, { provide: SchoolExternalToolService, useValue: createMock(), @@ -59,15 +84,36 @@ describe(ContextExternalToolUc.name, () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: LtiDeepLinkTokenService, + useValue: createMock(), + }, + { + provide: LtiDeepLinkingService, + useValue: createMock(), + }, + { + provide: Lti11EncryptionService, + useValue: createMock(), + }, + { + provide: DefaultEncryptionService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(ContextExternalToolUc); + externalToolService = module.get(ExternalToolService); schoolExternalToolService = module.get(SchoolExternalToolService); contextExternalToolService = module.get(ContextExternalToolService); contextExternalToolValidationService = module.get(ContextExternalToolValidationService); toolPermissionHelper = module.get(ToolPermissionHelper); authorizationService = module.get(AuthorizationService); + ltiDeepLinkTokenService = module.get(LtiDeepLinkTokenService); + ltiDeepLinkingService = module.get(LtiDeepLinkingService); + lti11EncryptionService = module.get(Lti11EncryptionService); + encryptionService = module.get(DefaultEncryptionService); }); afterAll(async () => { @@ -347,6 +393,7 @@ describe(ContextExternalToolUc.name, () => { schoolId, }); + const ltiDeepLink = ltiDeepLinkFactory.build(); const contextExternalTool = contextExternalToolFactory.buildWithId({ displayName: 'Course', schoolToolRef: { @@ -357,6 +404,7 @@ describe(ContextExternalToolUc.name, () => { id: 'contextId', type: ToolContextType.COURSE, }, + ltiDeepLink, }); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); @@ -369,11 +417,12 @@ describe(ContextExternalToolUc.name, () => { contextExternalToolId: contextExternalTool.id, user, schoolId, + ltiDeepLink, }; }; it('should call contextExternalToolService', async () => { - const { contextExternalTool, user, schoolId, contextExternalToolId } = setup(); + const { contextExternalTool, user, schoolId, contextExternalToolId, ltiDeepLink } = setup(); await uc.updateContextExternalTool(user.id, schoolId, contextExternalToolId, contextExternalTool.getProps()); @@ -381,6 +430,7 @@ describe(ContextExternalToolUc.name, () => { expect.objectContaining({ ...contextExternalTool.getProps(), id: expect.any(String), + ltiDeepLink, }) ); }); @@ -823,4 +873,221 @@ describe(ContextExternalToolUc.name, () => { }); }); }); + + describe('updateLtiDeepLink', () => { + describe('when deep linking a content', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const key = 'key'; + const secret = 'secret'; + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ userId: user.id, state }); + const externalTool = externalToolFactory + .withLti11Config({ + key, + secret, + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + }) + .build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { schoolToolId: schoolExternalTool.id, schoolId: user.school.id }, + displayName: 'oldName', + }); + const linkedContextExternalTool = new ContextExternalTool({ + ...contextExternalTool.getProps(), + ltiDeepLink, + displayName: ltiDeepLink.title, + }); + const callbackUrl = 'https://this.cloud/lti-deep-link-callback'; + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + ltiDeepLinkingService.getCallbackUrl.mockReturnValueOnce(callbackUrl); + encryptionService.decrypt.mockReturnValueOnce('decryptedSecret'); + lti11EncryptionService.verify.mockReturnValueOnce(true); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + contextExternalToolService.saveContextExternalTool.mockResolvedValueOnce(linkedContextExternalTool); + + return { + contextExternalTool, + ltiDeepLink, + payload, + user, + key, + secret, + state, + callbackUrl, + linkedContextExternalTool, + }; + }; + + it('should check the oauth signature', async () => { + const { contextExternalTool, payload, ltiDeepLink, key, state, callbackUrl } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink); + + expect(lti11EncryptionService.verify).toHaveBeenCalledWith(key, 'decryptedSecret', callbackUrl, payload); + }); + + it('should check the user permission', async () => { + const { contextExternalTool, payload, ltiDeepLink, state, user } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + user, + contextExternalTool, + AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) + ); + }); + + it('should should save the linked tool', async () => { + const { contextExternalTool, payload, ltiDeepLink, state, linkedContextExternalTool } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink); + + expect(contextExternalToolService.saveContextExternalTool).toHaveBeenCalledWith(linkedContextExternalTool); + }); + }); + + describe('when no content was linked', () => { + const setup = () => { + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ data: state }); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ state }); + const contextExternalTool = contextExternalToolFactory.build(); + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + contextExternalTool, + payload, + state, + }; + }; + + it('should do nothing', async () => { + const { contextExternalTool, payload, state } = setup(); + + await uc.updateLtiDeepLink(contextExternalTool.id, payload, state); + + expect(contextExternalToolService.saveContextExternalTool).not.toHaveBeenCalled(); + }); + }); + + describe('when deep linking a content', () => { + const setup = () => { + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const contextExternalTool = contextExternalToolFactory.build(); + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(null); + + return { + contextExternalTool, + payload, + ltiDeepLink, + state, + }; + }; + + it('should throw an error', async () => { + const { contextExternalTool, payload, ltiDeepLink, state } = setup(); + + await expect(uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink)).rejects.toThrow( + LtiDeepLinkTokenMissingLoggableException + ); + }); + }); + + describe('when the external tool is not an lti 1.1 tool', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ userId: user.id, state }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const externalTool = externalToolFactory.withBasicConfig().build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { schoolToolId: schoolExternalTool.id, schoolId: user.school.id }, + }); + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + + return { + contextExternalTool, + ltiDeepLink, + payload, + state, + }; + }; + + it('should throw an error', async () => { + const { contextExternalTool, payload, ltiDeepLink, state } = setup(); + + await expect(uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink)).rejects.toThrow( + InvalidToolTypeLoggableException + ); + }); + }); + + describe('when the oauth signature is invalid', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const state = new UUID().toString(); + const payload = new Lti11DeepLinkParamsFactory().buildRaw({ + data: state, + }); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build({ userId: user.id, state }); + const ltiDeepLink = ltiDeepLinkFactory.build(); + const externalTool = externalToolFactory + .withLti11Config({ lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST }) + .build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { schoolToolId: schoolExternalTool.id, schoolId: user.school.id }, + }); + const callbackUrl = 'https://this.cloud/lti-deep-link-callback'; + + ltiDeepLinkTokenService.findByState.mockResolvedValueOnce(ltiDeepLinkToken); + contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + ltiDeepLinkingService.getCallbackUrl.mockReturnValueOnce(callbackUrl); + encryptionService.decrypt.mockReturnValueOnce('decryptedSecret'); + lti11EncryptionService.verify.mockReturnValueOnce(false); + + return { + contextExternalTool, + ltiDeepLink, + payload, + state, + }; + }; + + it('should throw an error', async () => { + const { contextExternalTool, payload, ltiDeepLink, state } = setup(); + + await expect(uc.updateLtiDeepLink(contextExternalTool.id, payload, state, ltiDeepLink)).rejects.toThrow( + InvalidOauthSignatureLoggableException + ); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts index dbea96596d3..33c0c7a5cf7 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts @@ -1,3 +1,4 @@ +import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { AuthorizationContext, AuthorizationContextBuilder, @@ -5,16 +6,28 @@ import { ForbiddenLoggableException, } from '@modules/authorization'; import { AuthorizableReferenceType } from '@modules/authorization/domain'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ToolContextType } from '../../common/enum'; +import { Authorization } from 'oauth-1.0a'; +import { ToolConfigType, ToolContextType } from '../../common/enum'; +import { Lti11EncryptionService } from '../../common/service'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ExternalToolService } from '../../external-tool'; +import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ContextExternalTool, ContextRef } from '../domain'; -import { ContextExternalToolService } from '../service'; +import { + ContextExternalTool, + ContextRef, + InvalidOauthSignatureLoggableException, + InvalidToolTypeLoggableException, + LtiDeepLink, + LtiDeepLinkToken, + LtiDeepLinkTokenMissingLoggableException, +} from '../domain'; +import { ContextExternalToolService, LtiDeepLinkingService, LtiDeepLinkTokenService } from '../service'; import { ContextExternalToolValidationService } from '../service/context-external-tool-validation.service'; import { ContextExternalToolDto } from './dto/context-external-tool.types'; @@ -22,10 +35,15 @@ import { ContextExternalToolDto } from './dto/context-external-tool.types'; export class ContextExternalToolUc { constructor( private readonly toolPermissionHelper: ToolPermissionHelper, + private readonly externalToolService: ExternalToolService, private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, private readonly contextExternalToolValidationService: ContextExternalToolValidationService, - private readonly authorizationService: AuthorizationService + private readonly authorizationService: AuthorizationService, + private readonly ltiDeepLinkTokenService: LtiDeepLinkTokenService, + private readonly ltiDeepLinkingService: LtiDeepLinkingService, + private readonly lti11EncryptionService: Lti11EncryptionService, + @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService ) {} async createContextExternalTool( @@ -81,6 +99,7 @@ export class ContextExternalToolUc { contextExternalTool = new ContextExternalTool({ ...contextExternalToolDto, id: contextExternalTool.id, + ltiDeepLink: contextExternalTool.ltiDeepLink, }); contextExternalTool.schoolToolRef.schoolId = schoolId; @@ -143,4 +162,63 @@ export class ContextExternalToolUc { return toolsWithPermission; } + + public async updateLtiDeepLink( + contextExternalToolId: EntityId, + payload: Authorization, + state: string, + deepLink?: LtiDeepLink + ): Promise { + if (!deepLink) { + return; + } + + const ltiDeepLinkToken: LtiDeepLinkToken | null = await this.ltiDeepLinkTokenService.findByState(state); + + if (!ltiDeepLinkToken) { + throw new LtiDeepLinkTokenMissingLoggableException(state, contextExternalToolId); + } + + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findByIdOrFail( + contextExternalToolId + ); + + await this.checkOauthSignature(contextExternalTool, payload); + + const user: User = await this.authorizationService.getUserWithPermissions(ltiDeepLinkToken.userId); + const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); + await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); + + contextExternalTool.ltiDeepLink = deepLink; + if (deepLink.title) { + contextExternalTool.displayName = deepLink.title; + } + + await this.contextExternalToolService.saveContextExternalTool(contextExternalTool); + } + + private async checkOauthSignature(contextExternalTool: ContextExternalTool, payload: Authorization): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( + contextExternalTool.schoolToolRef.schoolToolId + ); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); + + if (!ExternalTool.isLti11Config(externalTool.config)) { + throw new InvalidToolTypeLoggableException(ToolConfigType.LTI11, externalTool.config.type); + } + + const url: string = this.ltiDeepLinkingService.getCallbackUrl(contextExternalTool.id); + const decryptedSecret: string = this.encryptionService.decrypt(externalTool.config.secret); + + const isValidSignature: boolean = this.lti11EncryptionService.verify( + externalTool.config.key, + decryptedSecret, + url, + payload + ); + + if (!isValidSignature) { + throw new InvalidOauthSignatureLoggableException(); + } + } } diff --git a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts index 4b6519e0bf5..912d27b756d 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts @@ -70,6 +70,7 @@ describe('ToolReferenceUc', () => { isOutdatedOnScopeContext: false, }), openInNewTab: externalTool.openNewTab, + isLtiDeepLinkingTool: false, }); const contextType: ToolContextType = ToolContextType.COURSE; @@ -159,6 +160,7 @@ describe('ToolReferenceUc', () => { isOutdatedOnScopeContext: false, }), openInNewTab: externalTool.openNewTab, + isLtiDeepLinkingTool: false, }); contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts index e6d3c1df1ba..ef9c435c155 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts @@ -1,7 +1,7 @@ import { InternalServerErrorException } from '@nestjs/common'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { CustomParameter } from '../../common/domain'; -import { ToolConfigType, ToolContextType } from '../../common/enum'; +import { LtiMessageType, ToolConfigType, ToolContextType } from '../../common/enum'; import { BasicToolConfig, ExternalToolConfig, Lti11ToolConfig, Oauth2ToolConfig } from './config'; import { ExternalToolMedium } from './external-tool-medium.do'; import { FileRecordRef } from './file-record-ref'; @@ -212,4 +212,11 @@ export class ExternalTool extends DomainObject { static isLti11Config(config: ExternalToolConfig): config is Lti11ToolConfig { return ToolConfigType.LTI11 === config.type; } + + public isLtiDeepLinkingTool(): boolean { + return ( + ExternalTool.isLti11Config(this.config) && + this.config.lti_message_type === LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST + ); + } } diff --git a/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts b/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts index 677d33bf4cf..2bf5ad6f2c6 100644 --- a/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts +++ b/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts @@ -23,7 +23,7 @@ export class ExternalToolDatasheetTemplateDataFactory extends Factory = { toolType: 'OAuth 2.0', skipConsent: 'Zustimmung überspringen: ja', - toolUrl: 'https://www.oauth2-baseUrl.com/', + toolUrl: 'https://www.oauth2-baseurl.com/', }; return this.params(params); } @@ -33,7 +33,7 @@ export class ExternalToolDatasheetTemplateDataFactory extends Factory( + BasicToolConfigEntity, + () => { + return { + type: ToolConfigType.BASIC, + baseUrl: 'https://mock.de', + }; + } +); + +export const oauth2ToolConfigEntityFactory = BaseFactory.define( + Oauth2ToolConfigEntity, + ({ sequence }) => { + return { + type: ToolConfigType.OAUTH2, + baseUrl: 'https://mock.de', + clientId: `client-${sequence}`, + skipConsent: false, + }; + } +); + +export const lti11ToolConfigEntityFactory = BaseFactory.define( + Lti11ToolConfigEntity, + () => { + return { + type: ToolConfigType.LTI11, + baseUrl: 'https://mock.de', + key: 'key', + secret: 'secret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + launch_presentation_locale: 'de-DE', + }; + } +); + export class ExternalToolEntityFactory extends BaseFactory { withName(name: string): this { const params: DeepPartial = { @@ -28,40 +65,27 @@ export class ExternalToolEntityFactory extends BaseFactory): this { const params: DeepPartial = { - config: new BasicToolConfigEntity({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - }), + config: basicToolConfigEntityFactory.build(customParam), }; + return this.params(params); } - withOauth2Config(clientId: string): this { + withOauth2Config(customParam?: DeepPartial): this { const params: DeepPartial = { - config: new Oauth2ToolConfigEntity({ - type: ToolConfigType.OAUTH2, - baseUrl: 'mockBaseUrl', - clientId, - skipConsent: false, - }), + config: oauth2ToolConfigEntityFactory.build(customParam), }; + return this.params(params); } - withLti11Config(): this { + withLti11Config(customParam?: DeepPartial): this { const params: DeepPartial = { - config: new Lti11ToolConfigEntity({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - key: 'key', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - secret: 'secret', - privacy_permission: LtiPrivacyPermission.ANONYMOUS, - launch_presentation_locale: 'de-DE', - }), + config: lti11ToolConfigEntityFactory.build(customParam), }; + return this.params(params); } @@ -114,10 +138,7 @@ export const externalToolEntityFactory = ExternalToolEntityFactory.define( description: 'This is a tool description', url: '', logoUrl: 'https://logourl.com', - config: new BasicToolConfigEntity({ - type: ToolConfigType.BASIC, - baseUrl: 'mockBaseUrl', - }), + config: basicToolConfigEntityFactory.build(), parameters: [customParameterEntityFactory.build()], isHidden: false, isDeactivated: false, diff --git a/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts b/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts index ac3005b33e7..2327eb4eee2 100644 --- a/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts +++ b/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts @@ -45,7 +45,7 @@ class Oauth2ToolConfigFactory extends DoBaseFactory { return { type: ToolConfigType.OAUTH2, - baseUrl: 'https://www.oauth2-baseUrl.com/', + baseUrl: 'https://www.oauth2-baseurl.com/', clientId: 'clientId', skipConsent: false, }; @@ -54,7 +54,7 @@ export const oauth2ToolConfigFactory = Oauth2ToolConfigFactory.define(Oauth2Tool export const lti11ToolConfigFactory = DoBaseFactory.define(Lti11ToolConfig, () => { return { type: ToolConfigType.LTI11, - baseUrl: 'https://www.lti11-baseUrl.com/', + baseUrl: 'https://www.lti11-baseurl.com/', key: 'key', secret: 'secret', privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, diff --git a/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts b/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts index 5cde8530834..4439f6ceff5 100644 --- a/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts +++ b/apps/server/src/modules/tool/external-tool/testing/file-record-ref.factory.ts @@ -2,16 +2,11 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Factory } from 'fishery'; import { FileRecordRef } from '../domain'; -export const fileRecordRefFactory = Factory.define(({ sequence }) => { - const fileName = `fileName-${sequence}`; - const fileRecordId = new ObjectId().toHexString(); - - return { - uploadUrl: 'uploadUrl', - fileName, - fileRecordId, - getPreviewUrl(): string { - return `/api/v3/file/preview/${fileRecordId}/${encodeURIComponent(fileName)}`; - }, - }; -}); +export const fileRecordRefFactory = Factory.define( + ({ sequence }) => + new FileRecordRef({ + uploadUrl: 'uploadUrl', + fileName: `fileName-${sequence}`, + fileRecordId: new ObjectId().toHexString(), + }) +); diff --git a/apps/server/src/modules/tool/external-tool/testing/index.ts b/apps/server/src/modules/tool/external-tool/testing/index.ts index 7dd621d10e0..ca4cdceb780 100644 --- a/apps/server/src/modules/tool/external-tool/testing/index.ts +++ b/apps/server/src/modules/tool/external-tool/testing/index.ts @@ -1,4 +1,10 @@ -export { externalToolEntityFactory, customParameterEntityFactory } from './external-tool-entity.factory'; +export { + externalToolEntityFactory, + customParameterEntityFactory, + basicToolConfigEntityFactory, + oauth2ToolConfigEntityFactory, + lti11ToolConfigEntityFactory, +} from './external-tool-entity.factory'; export { externalToolFactory, customParameterFactory, diff --git a/apps/server/src/modules/tool/tool-api.module.ts b/apps/server/src/modules/tool/tool-api.module.ts index 51eec1332ff..1e334abbdd3 100644 --- a/apps/server/src/modules/tool/tool-api.module.ts +++ b/apps/server/src/modules/tool/tool-api.module.ts @@ -11,7 +11,7 @@ import { LoggerModule } from '@src/core/logger'; import { LearnroomModule } from '../learnroom'; import { CommonToolModule } from './common'; import { ToolPermissionHelper } from './common/uc/tool-permission-helper'; -import { ToolContextController } from './context-external-tool/controller'; +import { ToolContextController, ToolDeepLinkController } from './context-external-tool/controller'; import { ToolReferenceController } from './context-external-tool/controller/tool-reference.controller'; import { ContextExternalToolUc, ToolReferenceUc } from './context-external-tool/uc'; import { ToolConfigurationController, ToolController } from './external-tool/controller'; @@ -45,6 +45,7 @@ import { ToolModule } from './tool.module'; ToolContextController, ToolReferenceController, ToolController, + ToolDeepLinkController, ], providers: [ LtiToolRepo, diff --git a/apps/server/src/modules/tool/tool-config.ts b/apps/server/src/modules/tool/tool-config.ts index 099a732ab0d..2b65f1b8f3e 100644 --- a/apps/server/src/modules/tool/tool-config.ts +++ b/apps/server/src/modules/tool/tool-config.ts @@ -8,4 +8,5 @@ export interface ToolConfig { FILES_STORAGE__SERVICE_BASE_URL: string; CTL_TOOLS__PREFERRED_TOOLS_LIMIT: number; FEATURE_PREFERRED_CTL_TOOLS_ENABLED: boolean; + PUBLIC_BACKEND_URL: string; } diff --git a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts index 4a695caa828..82f8efba10f 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts @@ -26,7 +26,7 @@ import { } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; -import { LaunchRequestMethod } from '../../types'; +import { LaunchRequestMethod, LaunchType } from '../../types'; import { ContextExternalToolBodyParams, ContextExternalToolLaunchParams, ToolLaunchRequestResponse } from '../dto'; describe('ToolLaunchController (API)', () => { @@ -123,7 +123,7 @@ describe('ToolLaunchController (API)', () => { method: LaunchRequestMethod.GET, url: 'https://mockurl.de/', openNewTab: true, - isDeepLink: false, + launchType: LaunchType.BASIC, }); }); }); @@ -414,7 +414,7 @@ describe('ToolLaunchController (API)', () => { method: LaunchRequestMethod.GET, url: 'https://mockurl.de/', openNewTab: true, - isDeepLink: false, + launchType: LaunchType.BASIC, }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts b/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts index a488bca921a..fb8f09300ee 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { LaunchRequestMethod } from '../../types'; +import { LaunchRequestMethod, LaunchType } from '../../types'; export class ToolLaunchRequestResponse { @ApiProperty({ @@ -30,15 +30,17 @@ export class ToolLaunchRequestResponse { openNewTab?: boolean; @ApiProperty({ - description: 'Specifies whether the request is an LTI Deep linking content item selection request', + description: 'Specifies the underlying type of the request', + enum: LaunchType, + enumName: 'LaunchType', }) - isDeepLink: boolean; + launchType: LaunchType; constructor(props: ToolLaunchRequestResponse) { this.url = props.url; this.method = props.method; this.payload = props.payload; this.openNewTab = props.openNewTab; - this.isDeepLink = props.isDeepLink; + this.launchType = props.launchType; } } diff --git a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts index 9a095fbf116..5632f65a19d 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts @@ -1,6 +1,6 @@ import { CustomParameterLocation, ToolConfigType } from '../../common/enum'; import { ToolLaunchRequestResponse } from '../controller/dto'; -import { LaunchRequestMethod, PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; +import { LaunchRequestMethod, LaunchType, PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchMapper } from './tool-launch.mapper'; describe('ToolLaunchMapper', () => { @@ -33,7 +33,7 @@ describe('ToolLaunchMapper', () => { url: 'url', openNewTab: true, payload: 'payload', - isDeepLink: false, + launchType: LaunchType.BASIC, }); const result: ToolLaunchRequestResponse = ToolLaunchMapper.mapToToolLaunchRequestResponse(toolLaunchRequest); @@ -43,7 +43,7 @@ describe('ToolLaunchMapper', () => { url: toolLaunchRequest.url, payload: toolLaunchRequest.payload, openNewTab: toolLaunchRequest.openNewTab, - isDeepLink: toolLaunchRequest.isDeepLink, + launchType: LaunchType.BASIC, }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/index.ts b/apps/server/src/modules/tool/tool-launch/service/index.ts index 9b8c27189b8..7da3dfd168e 100644 --- a/apps/server/src/modules/tool/tool-launch/service/index.ts +++ b/apps/server/src/modules/tool/tool-launch/service/index.ts @@ -1,2 +1 @@ export * from './tool-launch.service'; -export * from './lti11-encryption.service'; diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts index c2a049acef4..146e0eae91b 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts @@ -17,7 +17,7 @@ import { customParameterFactory, externalToolFactory } from '../../../external-t import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../../school-external-tool/testing'; import { MissingToolParameterValueLoggableException, ParameterTypeNotImplementedLoggableException } from '../../error'; -import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, @@ -60,6 +60,10 @@ class TestLaunchStrategy extends AbstractLaunchStrategy { public override determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod { return launchMethod; } + + determineLaunchType(): LaunchType { + return LaunchType.BASIC; + } } describe(AbstractLaunchStrategy.name, () => { @@ -345,8 +349,8 @@ describe(AbstractLaunchStrategy.name, () => { url: expectedUrl.toString(), method: strategy.determineLaunchRequestMethod(expectedProperties), openNewTab: false, - isDeepLink: false, payload: strategy.buildToolLaunchRequestPayload(expectedUrl.toString(), expectedProperties), + launchType: strategy.determineLaunchType(), }); }); }); @@ -385,8 +389,8 @@ describe(AbstractLaunchStrategy.name, () => { url: externalTool.config.baseUrl, method: strategy.determineLaunchRequestMethod([concreteConfigParameter]), openNewTab: false, - isDeepLink: false, payload: strategy.buildToolLaunchRequestPayload(externalTool.config.baseUrl, [concreteConfigParameter]), + launchType: strategy.determineLaunchType(), }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts index 7d5b413a9f7..bf4885ff247 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts @@ -8,15 +8,22 @@ import { ExternalTool } from '../../../external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { MissingToolParameterValueLoggableException, ParameterTypeNotImplementedLoggableException } from '../../error'; import { ToolLaunchMapper } from '../../mapper'; -import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchData, ToolLaunchRequest } from '../../types'; +import { + LaunchRequestMethod, + LaunchType, + PropertyData, + PropertyLocation, + ToolLaunchData, + ToolLaunchRequest, +} from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoParameterStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; import { ToolLaunchStrategy } from './tool-launch-strategy.interface'; @@ -52,7 +59,9 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { public abstract determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod; - public async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { + public abstract determineLaunchType(): LaunchType; + + protected async createLaunchData(userId: EntityId, data: ToolLaunchParams): Promise { const launchData: ToolLaunchData = this.buildToolLaunchDataFromExternalTool(data.externalTool); const launchDataProperties: PropertyData[] = await this.buildToolLaunchDataFromTools(data); @@ -64,6 +73,12 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { launchData.properties.push(...launchDataProperties); launchData.properties.push(...additionalLaunchDataProperties); + return launchData; + } + + public async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { + const launchData: ToolLaunchData = await this.createLaunchData(userId, data); + const requestMethod: LaunchRequestMethod = this.determineLaunchRequestMethod(launchData.properties); const url: string = this.buildUrl(launchData); const payload: string | null = this.buildToolLaunchRequestPayload(url, launchData.properties); @@ -73,13 +88,13 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { url, payload: payload ?? undefined, openNewTab: launchData.openNewTab, - isDeepLink: false, + launchType: this.determineLaunchType(), }); return toolLaunchRequest; } - private buildUrl(toolLaunchDataDO: ToolLaunchData): string { + protected buildUrl(toolLaunchDataDO: ToolLaunchData): string { const { baseUrl } = toolLaunchDataDO; const pathProperties: PropertyData[] = toolLaunchDataDO.properties.filter( diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts index db7bda486d0..e5c99b8389c 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts @@ -6,21 +6,21 @@ import { ExternalTool } from '../../../external-tool/domain'; import { externalToolFactory } from '../../../external-tool/testing'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../../school-external-tool/testing'; -import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData, PropertyLocation } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { BasicToolLaunchStrategy } from './basic-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; describe('BasicToolLaunchStrategy', () => { let module: TestingModule; - let basicToolLaunchStrategy: BasicToolLaunchStrategy; + let strategy: BasicToolLaunchStrategy; beforeAll(async () => { module = await Test.createTestingModule({ @@ -53,7 +53,7 @@ describe('BasicToolLaunchStrategy', () => { ], }).compile(); - basicToolLaunchStrategy = module.get(BasicToolLaunchStrategy); + strategy = module.get(BasicToolLaunchStrategy); }); afterAll(async () => { @@ -83,7 +83,7 @@ describe('BasicToolLaunchStrategy', () => { it('should return null', () => { const { properties } = setup(); - const payload: string | null = basicToolLaunchStrategy.buildToolLaunchRequestPayload('url', properties); + const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); expect(payload).toBeNull(); }); @@ -117,7 +117,7 @@ describe('BasicToolLaunchStrategy', () => { it('should build the tool launch request payload correctly', () => { const { properties } = setup(); - const payload: string | null = basicToolLaunchStrategy.buildToolLaunchRequestPayload('url', properties); + const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); expect(payload).toEqual('{"param1":"value1","param2":"value2"}'); }); @@ -142,10 +142,7 @@ describe('BasicToolLaunchStrategy', () => { it('should build the tool launch data from the basic tool config correctly', async () => { const { data } = setup(); - const result: PropertyData[] = await basicToolLaunchStrategy.buildToolLaunchDataFromConcreteConfig( - 'userId', - data - ); + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); expect(result).toEqual([]); }); @@ -174,7 +171,7 @@ describe('BasicToolLaunchStrategy', () => { it('should return GET', () => { const { properties } = setup(); - const result: LaunchRequestMethod = basicToolLaunchStrategy.determineLaunchRequestMethod(properties); + const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod(properties); expect(result).toEqual(LaunchRequestMethod.GET); }); @@ -208,10 +205,20 @@ describe('BasicToolLaunchStrategy', () => { it('should return POST', () => { const { properties } = setup(); - const result: LaunchRequestMethod = basicToolLaunchStrategy.determineLaunchRequestMethod(properties); + const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod(properties); expect(result).toEqual(LaunchRequestMethod.POST); }); }); }); + + describe('determineLaunchType', () => { + describe('whenever it is called', () => { + it('should return basic', () => { + const result = strategy.determineLaunchType(); + + expect(result).toEqual(LaunchType.BASIC); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts index 1cf107eedcb..a168e846928 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData, PropertyLocation } from '../../types'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -41,4 +41,8 @@ export class BasicToolLaunchStrategy extends AbstractLaunchStrategy { return launchRequestMethod; } + + public override determineLaunchType(): LaunchType { + return LaunchType.BASIC; + } } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts index 941b2680ec5..8a8c25831df 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts @@ -11,14 +11,24 @@ import { RoleName } from '@shared/domain/interface'; import { userDoFactory } from '@shared/testing'; import { pseudonymFactory } from '@shared/testing/factory/domainobject/pseudonym.factory'; import { Authorization } from 'oauth-1.0a'; +import { CustomParameterEntry } from '../../../common/domain'; import { LtiMessageType, LtiPrivacyPermission, LtiRole, ToolContextType } from '../../../common/enum'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; -import { contextExternalToolFactory } from '../../../context-external-tool/testing'; +import { Lti11EncryptionService } from '../../../common/service'; +import { + ContextExternalTool, + LtiMessageTypeNotImplementedLoggableException, +} from '../../../context-external-tool/domain'; +import { LtiDeepLinkingService, LtiDeepLinkTokenService } from '../../../context-external-tool/service'; +import { + contextExternalToolFactory, + ltiDeepLinkFactory, + ltiDeepLinkTokenFactory, +} from '../../../context-external-tool/testing'; import { ExternalTool } from '../../../external-tool/domain'; import { externalToolFactory } from '../../../external-tool/testing'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../../school-external-tool/testing'; -import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, @@ -27,7 +37,6 @@ import { AutoSchoolIdStrategy, AutoSchoolNumberStrategy, } from '../auto-parameter-strategy'; -import { Lti11EncryptionService } from '../lti11-encryption.service'; import { Lti11ToolLaunchStrategy } from './lti11-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -38,6 +47,8 @@ describe(Lti11ToolLaunchStrategy.name, () => { let userService: DeepMocked; let pseudonymService: DeepMocked; let lti11EncryptionService: DeepMocked; + let ltiDeepLinkTokenService: DeepMocked; + let ltiDeepLinkingService: DeepMocked; let encryptionService: DeepMocked; beforeAll(async () => { @@ -56,6 +67,14 @@ describe(Lti11ToolLaunchStrategy.name, () => { provide: Lti11EncryptionService, useValue: createMock(), }, + { + provide: LtiDeepLinkTokenService, + useValue: createMock(), + }, + { + provide: LtiDeepLinkingService, + useValue: createMock(), + }, { provide: AutoSchoolIdStrategy, useValue: createMock(), @@ -92,6 +111,8 @@ describe(Lti11ToolLaunchStrategy.name, () => { userService = module.get(UserService); pseudonymService = module.get(PseudonymService); lti11EncryptionService = module.get(Lti11EncryptionService); + ltiDeepLinkTokenService = module.get(LtiDeepLinkTokenService); + ltiDeepLinkingService = module.get(LtiDeepLinkingService); encryptionService = module.get(DefaultEncryptionService); }); @@ -104,98 +125,351 @@ describe(Lti11ToolLaunchStrategy.name, () => { }); describe('buildToolLaunchDataFromConcreteConfig', () => { - describe('when building the launch data for the encryption', () => { - const setup = () => { - const mockKey = 'mockKey'; - const mockSecret = 'mockSecret'; - const ltiMessageType = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; - const launchPresentationLocale = 'de-DE'; + describe('when lti messageType is basic lti launch request', () => { + describe('when building the launch data for the encryption', () => { + const setup = () => { + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const launchPresentationLocale = 'de-DE'; + + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: mockKey, + secret: mockSecret, + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.PUBLIC, + launch_presentation_locale: launchPresentationLocale, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }); + + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + userService.findById.mockResolvedValue(user); + + return { + data, + decrypted, + user, + mockKey, + mockSecret, + contextExternalTool, + launchPresentationLocale, + }; + }; - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: mockKey, - secret: mockSecret, - lti_message_type: ltiMessageType, - privacy_permission: LtiPrivacyPermission.PUBLIC, - launch_presentation_locale: launchPresentationLocale, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + it('should contain lti key and secret without location', async () => { + const { data, mockKey, decrypted } = setup(); - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, - }; + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ name: 'key', value: mockKey }), + new PropertyData({ name: 'secret', value: decrypted }), + ]) + ); + }); + + it('should contain mandatory lti attributes', async () => { + const { data, contextExternalTool, launchPresentationLocale } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'lti_message_type', + value: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), + new PropertyData({ + name: 'resource_link_id', + value: contextExternalTool.id, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'launch_presentation_document_target', + value: 'window', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'launch_presentation_locale', + value: launchPresentationLocale, + location: PropertyLocation.BODY, + }), + ]) + ); + }); + }); + + describe('when lti privacyPermission is public', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.PUBLIC, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const userEmail = 'user@email.com'; + const user: UserDO = userDoFactory.buildWithId( { - id: 'roleId2', - name: RoleName.USER, + email: userEmail, + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], }, - ], - }); + userId + ); - const decrypted = 'decryptedSecret'; - encryptionService.decrypt.mockReturnValue(decrypted); - userService.findById.mockResolvedValue(user); + const userDisplayName = 'Hans Peter Test'; - return { - data, - decrypted, - user, - mockKey, - mockSecret, - ltiMessageType, - contextExternalTool, - launchPresentationLocale, + userService.findById.mockResolvedValue(user); + userService.getDisplayName.mockResolvedValue(userDisplayName); + + return { + data, + userId, + userDisplayName, + userEmail, + }; }; - }; - it('should contain lti key and secret without location', async () => { - const { data, mockKey, decrypted } = setup(); + it('should contain all user related attributes', async () => { + const { data, userId, userDisplayName, userEmail } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_name_full', + value: userDisplayName, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_contact_email_primary', + value: userEmail, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), + ]) + ); + }); + }); + + describe('when lti privacyPermission is name', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.NAME, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId( + { + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + const userDisplayName = 'Hans Peter Test'; - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ name: 'key', value: mockKey }), - new PropertyData({ name: 'secret', value: decrypted }), - ]) - ); + userService.findById.mockResolvedValue(user); + userService.getDisplayName.mockResolvedValue(userDisplayName); + + return { + data, + userId, + userDisplayName, + }; + }; + + it('should contain the user name and id', async () => { + const { data, userId, userDisplayName } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_name_full', + value: userDisplayName, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), + ]) + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'lis_person_contact_email_primary' })]) + ); + }); }); - }); - describe('when lti privacyPermission is public', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.PUBLIC, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + describe('when lti privacyPermission is email', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.EMAIL, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const userEmail = 'user@email.com'; + const user: UserDO = userDoFactory.buildWithId( + { + email: userEmail, + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, + userService.findById.mockResolvedValue(user); + + return { + data, + userId, + userEmail, + }; }; - const userId: string = new ObjectId().toHexString(); - const userEmail = 'user@email.com'; - const user: UserDO = userDoFactory.buildWithId( - { - email: userEmail, + it('should contain the user email and id', async () => { + const { data, userId, userEmail } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lis_person_contact_email_primary', + value: userEmail, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), + ]) + ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'lis_person_name_full' })]) + ); + }); + }); + + describe('when lti privacyPermission is pseudonymous', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ roles: [ { id: 'roleId1', @@ -206,69 +480,67 @@ describe(Lti11ToolLaunchStrategy.name, () => { name: RoleName.USER, }, ], - }, - userId - ); + }); - const userDisplayName = 'Hans Peter Test'; + const pseudonym: Pseudonym = pseudonymFactory.build(); - userService.findById.mockResolvedValue(user); - userService.getDisplayName.mockResolvedValue(userDisplayName); + userService.findById.mockResolvedValue(user); + pseudonymService.findOrCreatePseudonym.mockResolvedValue(pseudonym); - return { - data, - userId, - userDisplayName, - userEmail, + return { + data, + pseudonym, + }; }; - }; - - it('should contain all user related attributes', async () => { - const { data, userId, userDisplayName, userEmail } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); - - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'lis_person_name_full', value: userDisplayName, location: PropertyLocation.BODY }), - new PropertyData({ - name: 'lis_person_contact_email_primary', - value: userEmail, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), - ]) - ); + it('should contain the pseudonymised user id', async () => { + const { data, pseudonym } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'user_id', + value: pseudonym.pseudonym, + location: PropertyLocation.BODY, + }), + ]) + ); + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'lis_person_name_full' }), + expect.objectContaining({ name: 'lis_person_contact_email_primary' }), + ]) + ); + }); }); - }); - - describe('when lti privacyPermission is name', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.NAME, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, - }; - - const userId: string = new ObjectId().toHexString(); - const user: UserDO = userDoFactory.buildWithId( - { + describe('when lti privacyPermission is anonymous', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ roles: [ { id: 'roleId1', @@ -279,190 +551,388 @@ describe(Lti11ToolLaunchStrategy.name, () => { name: RoleName.USER, }, ], - }, - userId - ); - - const userDisplayName = 'Hans Peter Test'; + }); - userService.findById.mockResolvedValue(user); - userService.getDisplayName.mockResolvedValue(userDisplayName); + userService.findById.mockResolvedValue(user); - return { - data, - userId, - userDisplayName, + return { + data, + }; }; - }; - it('should contain the user name and id', async () => { - const { data, userId, userDisplayName } = setup(); + it('should not contain user related information', async () => { + const { data } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'lis_person_name_full', value: userDisplayName, location: PropertyLocation.BODY }), - new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), - ]) - ); - expect(result).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'lis_person_contact_email_primary' })]) - ); + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'lis_person_name_full' }), + expect.objectContaining({ name: 'lis_person_contact_email_primary' }), + expect.objectContaining({ name: 'user_id' }), + expect.objectContaining({ name: 'roles' }), + ]) + ); + }); }); - }); - - describe('when lti privacyPermission is email', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.EMAIL, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, - }; - const userId: string = new ObjectId().toHexString(); - const userEmail = 'user@email.com'; - const user: UserDO = userDoFactory.buildWithId( - { - email: userEmail, + describe('when context external tool id is undefined', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const pseudoContextExternalTool = { + ...contextExternalToolFactory.build().getProps(), + id: undefined, + }; + + const data: ToolLaunchParams = { + contextExternalTool: pseudoContextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ roles: [ { id: 'roleId1', name: RoleName.TEACHER, }, - { - id: 'roleId2', - name: RoleName.USER, - }, ], - }, - userId - ); + }); - userService.findById.mockResolvedValue(user); + userService.findById.mockResolvedValue(user); - return { - data, - userId, - userEmail, + return { + data, + }; }; - }; - it('should contain the user email and id', async () => { - const { data, userId, userEmail } = setup(); + it('should use a random id', async () => { + const { data } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + const result = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), + expect(result).toContainEqual( new PropertyData({ - name: 'lis_person_contact_email_primary', - value: userEmail, + name: 'resource_link_id', + value: expect.any(String), location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), - ]) - ); - expect(result).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'lis_person_name_full' })])); + }) + ); + }); }); }); - describe('when lti privacyPermission is pseudonymous', () => { - const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, + describe('when lti messageType is content item selection request', () => { + describe('when no content is linked to the tool', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId(undefined, userId); + const ltiDeepLinkToken = ltiDeepLinkTokenFactory.build(); + + const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; + const callbackUrl = `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalTool.id}/lti11-deep-link-callback`; + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + ltiDeepLinkingService.getCallbackUrl.mockReturnValueOnce(callbackUrl); + ltiDeepLinkTokenService.generateToken.mockResolvedValueOnce(ltiDeepLinkToken); + + return { + data, + userId, + callbackUrl, + ltiDeepLinkToken, + }; }; - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - { - id: 'roleId2', - name: RoleName.USER, - }, - ], + it('should contain the attributes for a content item selection request', async () => { + const { data, userId, callbackUrl, ltiDeepLinkToken } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ name: 'key', value: 'mockKey' }), + new PropertyData({ name: 'secret', value: 'decryptedSecret' }), + new PropertyData({ + name: 'lti_message_type', + value: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'lti_version', + value: 'LTI-1p0', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'resource_link_id', + value: data.contextExternalTool.id as string, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'launch_presentation_document_target', + value: 'window', + location: PropertyLocation.BODY, + }), + new PropertyData({ + location: PropertyLocation.BODY, + name: 'launch_presentation_locale', + value: 'de-DE', + }), + new PropertyData({ + name: 'content_item_return_url', + value: callbackUrl, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_media_types', + value: '*/*', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_presentation_document_targets', + value: 'window', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_unsigned', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_multiple', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_copy_advice', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'auto_create', + value: 'true', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'data', + value: ltiDeepLinkToken.state, + location: PropertyLocation.BODY, + }), + ]) + ); }); + }); - const pseudonym: Pseudonym = pseudonymFactory.build(); + describe('when the linked content is an lti launch', () => { + const setup = () => { + const launchPresentationLocale = 'de-DE'; + + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + launch_presentation_locale: launchPresentationLocale, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const ltiDeepLinkParameter = new CustomParameterEntry({ name: 'dl_param', value: 'dl_value' }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltilink', + parameters: [ltiDeepLinkParameter], + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId(undefined, userId); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + contextExternalTool, + launchPresentationLocale, + ltiDeepLinkParameter, + }; + }; - userService.findById.mockResolvedValue(user); - pseudonymService.findOrCreatePseudonym.mockResolvedValue(pseudonym); + it('should contain the attributes for a basic lti launch request with the additional attributes from the deep link', async () => { + const { data, userId, contextExternalTool, launchPresentationLocale, ltiDeepLinkParameter } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ + name: 'lti_message_type', + value: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), + new PropertyData({ + name: 'resource_link_id', + value: contextExternalTool.id, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'launch_presentation_document_target', + value: 'window', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'launch_presentation_locale', + value: launchPresentationLocale, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: `custom_${ltiDeepLinkParameter.name}`, + value: ltiDeepLinkParameter.value as string, + location: PropertyLocation.BODY, + }), + ]) + ); + }); + }); - return { - data, - pseudonym, + describe('when the linked content does not require an lti launch', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/pdf', + parameters: undefined, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId(undefined, userId); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + contextExternalTool, + }; }; - }; - it('should contain the pseudonymised user id', async () => { - const { data, pseudonym } = setup(); + it('should not contain parameters', async () => { + const { data, userId } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'user_id', value: pseudonym.pseudonym, location: PropertyLocation.BODY }), - ]) - ); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'lis_person_name_full' }), - expect.objectContaining({ name: 'lis_person_contact_email_primary' }), - ]) - ); + expect(result).toEqual([]); + }); + }); + + describe('when the tool is not permanent', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const pseudoContextExternalTool = { + ...contextExternalToolFactory.build().getProps(), + id: undefined, + }; + + const data: ToolLaunchParams = { + contextExternalTool: pseudoContextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId(undefined, userId); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + }; + }; + + it('should throw an error', async () => { + const { data, userId } = setup(); + + await expect(() => strategy.buildToolLaunchDataFromConcreteConfig(userId, data)).rejects.toThrow( + new UnprocessableEntityException( + 'Cannot lauch a content selection request with a non-permanent context external tool' + ) + ); + }); }); }); - describe('when lti privacyPermission is anonymous', () => { + describe('when the lti message type is unknown', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.ANONYMOUS, + lti_message_type: 'unknown' as unknown as LtiMessageType, }) .build(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); @@ -474,38 +944,20 @@ describe(Lti11ToolLaunchStrategy.name, () => { externalTool, }; - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - { - id: 'roleId2', - name: RoleName.USER, - }, - ], - }); - - userService.findById.mockResolvedValue(user); + const userId: string = new ObjectId().toHexString(); return { data, + userId, + contextExternalTool, }; }; - it('should not contain user related information', async () => { - const { data } = setup(); - - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + it('should throw an error', async () => { + const { data, userId } = setup(); - expect(result).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'lis_person_name_full' }), - expect.objectContaining({ name: 'lis_person_contact_email_primary' }), - expect.objectContaining({ name: 'user_id' }), - expect.objectContaining({ name: 'roles' }), - ]) + await expect(() => strategy.buildToolLaunchDataFromConcreteConfig(userId, data)).rejects.toThrow( + LtiMessageTypeNotImplementedLoggableException ); }); }); @@ -539,249 +991,263 @@ describe(Lti11ToolLaunchStrategy.name, () => { ); }); }); + }); - describe('when context external tool id is undefined', () => { + describe('buildToolLaunchRequestPayload', () => { + describe('when key and secret are provided', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, - privacy_permission: LtiPrivacyPermission.ANONYMOUS, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const pseudoContextExternalTool = { - ...contextExternalToolFactory.build().getProps(), - id: undefined, - }; + const property1: PropertyData = new PropertyData({ + name: 'param1', + value: 'value1', + location: PropertyLocation.BODY, + }); - const data: ToolLaunchParams = { - contextExternalTool: pseudoContextExternalTool, - schoolExternalTool, - externalTool, - }; + const property2: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.BODY, + }); - const user: UserDO = userDoFactory.buildWithId({ - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - ], + const property3: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.PATH, }); - userService.findById.mockResolvedValue(user); + const mockKey = 'mockKey'; + const keyProperty: PropertyData = new PropertyData({ + name: 'key', + value: mockKey, + }); + + const secretProperty: PropertyData = new PropertyData({ + name: 'secret', + value: 'mockSecret', + }); + + const url = 'https://example.com/'; + + const signedPayload: Authorization = { + oauth_consumer_key: mockKey, + oauth_nonce: 'nonce', + oauth_signature: 'signature', + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: 1, + oauth_version: '1.0', + [property1.name]: property1.value, + [property2.name]: property2.value, + }; + + lti11EncryptionService.sign.mockReturnValue(signedPayload); return { - data, + properties: [property1, property2, property3, keyProperty, secretProperty], + url, + signedPayload, }; }; - it('should use a random id', async () => { - const { data } = setup(); + it('should return a OAuth1 signed payload', () => { + const { properties, signedPayload } = setup(); + + const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); - const result = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + expect(payload).toEqual(JSON.stringify(signedPayload)); + }); - expect(result).toContainEqual( - new PropertyData({ - name: 'resource_link_id', - value: expect.any(String), - location: PropertyLocation.BODY, - }) + it('should not return a payload with the signing secret', () => { + const { properties } = setup(); + + strategy.buildToolLaunchRequestPayload('url', properties); + + expect(lti11EncryptionService.sign).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ secret: expect.anything() }) ); }); }); - describe('when lti messageType is content item selection request', () => { + describe('when key or secret is missing', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory - .withLti11Config({ - key: 'mockKey', - secret: 'mockSecret', - lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, - privacy_permission: LtiPrivacyPermission.ANONYMOUS, - }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); - - const data: ToolLaunchParams = { - contextExternalTool, - schoolExternalTool, - externalTool, - }; + const property1: PropertyData = new PropertyData({ + name: 'param1', + value: 'value1', + location: PropertyLocation.BODY, + }); - const userId: string = new ObjectId().toHexString(); - const user: UserDO = userDoFactory.buildWithId( - { - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - { - id: 'roleId2', - name: RoleName.USER, - }, - ], - }, - userId - ); + const property2: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.BODY, + }); - const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; - const callbackUrl = `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalTool.id}/lti11-deep-link-callback`; + const property3: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.PATH, + }); - userService.findById.mockResolvedValue(user); - const decrypted = 'decryptedSecret'; - encryptionService.decrypt.mockReturnValue(decrypted); + const url = 'https://example.com/'; return { - data, - userId, - callbackUrl, + properties: [property1, property2, property3], + url, }; }; - it('should contain all user related attributes', async () => { - const { data, userId, callbackUrl } = setup(); + it('should throw an InternalServerErrorException', () => { + const { properties } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + const func = () => strategy.buildToolLaunchRequestPayload('url', properties); - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ name: 'key', value: 'mockKey' }), - new PropertyData({ name: 'secret', value: 'decryptedSecret' }), - new PropertyData({ - name: 'lti_message_type', - value: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, - location: PropertyLocation.BODY, - }), - new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), - new PropertyData({ - name: 'resource_link_id', - value: data.contextExternalTool.id as string, - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'launch_presentation_document_target', - value: 'window', - location: PropertyLocation.BODY, - }), - new PropertyData({ - location: PropertyLocation.BODY, - name: 'launch_presentation_locale', - value: 'de-DE', - }), - new PropertyData({ - name: 'content_item_return_url', - value: callbackUrl, - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'accept_media_types', - value: '*/*', - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'accept_presentation_document_targets', - value: 'window', - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'accept_unsigned', - value: 'false', - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'accept_multiple', - value: 'false', - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'accept_copy_advice', - value: 'false', - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'auto_create', - value: 'true', - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'data', - value: expect.any(String), - location: PropertyLocation.BODY, - }), - ]) + expect(func).toThrow( + new InternalServerErrorException( + 'Unable to build LTI 1.1 launch payload. "key" or "secret" is undefined in PropertyData' + ) ); }); }); + }); + + describe('determineLaunchRequestMethod', () => { + it('should return POST', () => { + const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod([]); + + expect(result).toEqual(LaunchRequestMethod.POST); + }); + }); - describe('when a content item selection request is made without a permanent tool', () => { + describe('createLaunchRequest', () => { + describe('when lti message type is content item selection request and no content is selected', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId({ id: userId }); + + const externalTool = externalToolFactory .withLti11Config({ key: 'mockKey', secret: 'mockSecret', lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, privacy_permission: LtiPrivacyPermission.ANONYMOUS, }) - .build(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); - const pseudoContextExternalTool = { - ...contextExternalToolFactory.build().getProps(), - id: undefined, - }; + .build({ + openNewTab: false, + }); + + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + }); const data: ToolLaunchParams = { - contextExternalTool: pseudoContextExternalTool, + contextExternalTool, schoolExternalTool, externalTool, }; - const userId: string = new ObjectId().toHexString(); - const user: UserDO = userDoFactory.buildWithId( - { - roles: [ - { - id: 'roleId1', - name: RoleName.TEACHER, - }, - { - id: 'roleId2', - name: RoleName.USER, - }, - ], - }, - userId - ); + const property1: PropertyData = new PropertyData({ + name: 'param1', + value: 'value1', + location: PropertyLocation.BODY, + }); + + const property2: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.BODY, + }); + + const signedPayload: Authorization = { + oauth_consumer_key: 'mockKey', + oauth_nonce: 'nonce', + oauth_signature: 'signature', + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: 1, + oauth_version: '1.0', + [property1.name]: property1.value, + [property2.name]: property2.value, + }; userService.findById.mockResolvedValue(user); const decrypted = 'decryptedSecret'; encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { + signedPayload, data, userId, }; }; - it('should throw an error', async () => { - const { data, userId } = setup(); + it('should create a post request with a signed payload and open in a new tab', async () => { + const { signedPayload, data, userId } = setup(); - await expect(() => strategy.buildToolLaunchDataFromConcreteConfig(userId, data)).rejects.toThrow( - new UnprocessableEntityException( - 'Cannot lauch a content selection request with a non-permanent context external tool' - ) - ); + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); + + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: true, + launchType: LaunchType.LTI11_CONTENT_ITEM_SELECTION, + }); }); }); - }); - describe('buildToolLaunchRequestPayload', () => { - describe('when key and secret are provided', () => { + describe('when there is a deep link with a url', () => { const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId({ id: userId }); + + const externalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build({ + openNewTab: false, + }); + + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltilink', + url: 'https://lti.deep.link', + }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + const property1: PropertyData = new PropertyData({ name: 'param1', value: 'value1', @@ -794,27 +1260,8 @@ describe(Lti11ToolLaunchStrategy.name, () => { location: PropertyLocation.BODY, }); - const property3: PropertyData = new PropertyData({ - name: 'param2', - value: 'value2', - location: PropertyLocation.PATH, - }); - - const mockKey = 'mockKey'; - const keyProperty: PropertyData = new PropertyData({ - name: 'key', - value: mockKey, - }); - - const secretProperty: PropertyData = new PropertyData({ - name: 'secret', - value: 'mockSecret', - }); - - const url = 'https://example.com/'; - const signedPayload: Authorization = { - oauth_consumer_key: mockKey, + oauth_consumer_key: 'mockKey', oauth_nonce: 'nonce', oauth_signature: 'signature', oauth_signature_method: 'HMAC-SHA1', @@ -824,40 +1271,142 @@ describe(Lti11ToolLaunchStrategy.name, () => { [property2.name]: property2.value, }; - lti11EncryptionService.sign.mockReturnValue(signedPayload); + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - properties: [property1, property2, property3, keyProperty, secretProperty], - url, signedPayload, + data, + userId, + ltiDeepLink, }; }; - it('should return a OAuth1 signed payload', () => { - const { properties, signedPayload } = setup(); + it('should use the deep link url', async () => { + const { signedPayload, data, userId, ltiDeepLink } = setup(); - const payload: string | null = strategy.buildToolLaunchRequestPayload('url', properties); + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(payload).toEqual(JSON.stringify(signedPayload)); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: ltiDeepLink.url as string, + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); }); + }); - it('should not return a payload with the signing secret', () => { - const { properties } = setup(); + describe('when there is a deep link resource that does not require an lti launch', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId({ id: userId }); - strategy.buildToolLaunchRequestPayload('url', properties); + const externalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build({ + openNewTab: false, + }); - expect(lti11EncryptionService.sign).not.toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({ secret: expect.anything() }) - ); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/pdf', + url: undefined, + }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + ltiDeepLink, + }; + }; + + it('should use the GET method without a payload', async () => { + const { data, userId } = setup(); + + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); + + expect(result).toEqual({ + method: LaunchRequestMethod.GET, + url: 'https://www.lti11-baseurl.com/', + payload: undefined, + openNewTab: false, + launchType: LaunchType.BASIC, + }); }); }); - describe('when key or secret is missing', () => { + describe('when there is a deep link resource of type lti assignment', () => { const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId({ id: userId }); + + const externalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build({ + openNewTab: false, + }); + + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltiassignment', + url: undefined, + }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + ltiDeepLink, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + const property1: PropertyData = new PropertyData({ name: 'param1', value: 'value1', @@ -870,44 +1419,46 @@ describe(Lti11ToolLaunchStrategy.name, () => { location: PropertyLocation.BODY, }); - const property3: PropertyData = new PropertyData({ - name: 'param2', - value: 'value2', - location: PropertyLocation.PATH, - }); + const signedPayload: Authorization = { + oauth_consumer_key: 'mockKey', + oauth_nonce: 'nonce', + oauth_signature: 'signature', + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: 1, + oauth_version: '1.0', + [property1.name]: property1.value, + [property2.name]: property2.value, + }; - const url = 'https://example.com/'; + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - properties: [property1, property2, property3], - url, + signedPayload, + data, + userId, + ltiDeepLink, }; }; - it('should throw an InternalServerErrorException', () => { - const { properties } = setup(); + it('should create a post request with a signed payload', async () => { + const { signedPayload, data, userId } = setup(); - const func = () => strategy.buildToolLaunchRequestPayload('url', properties); + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(func).toThrow( - new InternalServerErrorException( - 'Unable to build LTI 1.1 launch payload. "key" or "secret" is undefined in PropertyData' - ) - ); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); }); }); - }); - - describe('determineLaunchRequestMethod', () => { - it('should return POST', () => { - const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod([]); - - expect(result).toEqual(LaunchRequestMethod.POST); - }); - }); - describe('createLaunchRequest', () => { - describe('when lti message type is content item selection request', () => { + describe('when there is a deep link resource of type lti link', () => { const setup = () => { const userId: string = new ObjectId().toHexString(); const user: UserDO = userDoFactory.buildWithId({ id: userId }); @@ -919,9 +1470,15 @@ describe(Lti11ToolLaunchStrategy.name, () => { lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, privacy_permission: LtiPrivacyPermission.ANONYMOUS, }) - .build(); + .build({ + openNewTab: false, + }); const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const ltiDeepLink = ltiDeepLinkFactory.build({ + mediaType: 'application/vnd.ims.lti.v1.ltilink', + url: undefined, + }); const contextExternalToolId = 'contextExternalToolId'; const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ @@ -933,6 +1490,7 @@ describe(Lti11ToolLaunchStrategy.name, () => { contextRef: { type: ToolContextType.COURSE, }, + ltiDeepLink, }); const data: ToolLaunchParams = { @@ -964,35 +1522,35 @@ describe(Lti11ToolLaunchStrategy.name, () => { [property2.name]: property2.value, }; - const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - method: LaunchRequestMethod.POST, - url: 'https://www.lti11-baseurl.com/', - payload: JSON.stringify(signedPayload), - openNewTab: true, - isDeepLink: true, - }); - userService.findById.mockResolvedValue(user); const decrypted = 'decryptedSecret'; encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - toolLaunchRequest, + signedPayload, data, userId, + ltiDeepLink, }; }; - it('should create a LaunchRequest with the correct method, url and payload', async () => { - const { toolLaunchRequest, data, userId } = setup(); + it('should create a post request with a signed payload', async () => { + const { signedPayload, data, userId } = setup(); const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(result).toEqual(toolLaunchRequest); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); }); }); - describe('when lti message type is not content item selection request and no deeplink', () => { + describe('when lti message type is basic lti launch request', () => { const setup = () => { const userId: string = new ObjectId().toHexString(); const user: UserDO = userDoFactory.buildWithId({ id: userId }); @@ -1010,7 +1568,9 @@ describe(Lti11ToolLaunchStrategy.name, () => { privacy_permission: LtiPrivacyPermission.PUBLIC, launch_presentation_locale: launchPresentationLocale, }) - .build(); + .build({ + openNewTab: false, + }); const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); @@ -1055,31 +1615,40 @@ describe(Lti11ToolLaunchStrategy.name, () => { [property2.name]: property2.value, }; - const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - method: LaunchRequestMethod.POST, - url: 'https://www.lti11-baseurl.com/', - payload: JSON.stringify(signedPayload), - openNewTab: false, - isDeepLink: false, - }); - userService.findById.mockResolvedValue(user); const decrypted = 'decryptedSecret'; encryptionService.decrypt.mockReturnValue(decrypted); + lti11EncryptionService.sign.mockReturnValueOnce(signedPayload); return { - toolLaunchRequest, + signedPayload, data, userId, }; }; - it('should create a LaunchRequest with the correct method, url and payload', async () => { - const { toolLaunchRequest, data, userId } = setup(); + it('should create a post request with a signed payload', async () => { + const { signedPayload, data, userId } = setup(); const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); - expect(result).toEqual(toolLaunchRequest); + expect(result).toEqual({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: false, + launchType: LaunchType.LTI11_BASIC_LAUNCH, + }); + }); + }); + }); + + describe('determineLaunchType', () => { + describe('whenever it is called', () => { + it('should return lti basic launch', () => { + const result = strategy.determineLaunchType(); + + expect(result).toEqual(LaunchType.LTI11_BASIC_LAUNCH); }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts index 912c25980d4..567c30116d5 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts @@ -1,4 +1,3 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { ObjectId } from '@mikro-orm/mongodb'; import { PseudonymService } from '@modules/pseudonym/service'; @@ -7,16 +6,25 @@ import { Inject, Injectable, InternalServerErrorException, UnprocessableEntityEx import { Pseudonym, RoleReference, UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { UUID } from 'bson'; import { Authorization } from 'oauth-1.0a'; +import { CustomParameterEntry } from '../../../common/domain'; import { LtiMessageType, LtiPrivacyPermission, LtiRole } from '../../../common/enum'; +import { Lti11EncryptionService } from '../../../common/service'; +import { + LtiDeepLink, + LtiDeepLinkToken, + LtiMessageTypeNotImplementedLoggableException, +} from '../../../context-external-tool/domain'; +import { LtiDeepLinkingService, LtiDeepLinkTokenService } from '../../../context-external-tool/service'; import { ExternalTool, Lti11ToolConfig } from '../../../external-tool/domain'; import { LtiRoleMapper } from '../../mapper'; import { AuthenticationValues, LaunchRequestMethod, + LaunchType, PropertyData, PropertyLocation, + ToolLaunchData, ToolLaunchRequest, } from '../../types'; import { @@ -27,7 +35,6 @@ import { AutoSchoolIdStrategy, AutoSchoolNumberStrategy, } from '../auto-parameter-strategy'; -import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -37,6 +44,8 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { private readonly userService: UserService, private readonly pseudonymService: PseudonymService, private readonly lti11EncryptionService: Lti11EncryptionService, + private readonly ltiDeepLinkTokenService: LtiDeepLinkTokenService, + private readonly ltiDeepLinkingService: LtiDeepLinkingService, @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService, autoSchoolIdStrategy: AutoSchoolIdStrategy, autoSchoolNumberStrategy: AutoSchoolNumberStrategy, @@ -69,15 +78,38 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { } let properties: PropertyData[]; - if (config.lti_message_type === LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST) { - properties = await this.buildToolLaunchDataForContentItemSelectionRequest(userId, data, config); - } else { - properties = await this.buildToolLaunchDataForLtiLaunch( - userId, - data, - config, - LtiMessageType.BASIC_LTI_LAUNCH_REQUEST - ); + switch (config.lti_message_type) { + case LtiMessageType.BASIC_LTI_LAUNCH_REQUEST: { + properties = await this.buildToolLaunchDataForLtiLaunch( + userId, + data, + config, + LtiMessageType.BASIC_LTI_LAUNCH_REQUEST + ); + break; + } + case LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST: { + if (!data.contextExternalTool.ltiDeepLink) { + properties = await this.buildToolLaunchDataForContentItemSelectionRequest(userId, data, config); + } else if ( + data.contextExternalTool.ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltilink' || + data.contextExternalTool.ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltiassignment' + ) { + properties = await this.buildToolLaunchDataForLtiLaunch( + userId, + data, + config, + LtiMessageType.BASIC_LTI_LAUNCH_REQUEST + ); + + properties.push(...this.buildToolLaunchDataFromDeepLink(data.contextExternalTool.ltiDeepLink)); + } else { + properties = []; + } + break; + } + default: + throw new LtiMessageTypeNotImplementedLoggableException(config.lti_message_type); } return properties; @@ -101,17 +133,14 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST ); - const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; - const callbackUrl = new URL( - `${publicBackendUrl}/v3/tools/context-external-tools/${data.contextExternalTool.id}/lti11-deep-link-callback` - ); + const callbackUrl: string = this.ltiDeepLinkingService.getCallbackUrl(data.contextExternalTool.id); - const state = new UUID().toString(); + const ltiDeepLinkToken: LtiDeepLinkToken = await this.ltiDeepLinkTokenService.generateToken(userId); additionalProperties.push( new PropertyData({ name: 'content_item_return_url', - value: callbackUrl.toString(), + value: callbackUrl, location: PropertyLocation.BODY, }), new PropertyData({ @@ -148,7 +177,7 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { }), new PropertyData({ name: 'data', - value: state, + value: ltiDeepLinkToken.state, location: PropertyLocation.BODY, }) ); @@ -256,6 +285,24 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { return additionalProperties; } + private buildToolLaunchDataFromDeepLink(deepLink: LtiDeepLink): PropertyData[] { + const deepLinkProperties: PropertyData[] = []; + + deepLink.parameters.forEach((parameter: CustomParameterEntry): void => { + if (parameter.value) { + deepLinkProperties.push( + new PropertyData({ + name: `custom_${parameter.name}`, + value: parameter.value, + location: PropertyLocation.BODY, + }) + ); + } + }); + + return deepLinkProperties; + } + // eslint-disable-next-line @typescript-eslint/require-await public override buildToolLaunchRequestPayload(url: string, properties: PropertyData[]): string | null { const bodyProperties: PropertyData[] = properties.filter( @@ -302,17 +349,54 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { return LaunchRequestMethod.POST; } + public override determineLaunchType(): LaunchType { + return LaunchType.LTI11_BASIC_LAUNCH; + } + public override async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { - const request: ToolLaunchRequest = await super.createLaunchRequest(userId, data); + const launchData: ToolLaunchData = await this.createLaunchData(userId, data); + const { ltiDeepLink } = data.contextExternalTool; - if ( - ExternalTool.isLti11Config(data.externalTool.config) && - data.externalTool.config.lti_message_type === LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST - ) { - request.openNewTab = true; - request.isDeepLink = true; + let method: LaunchRequestMethod; + let url: string; + let payload: string | null; + let launchType: LaunchType; + let { openNewTab } = launchData; + + if (ltiDeepLink?.url) { + url = ltiDeepLink?.url; + } else { + url = this.buildUrl(launchData); + } + + const isLtiLaunch: boolean = + !ltiDeepLink || + ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltilink' || + ltiDeepLink.mediaType === 'application/vnd.ims.lti.v1.ltiassignment'; + if (isLtiLaunch) { + method = this.determineLaunchRequestMethod(launchData.properties); + payload = this.buildToolLaunchRequestPayload(url, launchData.properties); + launchType = this.determineLaunchType(); + } else { + method = LaunchRequestMethod.GET; + payload = null; + launchType = LaunchType.BASIC; } - return request; + const isContentItemSelectionRequest: boolean = data.externalTool.isLtiDeepLinkingTool() && !ltiDeepLink; + if (isContentItemSelectionRequest) { + openNewTab = true; + launchType = LaunchType.LTI11_CONTENT_ITEM_SELECTION; + } + + const toolLaunchRequest = new ToolLaunchRequest({ + method, + url, + payload: payload ?? undefined, + openNewTab, + launchType, + }); + + return toolLaunchRequest; } } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts index dcda4d88a86..ef5d05f39fc 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts @@ -6,14 +6,14 @@ import { ExternalTool } from '../../../external-tool/domain'; import { externalToolFactory } from '../../../external-tool/testing'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../../school-external-tool/testing'; -import { LaunchRequestMethod, PropertyData } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { OAuth2ToolLaunchStrategy } from './oauth2-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -101,4 +101,14 @@ describe('OAuth2ToolLaunchStrategy', () => { }); }); }); + + describe('determineLaunchType', () => { + describe('whenever it is called', () => { + it('should return oauth2', () => { + const result = strategy.determineLaunchType(); + + expect(result).toEqual(LaunchType.OAUTH2); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts index bf061ad75d5..580a40b0dbc 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { LaunchRequestMethod, PropertyData } from '../../types'; +import { LaunchRequestMethod, LaunchType, PropertyData } from '../../types'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -24,4 +24,8 @@ export class OAuth2ToolLaunchStrategy extends AbstractLaunchStrategy { public override determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod { return LaunchRequestMethod.GET; } + + public override determineLaunchType(): LaunchType { + return LaunchType.OAUTH2; + } } diff --git a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.spec.ts deleted file mode 100644 index 5f35cafe3be..00000000000 --- a/apps/server/src/modules/tool/tool-launch/service/lti11-encryption.service.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { Authorization } from 'oauth-1.0a'; -import { Lti11EncryptionService } from './lti11-encryption.service'; - -describe('Lti11EncryptionService', () => { - let module: TestingModule; - let service: Lti11EncryptionService; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [Lti11EncryptionService], - }).compile(); - - service = module.get(Lti11EncryptionService); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('sign', () => { - describe('when signing with OAuth1', () => { - const setup = () => { - const mockKey = 'mockKey'; - const mockSecret = 'mockSecret'; - const mockUrl = 'https://mockurl.com/'; - const testPayload: Record = { - param1: 'test1', - }; - - return { - mockKey, - mockSecret, - mockUrl, - testPayload, - }; - }; - - it('should sign the payload with OAuth1', () => { - const { mockKey, mockSecret, mockUrl, testPayload } = setup(); - - const result: Authorization = service.sign(mockKey, mockSecret, mockUrl, testPayload); - - expect(result).toEqual({ - oauth_consumer_key: mockKey, - oauth_nonce: expect.any(String), - oauth_signature: expect.any(String), - oauth_signature_method: 'HMAC-SHA1', - oauth_timestamp: expect.any(Number), - oauth_version: '1.0', - ...testPayload, - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index aa333f1c186..7e66754a99f 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -12,7 +12,8 @@ import { externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ToolStatusNotLaunchableLoggableException } from '../error'; -import { LaunchRequestMethod, ToolLaunchRequest } from '../types'; +import { toolLaunchRequestFactory } from '../testing'; +import { ToolLaunchRequest } from '../types'; import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, @@ -95,13 +96,7 @@ describe(ToolLaunchService.name, () => { }, }); - const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'https://example.com/tool-launch', - method: LaunchRequestMethod.GET, - payload: '{ "key": "value" }', - openNewTab: false, - isDeepLink: true, - }); + const expectedLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); @@ -157,13 +152,7 @@ describe(ToolLaunchService.name, () => { }, }); - const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'https://example.com/tool-launch', - method: LaunchRequestMethod.GET, - payload: '{ "key": "value" }', - openNewTab: false, - isDeepLink: true, - }); + const expectedLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); @@ -219,13 +208,7 @@ describe(ToolLaunchService.name, () => { }, }); - const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'https://example.com/tool-launch', - method: LaunchRequestMethod.GET, - payload: '{ "key": "value" }', - openNewTab: false, - isDeepLink: true, - }); + const expectedLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); diff --git a/apps/server/src/modules/tool/tool-launch/testing/index.ts b/apps/server/src/modules/tool/tool-launch/testing/index.ts new file mode 100644 index 00000000000..5063cff353c --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/testing/index.ts @@ -0,0 +1 @@ +export { toolLaunchRequestFactory } from './tool-launch-request.factory'; diff --git a/apps/server/src/modules/tool/tool-launch/testing/tool-launch-request.factory.ts b/apps/server/src/modules/tool/tool-launch/testing/tool-launch-request.factory.ts new file mode 100644 index 00000000000..4faed72d7b1 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/testing/tool-launch-request.factory.ts @@ -0,0 +1,13 @@ +import { Factory } from 'fishery'; +import { LaunchRequestMethod, LaunchType, ToolLaunchRequest } from '../types'; + +export const toolLaunchRequestFactory = Factory.define( + () => + new ToolLaunchRequest({ + url: 'https://example.com/tool-launch', + method: LaunchRequestMethod.GET, + payload: '{ "key": "value" }', + openNewTab: false, + launchType: LaunchType.BASIC, + }) +); diff --git a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts index d58f0ad2cca..d3d294baa55 100644 --- a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts +++ b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts @@ -10,7 +10,7 @@ import { CommonToolModule } from '../common'; import { ContextExternalToolModule } from '../context-external-tool'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; -import { Lti11EncryptionService, ToolLaunchService } from './service'; +import { ToolLaunchService } from './service'; import { AutoContextIdStrategy, AutoContextNameStrategy, @@ -37,7 +37,6 @@ import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrat ], providers: [ ToolLaunchService, - Lti11EncryptionService, BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrategy, diff --git a/apps/server/src/modules/tool/tool-launch/types/index.ts b/apps/server/src/modules/tool/tool-launch/types/index.ts index 47a1fe842cf..34e79244b31 100644 --- a/apps/server/src/modules/tool/tool-launch/types/index.ts +++ b/apps/server/src/modules/tool/tool-launch/types/index.ts @@ -5,3 +5,4 @@ export * from './tool-launch-request'; export * from './tool-launch-data-type'; export * from './launch-request-method'; export * from './authentication-values'; +export * from './launch-type.enum'; diff --git a/apps/server/src/modules/tool/tool-launch/types/launch-type.enum.ts b/apps/server/src/modules/tool/tool-launch/types/launch-type.enum.ts new file mode 100644 index 00000000000..663fceedd92 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/types/launch-type.enum.ts @@ -0,0 +1,6 @@ +export enum LaunchType { + BASIC = 'basic', + OAUTH2 = 'oauth2', + LTI11_BASIC_LAUNCH = 'lti11BasicLaunch', + LTI11_CONTENT_ITEM_SELECTION = 'lti11ContentItemSelection', +} diff --git a/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts b/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts index 9b7d34a130d..ee51eca6f8c 100644 --- a/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts +++ b/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts @@ -1,4 +1,5 @@ import { LaunchRequestMethod } from './launch-request-method'; +import { LaunchType } from './launch-type.enum'; export class ToolLaunchRequest { method: LaunchRequestMethod; @@ -9,13 +10,13 @@ export class ToolLaunchRequest { openNewTab: boolean; - isDeepLink: boolean; + launchType: LaunchType; constructor(props: ToolLaunchRequest) { this.url = props.url; this.method = props.method; this.payload = props.payload; this.openNewTab = props.openNewTab; - this.isDeepLink = props.isDeepLink; + this.launchType = props.launchType; } } diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts index a8b2e81e942..e83fb3afc64 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts @@ -15,7 +15,8 @@ import { contextExternalToolFactory } from '../../context-external-tool/testing' import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ToolLaunchService } from '../service'; -import { LaunchRequestMethod, ToolLaunchRequest } from '../types'; +import { toolLaunchRequestFactory } from '../testing'; +import { ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from './tool-launch.uc'; describe('ToolLaunchUc', () => { @@ -84,13 +85,7 @@ describe('ToolLaunchUc', () => { id: contextExternalToolId, }); - const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - url: 'baseUrl', - method: LaunchRequestMethod.GET, - payload: '', - openNewTab: true, - isDeepLink: true, - }); + const toolLaunchRequest: ToolLaunchRequest = toolLaunchRequestFactory.build(); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); @@ -165,13 +160,7 @@ describe('ToolLaunchUc', () => { parameters: [], }; - const toolLaunchRequest = new ToolLaunchRequest({ - openNewTab: true, - method: LaunchRequestMethod.GET, - payload: '', - url: 'https://mock.com/', - isDeepLink: false, - }); + const toolLaunchRequest = toolLaunchRequestFactory.build(); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); diff --git a/apps/server/src/shared/controller/transformer/index.ts b/apps/server/src/shared/controller/transformer/index.ts index 26b5d83fa86..889f33130c7 100644 --- a/apps/server/src/shared/controller/transformer/index.ts +++ b/apps/server/src/shared/controller/transformer/index.ts @@ -3,3 +3,4 @@ export * from './decode-html-entities.transformer'; export * from './single-value-to-array.transformer'; export * from './sanitize-html.transformer'; export { PolymorphicArrayTransform } from './polymorphic-array.transformer'; +export { StringToObject } from './string-to-object.transformer'; diff --git a/apps/server/src/shared/controller/transformer/string-to-object.transformer.spec.ts b/apps/server/src/shared/controller/transformer/string-to-object.transformer.spec.ts new file mode 100644 index 00000000000..5480c49955f --- /dev/null +++ b/apps/server/src/shared/controller/transformer/string-to-object.transformer.spec.ts @@ -0,0 +1,75 @@ +import { plainToClass } from 'class-transformer'; +import { StringToObject } from './index'; + +class TestObject { + string!: string; + + number!: number; + + boolean!: boolean; + + array!: Array; +} + +class Dto { + @StringToObject(TestObject) + obj!: TestObject; +} + +describe('StringToObject Decorator', () => { + describe('when transform a string to an object', () => { + const setup = () => { + const obj: TestObject = { + string: 'test', + number: 1, + boolean: true, + array: [], + }; + + const plain = { + obj: JSON.stringify(obj), + }; + + return { + obj, + plain, + }; + }; + + it('should transform a string to an object', () => { + const { obj, plain } = setup(); + + const result = plainToClass(Dto, plain); + + expect(result.obj).toEqual(obj); + }); + }); + + describe('when the object is already an object', () => { + const setup = () => { + const obj: TestObject = { + string: 'test', + number: 1, + boolean: true, + array: [], + }; + + const plain = { + obj, + }; + + return { + obj, + plain, + }; + }; + + it('should stay an object', () => { + const { obj, plain } = setup(); + + const result = plainToClass(Dto, plain); + + expect(result.obj).toEqual(obj); + }); + }); +}); diff --git a/apps/server/src/shared/controller/transformer/string-to-object.transformer.ts b/apps/server/src/shared/controller/transformer/string-to-object.transformer.ts new file mode 100644 index 00000000000..c40c5404fdf --- /dev/null +++ b/apps/server/src/shared/controller/transformer/string-to-object.transformer.ts @@ -0,0 +1,15 @@ +import { ClassConstructor, plainToClass, Transform, TransformFnParams } from 'class-transformer'; + +export function StringToObject(classType: ClassConstructor): PropertyDecorator { + return Transform((params: TransformFnParams): unknown => { + if (typeof params.value === 'string') { + const res: unknown = JSON.parse(params.value); + + const obj: unknown = plainToClass(classType, res, params.options); + + return obj; + } + + return params.value; + }); +} diff --git a/apps/server/src/shared/controller/validator/index.ts b/apps/server/src/shared/controller/validator/index.ts index 711a5e04be2..e62c3e876bb 100644 --- a/apps/server/src/shared/controller/validator/index.ts +++ b/apps/server/src/shared/controller/validator/index.ts @@ -1 +1,2 @@ export * from './privacy-protect.validator'; +export { ValidateRecord } from './validate-record.validator'; diff --git a/apps/server/src/shared/controller/validator/validate-record.validator.spec.ts b/apps/server/src/shared/controller/validator/validate-record.validator.spec.ts new file mode 100644 index 00000000000..d7ad1d283f3 --- /dev/null +++ b/apps/server/src/shared/controller/validator/validate-record.validator.spec.ts @@ -0,0 +1,96 @@ +import { plainToClass } from 'class-transformer'; +import { isString, validate } from 'class-validator'; +import { ValidateRecord } from './validate-record.validator'; + +class Dto { + @ValidateRecord(isString) + obj!: Record; +} + +describe('ValidateRecord Validator', () => { + describe('when the record has only valid values', () => { + const setup = () => { + const dto: Dto = { + obj: { + string1: 'string1', + string2: 'string2', + }, + }; + + return { + dto, + }; + }; + + it('should return no errors', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(0); + }); + }); + + describe('when the record has an invalid value', () => { + const setup = () => { + const dto = { + obj: { + string1: 'string1', + number1: 1, + }, + }; + + return { + dto, + }; + }; + + it('should return an error', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(1); + }); + }); + + describe('when the target is not an object', () => { + const setup = () => { + const dto = { + obj: 1, + }; + + return { + dto, + }; + }; + + it('should return an error', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(1); + }); + }); + + describe('when the target is null', () => { + const setup = () => { + const dto = { + obj: null, + }; + + return { + dto, + }; + }; + + it('should return an error', async () => { + const { dto } = setup(); + + const result = await validate(plainToClass(Dto, dto)); + + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/apps/server/src/shared/controller/validator/validate-record.validator.ts b/apps/server/src/shared/controller/validator/validate-record.validator.ts new file mode 100644 index 00000000000..33a02dfe101 --- /dev/null +++ b/apps/server/src/shared/controller/validator/validate-record.validator.ts @@ -0,0 +1,23 @@ +import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; + +export function ValidateRecord(validationFn: (value: unknown) => boolean, validationOptions?: ValidationOptions) { + return (object: object, propertyName: string): void => { + registerDecorator({ + name: 'ValidateRecord', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown): boolean { + if (typeof value !== 'object' || value === null) { + return false; + } + return Object.values(value).every((val: unknown): boolean => validationFn(val)); + }, + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be a record with valid values`; + }, + }, + }); + }; +} diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 537d17b30d5..45d69efea23 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -11,15 +11,15 @@ import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; import { RoomEntity } from '@modules/room/repo/entity'; -import { RoomMemberEntity } from '@src/modules/room-member/repo/entity/room-member.entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { SystemEntity } from '@modules/system/entity/system.entity'; import { TldrawDrawing } from '@modules/tldraw/entities'; -import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; +import { ContextExternalToolEntity, LtiDeepLinkTokenEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { ImportUser } from '@modules/user-import/entity'; import { MediaSourceEntity, MediaUserLicenseEntity, UserLicenseEntity } from '@modules/user-license/entity'; +import { RoomMemberEntity } from '@src/modules/room-member/repo/entity/room-member.entity'; import { ColumnBoardNode } from './column-board-node.entity'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; @@ -105,4 +105,5 @@ export const ALL_ENTITIES = [ InstanceEntity, MediaSourceEntity, OauthSessionTokenEntity, + LtiDeepLinkTokenEntity, ]; diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts index a17efc3df59..b96b2150517 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.spec.ts @@ -8,6 +8,7 @@ import { ContextExternalToolEntity, ContextExternalToolType } from '@modules/too import { contextExternalToolEntityFactory, contextExternalToolFactory, + ltiDeepLinkFactory, } from '@modules/tool/context-external-tool/testing'; import { ContextExternalToolQuery } from '@modules/tool/context-external-tool/uc/dto/context-external-tool.types'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; @@ -151,10 +152,12 @@ describe(ContextExternalToolRepo.name, () => { const result: ContextExternalTool = await repo.save(domainObject); - expect(result).toMatchObject({ - ...domainObject.getProps(), - id: expect.any(String), - }); + expect(result).toEqual( + new ContextExternalTool({ + ...domainObject.getProps(), + id: expect.any(String), + }) + ); }); }); @@ -171,6 +174,7 @@ describe(ContextExternalToolRepo.name, () => { schoolToolId: new ObjectId().toHexString(), schoolId: undefined, }, + ltiDeepLink: ltiDeepLinkFactory.build(), }); return { @@ -183,10 +187,12 @@ describe(ContextExternalToolRepo.name, () => { const result: ContextExternalTool = await repo.save(domainObject); - expect(result).toMatchObject({ - ...domainObject.getProps(), - id: expect.any(String), - }); + expect(result).toEqual( + new ContextExternalTool({ + ...domainObject.getProps(), + id: expect.any(String), + }) + ); }); }); diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts index a5bd792ca85..e6165215b28 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts @@ -1,11 +1,12 @@ import { EntityName, Primary, Utils } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { ToolContextType } from '@modules/tool/common/enum/tool-context-type.enum'; -import { ContextExternalTool, ContextRef } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalTool, ContextRef, LtiDeepLink } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolEntity, ContextExternalToolEntityProps, ContextExternalToolType, + LtiDeepLinkEmbeddable, } from '@modules/tool/context-external-tool/entity'; import { ContextExternalToolQuery } from '@modules/tool/context-external-tool/uc/dto/context-external-tool.types'; import { SchoolExternalToolRef } from '@modules/tool/school-external-tool/domain'; @@ -140,22 +141,55 @@ export class ContextExternalToolRepo { type: this.mapContextTypeToDomainObjectType(entity.contextType), }); + const ltiDeepLinkEntity: LtiDeepLinkEmbeddable | undefined = entity.ltiDeepLink; + const ltiDeepLink: LtiDeepLink | undefined = ltiDeepLinkEntity + ? new LtiDeepLink({ + mediaType: ltiDeepLinkEntity.mediaType, + url: ltiDeepLinkEntity.url, + title: ltiDeepLinkEntity.title, + text: ltiDeepLinkEntity.text, + parameters: ExternalToolRepoMapper.mapCustomParameterEntryEntitiesToDOs(ltiDeepLinkEntity.parameters), + availableFrom: ltiDeepLinkEntity.availableFrom, + availableUntil: ltiDeepLinkEntity.availableUntil, + submissionFrom: ltiDeepLinkEntity.submissionFrom, + submissionUntil: ltiDeepLinkEntity.submissionUntil, + }) + : undefined; + return new ContextExternalTool({ id: entity.id, schoolToolRef, contextRef, displayName: entity.displayName, parameters: ExternalToolRepoMapper.mapCustomParameterEntryEntitiesToDOs(entity.parameters), + ltiDeepLink, }); } private mapDomainObjectToEntityProps(entityDO: ContextExternalTool): ContextExternalToolEntityProps { + const { ltiDeepLink } = entityDO; + + const ltiDeepLinkEntity: LtiDeepLinkEmbeddable | undefined = ltiDeepLink + ? new LtiDeepLinkEmbeddable({ + mediaType: ltiDeepLink.mediaType, + url: ltiDeepLink.url, + title: ltiDeepLink.title, + text: ltiDeepLink.text, + parameters: ExternalToolRepoMapper.mapCustomParameterEntryDOsToEntities(ltiDeepLink.parameters), + availableFrom: ltiDeepLink.availableFrom, + availableUntil: ltiDeepLink.availableUntil, + submissionFrom: ltiDeepLink.submissionFrom, + submissionUntil: ltiDeepLink.submissionUntil, + }) + : undefined; + return { contextId: entityDO.contextRef.id, contextType: this.mapContextTypeToEntityType(entityDO.contextRef.type), displayName: entityDO.displayName, schoolTool: this.em.getReference(SchoolExternalToolEntity, entityDO.schoolToolRef.schoolToolId), parameters: ExternalToolRepoMapper.mapCustomParameterEntryDOsToEntities(entityDO.parameters), + ltiDeepLink: ltiDeepLinkEntity, }; } diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts index 88f57335a34..d538e13efed 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.spec.ts @@ -56,8 +56,12 @@ describe(ExternalToolRepo.name, () => { const client2Id = 'client-2'; const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.withBasicConfig().buildWithId(); - const externalOauthTool: ExternalToolEntity = externalToolEntityFactory.withOauth2Config('client-1').buildWithId(); - const externalOauthTool2: ExternalToolEntity = externalToolEntityFactory.withOauth2Config('client-2').buildWithId(); + const externalOauthTool: ExternalToolEntity = externalToolEntityFactory + .withOauth2Config({ clientId: 'client-1' }) + .buildWithId(); + const externalOauthTool2: ExternalToolEntity = externalToolEntityFactory + .withOauth2Config({ clientId: 'client-2' }) + .buildWithId(); const externalLti11Tool: ExternalToolEntity = externalToolEntityFactory.withLti11Config().buildWithId(); await em.persistAndFlush([externalToolEntity, externalOauthTool, externalOauthTool2, externalLti11Tool]); diff --git a/apps/server/src/shared/testing/date-to-string.ts b/apps/server/src/shared/testing/date-to-string.ts new file mode 100644 index 00000000000..ecdfaea67bb --- /dev/null +++ b/apps/server/src/shared/testing/date-to-string.ts @@ -0,0 +1 @@ +export type DateToString = T extends Date ? string : T extends object ? { [K in keyof T]: DateToString } : T; diff --git a/apps/server/src/shared/testing/dates-to-strings.ts b/apps/server/src/shared/testing/dates-to-strings.ts deleted file mode 100644 index 96cc18d2c34..00000000000 --- a/apps/server/src/shared/testing/dates-to-strings.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type DatesToStrings = { - [k in keyof T]: T[k] extends Date ? string : DatesToStrings; -}; diff --git a/apps/server/src/shared/testing/index.ts b/apps/server/src/shared/testing/index.ts index 5615f70b563..c891be689a6 100644 --- a/apps/server/src/shared/testing/index.ts +++ b/apps/server/src/shared/testing/index.ts @@ -5,4 +5,4 @@ export * from './cleanup-collections'; export * from './map-user-to-current-user'; export * from './test-api-client'; export * from './web-socket-ready-state-enum'; -export { DatesToStrings } from './dates-to-strings'; +export { DateToString } from './date-to-string'; diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index 9dfb704a35b..68da38a66ab 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -1126,8 +1126,8 @@ "parameters": [], "isHidden": false, "isDeactivated": false, - "openNewTab": false, - "restrictToContexts": [], + "openNewTab": true, + "restrictToContexts": ["board-element", "course"], "isPreferred": true, "iconName": "mdiMovieRoll" }, From 853e9d119b8d2c897dc22225d7dc68149e3f9e53 Mon Sep 17 00:00:00 2001 From: Max <53796487+dyedwiper@users.noreply.github.com> Date: Mon, 2 Dec 2024 16:41:10 +0100 Subject: [PATCH 06/17] BC-7994 Remove unused dependency nodemon (#5366) --- .nodemon.json | 7 ---- package-lock.json | 104 ---------------------------------------------- package.json | 1 - 3 files changed, 112 deletions(-) delete mode 100644 .nodemon.json diff --git a/.nodemon.json b/.nodemon.json deleted file mode 100644 index 0011ae62de5..00000000000 --- a/.nodemon.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "ignore": ["**/*.test.js", "**/*.test.ts", "**/*.spec.ts", ".git", "node_modules"], - "watch": ["src", "config", "public"], - "ext": "js,json,ts,yml,yaml", - "verbose": true, - "delay": "2500" -} diff --git a/package-lock.json b/package-lock.json index c0ccab39c35..f7644fe0da6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -205,7 +205,6 @@ "mocha": "^9.1.3", "mockery": "^2.0.0", "nock": "^13.0.0", - "nodemon": "^3.1.4", "nyc": "^15.0.1", "prettier": "^2.8.1", "prettier-eslint": "^12.0.0", @@ -9680,11 +9679,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/abbrev": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, "node_modules/accepts": { "version": "1.3.8", "license": "MIT", @@ -15566,11 +15560,6 @@ "node": ">= 4" } }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "dev": true, - "license": "ISC" - }, "node_modules/image-size": { "version": "1.0.2", "license": "MIT", @@ -20508,54 +20497,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nodemon": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz", - "integrity": "sha512-wjPBbFhtpJwmIeY2yP7QF+UKzPfltVGtfce1g/bB15/8vCGZj8uxD62b/b9M9/WVgme0NZudpownKN+c0plXlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/has-flag": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nodemon/node_modules/supports-color": { - "version": "5.5.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/noms": { "version": "0.0.0", "dev": true, @@ -20581,17 +20522,6 @@ "string_decoder": "~0.10.x" } }, - "node_modules/nopt": { - "version": "1.0.10", - "dev": true, - "license": "MIT", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "dev": true, @@ -22203,11 +22133,6 @@ "version": "1.8.0", "license": "MIT" }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "dev": true, - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.0", "license": "MIT", @@ -23719,19 +23644,6 @@ "version": "0.3.2", "license": "MIT" }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/sinon": { "version": "11.1.2", "dev": true, @@ -24968,17 +24880,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/touch": { - "version": "3.1.0", - "dev": true, - "license": "ISC", - "dependencies": { - "nopt": "~1.0.10" - }, - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, "node_modules/tough-cookie": { "version": "4.1.3", "license": "BSD-3-Clause", @@ -25633,11 +25534,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undefsafe": { - "version": "2.0.5", - "dev": true, - "license": "MIT" - }, "node_modules/underscore": { "version": "1.12.1", "license": "MIT" diff --git a/package.json b/package.json index 1159b4954ad..8b1e6e52817 100644 --- a/package.json +++ b/package.json @@ -323,7 +323,6 @@ "mocha": "^9.1.3", "mockery": "^2.0.0", "nock": "^13.0.0", - "nodemon": "^3.1.4", "nyc": "^15.0.1", "prettier": "^2.8.1", "prettier-eslint": "^12.0.0", From 87b036cbeedd4e626dc45f9fd1bcaa383194bd23 Mon Sep 17 00:00:00 2001 From: Cedric Evers <12080057+CeEv@users.noreply.github.com> Date: Tue, 3 Dec 2024 10:31:04 +0100 Subject: [PATCH 07/17] Move @shared/controller/swagger to src/apps/helper (#5370) --- apps/server/src/apps/admin-api-server.app.ts | 8 ++++---- apps/server/src/apps/board-collaboration.app.ts | 4 ++-- apps/server/src/apps/common-cartridge.app.ts | 14 +++++++------- apps/server/src/apps/files-storage.app.ts | 2 +- apps/server/src/apps/fwu-learning-contents.app.ts | 2 +- apps/server/src/apps/h5p-editor.app.ts | 2 +- apps/server/src/apps/helpers/index.ts | 3 +++ .../controller => apps/helpers}/swagger.spec.ts | 0 .../{shared/controller => apps/helpers}/swagger.ts | 0 apps/server/src/apps/management.app.ts | 2 +- apps/server/src/apps/server.app.ts | 10 +++++----- apps/server/src/apps/tldraw.app.ts | 6 +++--- 12 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/apps/helpers/index.ts rename apps/server/src/{shared/controller => apps/helpers}/swagger.spec.ts (100%) rename apps/server/src/{shared/controller => apps/helpers}/swagger.ts (100%) diff --git a/apps/server/src/apps/admin-api-server.app.ts b/apps/server/src/apps/admin-api-server.app.ts index eb8d6cad07f..92025e31ca8 100644 --- a/apps/server/src/apps/admin-api-server.app.ts +++ b/apps/server/src/apps/admin-api-server.app.ts @@ -2,16 +2,16 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; -import { enableOpenApiDocs } from '@shared/controller/swagger'; -import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; import { LegacyLogger, Logger } from '@src/core/logger'; -import { AdminApiServerModule } from '@src/modules/server/admin-api.server.module'; +import { AdminApiServerModule } from '@modules/server/admin-api.server.module'; import express from 'express'; import { install as sourceMapInstall } from 'source-map-support'; import { + AppStartLoggable, + enableOpenApiDocs, addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, -} from './helpers/prometheus-metrics'; +} from './helpers'; async function bootstrap() { sourceMapInstall(); diff --git a/apps/server/src/apps/board-collaboration.app.ts b/apps/server/src/apps/board-collaboration.app.ts index d9ca6ca8c10..25a8c418c99 100644 --- a/apps/server/src/apps/board-collaboration.app.ts +++ b/apps/server/src/apps/board-collaboration.app.ts @@ -10,13 +10,13 @@ import { SwaggerDocumentOptions } from '@nestjs/swagger'; import { LegacyLogger, Logger } from '@src/core/logger'; import { RedisIoAdapter } from '@infra/socketio'; import { BoardCollaborationModule } from '@modules/board/board-collaboration.app.module'; -import { enableOpenApiDocs } from '@shared/controller/swagger'; import express from 'express'; import { ExpressAdapter } from '@nestjs/platform-express'; import { + enableOpenApiDocs, addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, -} from './helpers/prometheus-metrics'; +} from './helpers'; async function bootstrap() { sourceMapInstall(); diff --git a/apps/server/src/apps/common-cartridge.app.ts b/apps/server/src/apps/common-cartridge.app.ts index 21398209f8e..48a5daf6b05 100644 --- a/apps/server/src/apps/common-cartridge.app.ts +++ b/apps/server/src/apps/common-cartridge.app.ts @@ -2,16 +2,16 @@ /* eslint-disable no-console */ import { NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; -import { enableOpenApiDocs } from '@shared/controller/swagger'; -import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; -import { - addPrometheusMetricsMiddlewaresIfEnabled, - createAndStartPrometheusMetricsAppIfEnabled, -} from '@src/apps/helpers/prometheus-metrics'; import { LegacyLogger, Logger } from '@src/core/logger'; -import { CommonCartridgeApiModule } from '@src/modules/common-cartridge/common-cartridge-api.module'; +import { CommonCartridgeApiModule } from '@modules/common-cartridge/common-cartridge-api.module'; import express from 'express'; import { install as sourceMapInstall } from 'source-map-support'; +import { + AppStartLoggable, + enableOpenApiDocs, + addPrometheusMetricsMiddlewaresIfEnabled, + createAndStartPrometheusMetricsAppIfEnabled, +} from './helpers'; async function bootstrap() { sourceMapInstall(); diff --git a/apps/server/src/apps/files-storage.app.ts b/apps/server/src/apps/files-storage.app.ts index adbaac9d90c..8a1f4e59a4d 100644 --- a/apps/server/src/apps/files-storage.app.ts +++ b/apps/server/src/apps/files-storage.app.ts @@ -12,7 +12,7 @@ import { FilesStorageApiModule } from '@modules/files-storage/files-storage-api. import { API_VERSION_PATH } from '@modules/files-storage/files-storage.const'; import { SwaggerDocumentOptions } from '@nestjs/swagger'; import { LegacyLogger } from '@src/core/logger'; -import { enableOpenApiDocs } from '@src/shared/controller/swagger'; +import { enableOpenApiDocs } from './helpers'; async function bootstrap() { sourceMapInstall(); diff --git a/apps/server/src/apps/fwu-learning-contents.app.ts b/apps/server/src/apps/fwu-learning-contents.app.ts index 15279392f12..e42c4d76df8 100644 --- a/apps/server/src/apps/fwu-learning-contents.app.ts +++ b/apps/server/src/apps/fwu-learning-contents.app.ts @@ -10,7 +10,7 @@ import { install as sourceMapInstall } from 'source-map-support'; // application imports import { LegacyLogger } from '@src/core/logger'; import { FwuLearningContentsModule } from '@modules/fwu-learning-contents'; -import { enableOpenApiDocs } from '@src/shared/controller/swagger'; +import { enableOpenApiDocs } from './helpers'; async function bootstrap() { sourceMapInstall(); diff --git a/apps/server/src/apps/h5p-editor.app.ts b/apps/server/src/apps/h5p-editor.app.ts index c25f9156be6..96972f118a6 100644 --- a/apps/server/src/apps/h5p-editor.app.ts +++ b/apps/server/src/apps/h5p-editor.app.ts @@ -10,7 +10,7 @@ import { install as sourceMapInstall } from 'source-map-support'; // application imports import { LegacyLogger } from '@src/core/logger'; import { H5PEditorModule } from '@modules/h5p-editor'; -import { enableOpenApiDocs } from '@src/shared/controller/swagger'; +import { enableOpenApiDocs } from './helpers'; async function bootstrap() { sourceMapInstall(); diff --git a/apps/server/src/apps/helpers/index.ts b/apps/server/src/apps/helpers/index.ts new file mode 100644 index 00000000000..ece61cf4004 --- /dev/null +++ b/apps/server/src/apps/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './app-start-loggable'; +export * from './prometheus-metrics'; +export * from './swagger'; diff --git a/apps/server/src/shared/controller/swagger.spec.ts b/apps/server/src/apps/helpers/swagger.spec.ts similarity index 100% rename from apps/server/src/shared/controller/swagger.spec.ts rename to apps/server/src/apps/helpers/swagger.spec.ts diff --git a/apps/server/src/shared/controller/swagger.ts b/apps/server/src/apps/helpers/swagger.ts similarity index 100% rename from apps/server/src/shared/controller/swagger.ts rename to apps/server/src/apps/helpers/swagger.ts diff --git a/apps/server/src/apps/management.app.ts b/apps/server/src/apps/management.app.ts index dd66443300a..4206485007e 100644 --- a/apps/server/src/apps/management.app.ts +++ b/apps/server/src/apps/management.app.ts @@ -10,7 +10,7 @@ import { install as sourceMapInstall } from 'source-map-support'; // application imports import { LegacyLogger } from '@src/core/logger'; import { ManagementServerModule } from '@modules/management'; -import { enableOpenApiDocs } from '@src/shared/controller/swagger'; +import { enableOpenApiDocs } from './helpers'; async function bootstrap() { sourceMapInstall(); diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index f0fa2e4779e..1046f0fdf8b 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -6,7 +6,7 @@ import { MikroORM } from '@mikro-orm/core'; import { AccountService } from '@modules/account'; import { SystemRule } from '@modules/authorization-rules'; import { ColumnBoardService } from '@modules/board'; -import { ContextExternalToolService } from '@src/modules/tool/context-external-tool'; +import { ContextExternalToolService } from '@modules/tool/context-external-tool'; import { CollaborativeStorageUc } from '@modules/collaborative-storage/uc/collaborative-storage.uc'; import { GroupService } from '@modules/group'; import { InternalServerModule } from '@modules/internal-server'; @@ -16,19 +16,19 @@ import { ServerModule } from '@modules/server'; import { TeamService } from '@modules/teams/service/team.service'; import { NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; -import { enableOpenApiDocs } from '@shared/controller/swagger'; import { LegacyLogger, Logger } from '@src/core/logger'; -import { AccountUc } from '@src/modules/account/api/account.uc'; +import { AccountUc } from '@modules/account/api/account.uc'; import express from 'express'; import { join } from 'path'; // register source-map-support for debugging import { install as sourceMapInstall } from 'source-map-support'; -import { AppStartLoggable } from './helpers/app-start-loggable'; import { + AppStartLoggable, + enableOpenApiDocs, addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, -} from './helpers/prometheus-metrics'; +} from './helpers'; import legacyAppPromise = require('../../../../src/app'); async function bootstrap() { diff --git a/apps/server/src/apps/tldraw.app.ts b/apps/server/src/apps/tldraw.app.ts index 134d4e5d94a..cab6508bbca 100644 --- a/apps/server/src/apps/tldraw.app.ts +++ b/apps/server/src/apps/tldraw.app.ts @@ -7,14 +7,14 @@ import { TldrawWsModule } from '@modules/tldraw/tldraw-ws.module'; import { LegacyLogger, Logger } from '@src/core/logger'; import * as WebSocket from 'ws'; import { WsAdapter } from '@nestjs/platform-ws'; -import { enableOpenApiDocs } from '@shared/controller/swagger'; -import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; import { ExpressAdapter } from '@nestjs/platform-express'; import express from 'express'; import { + AppStartLoggable, + enableOpenApiDocs, addPrometheusMetricsMiddlewaresIfEnabled, createAndStartPrometheusMetricsAppIfEnabled, -} from '@src/apps/helpers/prometheus-metrics'; +} from './helpers'; async function bootstrap() { sourceMapInstall(); From 7d6866a5417671fe60283a684d6e2f036bede090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:09:52 +0100 Subject: [PATCH 08/17] N21-2310 Remove entity types from migration script (#5371) --- .../src/migrations/mikro-orm/Migration20241127134513.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/server/src/migrations/mikro-orm/Migration20241127134513.ts b/apps/server/src/migrations/mikro-orm/Migration20241127134513.ts index 18369aadf4a..d6f4c100db8 100644 --- a/apps/server/src/migrations/mikro-orm/Migration20241127134513.ts +++ b/apps/server/src/migrations/mikro-orm/Migration20241127134513.ts @@ -1,17 +1,15 @@ import { Migration } from '@mikro-orm/migrations-mongodb'; import { ObjectId } from '@mikro-orm/mongodb'; -import { ContextExternalToolType } from '@modules/tool/context-external-tool/entity/context-external-tool-type.enum'; -import type { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity/context-external-tool.entity'; export class Migration20241127134513 extends Migration { async up(): Promise { let deleteCount = 0; - const cursor = this.getCollection('context-external-tools').find({ - contextType: ContextExternalToolType.COURSE, + const cursor = this.getCollection<{ contextId: ObjectId; contextType: string }>('context-external-tools').find({ + contextType: 'course', }); for await (const doc of cursor) { - const course = await this.getCollection('courses').findOne({ _id: new ObjectId(doc.contextId) }); + const course = await this.getCollection('courses').findOne({ _id: doc.contextId }); if (course === null) { await this.getCollection('context-external-tools').deleteOne({ _id: doc._id }); From 67cbf0c46ddc3f9e6faf60d60c9031a490a18417 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:43:37 +0100 Subject: [PATCH 09/17] BC-8200 - adjust ESLint rules (#5368) * add new ESLint-rules according to ticket * adapt rules for @ infra, @ apps, @ core etc. --- .eslintrc.js | 111 +++++++++++++++++- ...r.spec.ts => domain-error-handler.spec.ts} | 2 +- ...rrorHandler.ts => domain-error-handler.ts} | 0 apps/server/src/core/error/domain/index.ts | 2 +- .../decorator/jwt-auth.decorator.spec.ts | 4 +- package-lock.json | 19 ++- package.json | 5 +- 7 files changed, 130 insertions(+), 13 deletions(-) rename apps/server/src/core/error/domain/{domainErrorHandler.spec.ts => domain-error-handler.spec.ts} (98%) rename apps/server/src/core/error/domain/{domainErrorHandler.ts => domain-error-handler.ts} (100%) diff --git a/.eslintrc.js b/.eslintrc.js index a1851003d0d..3300d598dc0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,8 +49,9 @@ module.exports = { 'arrow-parens': ['error', 'always'], 'arrow-body-style': ['error', 'as-needed', { requireReturnForObjectLiteral: true }], 'no-only-tests/no-only-tests': 'error', + 'max-classes-per-file': ['warn', 1], }, - plugins: ['import', 'prettier', 'promise', 'no-only-tests'], + plugins: ['import', 'prettier', 'promise', 'no-only-tests', 'filename-rules'], env: { node: true, mocha: true, @@ -88,11 +89,27 @@ module.exports = { 'import/no-extraneous-dependencies': 'off', // better handles by ts resolver 'import/prefer-default-export': 'off', 'no-void': ['error', { allowAsStatement: true }], - 'max-classes-per-file': 'off', 'class-methods-use-this': 'off', 'no-param-reassign': 'off', 'no-underscore-dangle': 'off', + 'filename-rules/match': [1, 'kebabcase'], + 'require-await': 'warn', '@typescript-eslint/unbound-method': 'error', + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/explicit-member-accessibility': [ + 'warn', + { + accessibility: 'explicit', + overrides: { + accessors: 'no-public', + constructors: 'no-public', + methods: 'explicit', + properties: 'explicit', + parameterProperties: 'explicit', + }, + }, + ], '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/no-empty-interface': [ 'error', @@ -105,7 +122,11 @@ module.exports = { { patterns: [ { - group: ['@infra/*/*', '@modules/*/*', '!*.module'], + group: ['@src/apps/**', '@src/core/**', '@src/modules/*/*', '@src/shared/**'], + message: 'Remove src/ from import path', + }, + { + group: ['@infra/*/*', '@modules/*/*', '!@modules/*/testing', '!*.module'], message: 'Do not deep import from a module', }, ], @@ -122,7 +143,91 @@ module.exports = { rules: { // you should turn the original rule off *only* for test files '@typescript-eslint/unbound-method': 'off', + 'jest/prefer-spy-on': 'warn', 'jest/unbound-method': 'error', + '@typescript-eslint/explicit-function-return-type': 'off', + 'max-classes-per-file': 'off', + '@typescript-eslint/explicit-member-accessibility': 'off', + }, + }, + { + files: ['apps/server/src/apps/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['@apps/**', '@infra/**', '@shared/**'], + message: 'apps-modules may NOT import from @apps, @infra or @shared', + }, + ], + }, + ], + }, + }, + { + files: ['apps/server/src/core/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['@apps/**', '@core/**', '@infra/**', '@modules/**'], + message: 'core-modules may NOT import from @apps, @core, @infra or @modules', + }, + ], + }, + ], + }, + }, + { + files: ['apps/server/src/infra/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['@apps/**', '@core/**', '@modules/**'], + message: 'infra-modules may NOT import from @apps, @core or @modules', + }, + ], + }, + ], + }, + }, + { + files: ['apps/server/src/modules/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['@apps/**'], + message: 'modules-modules may NOT import from @apps', + }, + ], + }, + ], + }, + }, + { + files: ['apps/server/src/shared/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['@apps/**', '@core/**', '@infra/**', '@modules/**', '@shared/**'], + message: 'shared modules may NOT import from @apps, @core, @infra, @modules or @shared', + }, + ], + }, + ], }, }, ], diff --git a/apps/server/src/core/error/domain/domainErrorHandler.spec.ts b/apps/server/src/core/error/domain/domain-error-handler.spec.ts similarity index 98% rename from apps/server/src/core/error/domain/domainErrorHandler.spec.ts rename to apps/server/src/core/error/domain/domain-error-handler.spec.ts index d253593efb5..4edf8b5d167 100644 --- a/apps/server/src/core/error/domain/domainErrorHandler.spec.ts +++ b/apps/server/src/core/error/domain/domain-error-handler.spec.ts @@ -6,7 +6,7 @@ import { ErrorLogger, ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogM import util from 'util'; import { ErrorLoggable } from '../loggable/error.loggable'; import { ErrorUtils } from '../utils'; -import { DomainErrorHandler } from './domainErrorHandler'; +import { DomainErrorHandler } from './domain-error-handler'; class SampleLoggableException extends BadRequestException implements Loggable { constructor(private testData: string) { diff --git a/apps/server/src/core/error/domain/domainErrorHandler.ts b/apps/server/src/core/error/domain/domain-error-handler.ts similarity index 100% rename from apps/server/src/core/error/domain/domainErrorHandler.ts rename to apps/server/src/core/error/domain/domain-error-handler.ts diff --git a/apps/server/src/core/error/domain/index.ts b/apps/server/src/core/error/domain/index.ts index 8d30603d1d3..77c35aa9f66 100644 --- a/apps/server/src/core/error/domain/index.ts +++ b/apps/server/src/core/error/domain/index.ts @@ -1 +1 @@ -export { DomainErrorHandler } from './domainErrorHandler'; +export { DomainErrorHandler } from './domain-error-handler'; diff --git a/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.spec.ts b/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.spec.ts index 51098ec2964..4f39b8b52c0 100644 --- a/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.spec.ts +++ b/apps/server/src/infra/auth-guard/decorator/jwt-auth.decorator.spec.ts @@ -12,7 +12,7 @@ import { CurrentUser, JWT, JwtAuthentication } from './jwt-auth.decorator'; export class TestDecoratorCurrentUserController { @Get('test') async test(@CurrentUser() currentUser: ICurrentUser): Promise { - return Promise.resolve(); + await Promise.resolve(currentUser); } } @@ -21,7 +21,7 @@ export class TestDecoratorCurrentUserController { export class TestDecoratorJWTController { @Get('test') async test(@JWT() jwt: string): Promise { - return Promise.resolve(); + await Promise.resolve(jwt); } } diff --git a/package-lock.json b/package-lock.json index f7644fe0da6..81a0835d7ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -193,6 +193,7 @@ "eslint-config-airbnb-typescript": "^17.1.0", "eslint-config-prettier": "^8.10.0", "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-filename-rules": "^1.3.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^27.9.0", "eslint-plugin-jsx-a11y": "^6.9.0", @@ -13353,6 +13354,16 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-filename-rules": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-filename-rules/-/eslint-plugin-filename-rules-1.3.1.tgz", + "integrity": "sha512-kBMxGFvK3QrRBHMurhFSNa+PFdszezVtBV6egg39TDzlj6D4jL3Xx6oyNjm5xE4C+TdQUBzWwymHJHBPyxOreA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/eslint-plugin-import": { "version": "2.29.1", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", @@ -19407,11 +19418,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.4", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "license": "MIT", "dependencies": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" diff --git a/package.json b/package.json index 8b1e6e52817..21409c40dcb 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ ] }, "scripts": { - "lint-fix": "eslint . --fix --ignore-path .gitignore", "lint": "eslint . --ignore-path .gitignore", "test": "npm run nest:test && npm run feathers:test", "feathers:test": "cross-env NODE_ENV=test npm run setup:db:seed && npm run nest:build && npm run coverage", @@ -115,7 +114,6 @@ "nest:test:cov": "jest \"^((?!\\.load\\.spec\\.ts).)*\\.spec\\.ts$\" --coverage --force-exit --maxWorkers='50%'", "nest:test:debug": "jest --runInBand", "nest:lint": "eslint apps --ignore-path .gitignore", - "nest:lint:fix": "eslint apps --fix --ignore-path .gitignore", "syncIndexes": "node ./scripts/syncIndexes.js", "ensureIndexes": "npm run nest:start:console -- database sync-indexes", "schoolExport": "node ./scripts/schoolExport.js", @@ -311,6 +309,7 @@ "eslint-config-airbnb-typescript": "^17.1.0", "eslint-config-prettier": "^8.10.0", "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-filename-rules": "^1.3.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^27.9.0", "eslint-plugin-jsx-a11y": "^6.9.0", @@ -340,4 +339,4 @@ "tsconfig-paths": "^4.1.1", "typescript": "^5.5.4" } -} +} \ No newline at end of file From 60c07022c87349bc577e4237096b787b1d527719 Mon Sep 17 00:00:00 2001 From: Uwe Ilgenstein Date: Wed, 4 Dec 2024 17:05:44 +0100 Subject: [PATCH 10/17] BC-8348 - Implement Sharing and Copying of Room Boards (#5377) * add schoolId to room * implement draft status on board copy * rename room-member to room-membership * add schoolId to roomMembership * add api tests --- .../mikro-orm/Migration202410041210124.ts | 4 +- .../mikro-orm/Migration20241111160412.ts | 27 ++ .../mikro-orm/Migration20241112163538.ts | 31 ++ .../mikro-orm/Migration20241113100535.ts | 67 +++++ .../mikro-orm/Migration20241113152001.ts | 102 +++++++ .../mikro-orm/Migration20241127195120.ts | 15 + .../mikro-orm/Migration20241128155801.ts | 61 ++++ .../src/modules/board/board-api.module.ts | 5 +- .../src/modules/board/board-ws-api.module.ts | 12 +- apps/server/src/modules/board/board.module.ts | 4 +- .../board-context-in-rooms.api.spec.ts | 10 +- .../api-test/board-copy-in-course.api.spec.ts | 27 +- .../api-test/board-copy-in-room.api.spec.ts | 78 +++++ .../api-test/board-create-in-room.api.spec.ts | 20 +- .../api-test/board-delete-in-room.api.spec.ts | 10 +- .../api-test/board-lookup-in-room.api.spec.ts | 10 +- .../board-update-title-in-room.api.spec.ts | 10 +- .../board-visibility-in-room.api.spec.ts | 10 +- .../service/column-board.service.spec.ts | 16 +- .../board/service/column-board.service.ts | 11 +- .../internal/board-context.service.spec.ts | 34 +-- .../service/internal/board-context.service.ts | 13 +- .../internal/board-node-copy-context.spec.ts | 18 +- .../internal/board-node-copy-context.ts | 19 +- .../board-node-copy-general.service.spec.ts | 6 +- .../board-node-copy-specific.service.spec.ts | 10 +- .../column-board-copy.service.spec.ts | 155 +++------- .../internal/column-board-copy.service.ts | 58 ++-- .../internal/column-board-title.service.ts | 2 +- .../src/modules/board/uc/board.uc.spec.ts | 35 ++- apps/server/src/modules/board/uc/board.uc.ts | 51 +++- .../src/modules/board/uc/element.uc.spec.ts | 3 +- .../models/copy-api-response.ts | 168 +++++------ .../models/copy-api-response.ts | 170 +++++------ .../modules/copy-helper/dto/copy.response.ts | 6 +- .../modules/copy-helper/mapper/copy.mapper.ts | 15 +- .../files-storage-client.module.ts | 2 +- .../service/copy-files.service.ts | 3 +- .../src/modules/learnroom/learnroom.module.ts | 2 - .../service/board-copy.service.spec.ts | 124 ++++++-- .../learnroom/service/board-copy.service.ts | 21 +- .../learnroom/service/course-copy.service.ts | 7 +- .../room-member/do/room-member.do.spec.ts | 42 --- apps/server/src/modules/room-member/index.ts | 9 - .../modules/room-member/repo/entity/index.ts | 1 - .../repo/entity/room-member.entity.ts | 29 -- .../repo/room-member-domain.mapper.spec.ts | 93 ------ .../repo/room-member-domain.mapper.ts | 37 --- .../room-member/repo/room-member.repo.spec.ts | 155 ---------- .../room-member/repo/room-member.repo.ts | 64 ---- .../modules/room-member/room-member.module.ts | 15 - .../service/room-member.service.ts | 133 -------- .../src/modules/room-member/testing/index.ts | 2 - .../room-membership.rule.spec.ts} | 46 +-- .../authorization/room-membership.rule.ts} | 10 +- .../do/room-membership-authorizable.do.ts} | 4 +- .../do/room-membership.do.spec.ts | 47 +++ .../do/room-membership.do.ts} | 13 +- .../src/modules/room-membership/index.ts | 9 + .../room-membership/repo/entity/index.ts | 1 + .../repo/entity/room-membership.entity.ts | 22 ++ .../room-membership-domain.mapper.spec.ts | 96 ++++++ .../repo/room-membership-domain.mapper.ts | 37 +++ .../repo/room-membership.repo.spec.ts | 155 ++++++++++ .../repo/room-membership.repo.ts | 64 ++++ .../room-membership/room-membership.module.ts | 16 + .../service/room-membership.service.spec.ts} | 176 ++++++----- .../service/room-membership.service.ts | 144 +++++++++ .../modules/room-membership/testing/index.ts | 2 + .../room-membership-entity.factory.ts} | 8 +- .../testing/room-membership.factory.ts} | 7 +- .../dto/request/create-room.body.params.ts | 14 +- .../dto/request/update-room.body.params.ts | 8 +- .../dto/response/room-board-item.response.ts | 2 +- .../api/dto/response/room-details.response.ts | 12 +- .../api/dto/response/room-item.response.ts | 4 + .../modules/room/api/mapper/room.mapper.ts | 8 +- .../src/modules/room/api/room.controller.ts | 13 +- .../src/modules/room/api/room.uc.spec.ts | 18 +- apps/server/src/modules/room/api/room.uc.ts | 84 ++++-- .../api/test/room-add-members.api.spec.ts | 14 +- .../room/api/test/room-create.api.spec.ts | 14 +- .../room/api/test/room-delete.api.spec.ts | 23 +- .../room/api/test/room-get-boards.api.spec.ts | 16 +- .../room/api/test/room-get.api.spec.ts | 14 +- .../room/api/test/room-index.api.spec.ts | 16 +- .../room/api/test/room-members.api.spec.ts | 14 +- .../api/test/room-remove-members.api.spec.ts | 14 +- .../room/api/test/room-update.api.spec.ts | 91 ++++-- .../modules/room/domain/do/room.do.spec.ts | 2 + .../src/modules/room/domain/do/room.do.ts | 13 +- .../room/domain/service/room.service.spec.ts | 2 + .../room/domain/service/room.service.ts | 14 +- apps/server/src/modules/room/index.ts | 1 + .../modules/room/repo/entity/room.entity.ts | 5 + .../room/repo/room-domain.mapper.spec.ts | 6 + .../src/modules/room/room-api.module.ts | 4 +- .../room/testing/room-entity.factory.ts | 1 + .../src/modules/room/testing/room.factory.ts | 1 + .../src/modules/server/server.module.ts | 2 +- .../api-test/sharing-create-token.api.spec.ts | 4 +- ...haring-import-room-board-token.api.spec.ts | 108 +++++++ .../api-test/sharing-import-token.api.spec.ts | 283 ++++++++---------- .../api-test/sharing-lookup-token.api.spec.ts | 10 +- .../dto/share-token-import.body.params.ts | 4 +- .../controller/share-token.controller.ts | 2 +- .../src/modules/sharing/sharing-api.module.ts | 31 ++ .../src/modules/sharing/sharing.module.ts | 33 +- .../modules/sharing/uc/share-token.uc.spec.ts | 38 ++- .../src/modules/sharing/uc/share-token.uc.ts | 131 ++++++-- .../shared/controller/transformer/index.ts | 1 + .../null-to-undefined.transformer.spec.ts | 35 +++ .../null-to-undefined.transformer.ts | 12 + .../src/shared/domain/entity/all-entities.ts | 4 +- .../domain/interface/permission.enum.ts | 2 + .../shared/domain/interface/rolename.enum.ts | 6 +- .../src/shared/domain/types/task.types.ts | 4 +- .../shared/testing/user-role-permissions.ts | 1 + backup/setup/migrations.json | 60 +++- backup/setup/roles.json | 12 +- 120 files changed, 2494 insertions(+), 1606 deletions(-) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241111160412.ts create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241112163538.ts create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241113100535.ts create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241113152001.ts create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241127195120.ts create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241128155801.ts create mode 100644 apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts delete mode 100644 apps/server/src/modules/room-member/do/room-member.do.spec.ts delete mode 100644 apps/server/src/modules/room-member/index.ts delete mode 100644 apps/server/src/modules/room-member/repo/entity/index.ts delete mode 100644 apps/server/src/modules/room-member/repo/entity/room-member.entity.ts delete mode 100644 apps/server/src/modules/room-member/repo/room-member-domain.mapper.spec.ts delete mode 100644 apps/server/src/modules/room-member/repo/room-member-domain.mapper.ts delete mode 100644 apps/server/src/modules/room-member/repo/room-member.repo.spec.ts delete mode 100644 apps/server/src/modules/room-member/repo/room-member.repo.ts delete mode 100644 apps/server/src/modules/room-member/room-member.module.ts delete mode 100644 apps/server/src/modules/room-member/service/room-member.service.ts delete mode 100644 apps/server/src/modules/room-member/testing/index.ts rename apps/server/src/modules/{room-member/authorization/room-member.rule.spec.ts => room-membership/authorization/room-membership.rule.spec.ts} (60%) rename apps/server/src/modules/{room-member/authorization/room-member.rule.ts => room-membership/authorization/room-membership.rule.ts} (69%) rename apps/server/src/modules/{room-member/do/room-member-authorizable.do.ts => room-membership/do/room-membership-authorizable.do.ts} (79%) create mode 100644 apps/server/src/modules/room-membership/do/room-membership.do.spec.ts rename apps/server/src/modules/{room-member/do/room-member.do.ts => room-membership/do/room-membership.do.ts} (69%) create mode 100644 apps/server/src/modules/room-membership/index.ts create mode 100644 apps/server/src/modules/room-membership/repo/entity/index.ts create mode 100644 apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts create mode 100644 apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.spec.ts create mode 100644 apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.ts create mode 100644 apps/server/src/modules/room-membership/repo/room-membership.repo.spec.ts create mode 100644 apps/server/src/modules/room-membership/repo/room-membership.repo.ts create mode 100644 apps/server/src/modules/room-membership/room-membership.module.ts rename apps/server/src/modules/{room-member/service/room-member.service.spec.ts => room-membership/service/room-membership.service.spec.ts} (51%) create mode 100644 apps/server/src/modules/room-membership/service/room-membership.service.ts create mode 100644 apps/server/src/modules/room-membership/testing/index.ts rename apps/server/src/modules/{room-member/testing/room-member-entity.factory.ts => room-membership/testing/room-membership-entity.factory.ts} (50%) rename apps/server/src/modules/{room-member/testing/room-member.factory.ts => room-membership/testing/room-membership.factory.ts} (51%) create mode 100644 apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts create mode 100644 apps/server/src/modules/sharing/sharing-api.module.ts create mode 100644 apps/server/src/shared/controller/transformer/null-to-undefined.transformer.spec.ts create mode 100644 apps/server/src/shared/controller/transformer/null-to-undefined.transformer.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration202410041210124.ts b/apps/server/src/migrations/mikro-orm/Migration202410041210124.ts index c18d21eb11c..cf10456dcdb 100644 --- a/apps/server/src/migrations/mikro-orm/Migration202410041210124.ts +++ b/apps/server/src/migrations/mikro-orm/Migration202410041210124.ts @@ -19,11 +19,11 @@ export class Migration202410041210124 extends Migration { async down(): Promise { // Remove ROOM_VIEWER role - await this.getCollection('roles').deleteOne({ name: 'ROOM_VIEWER' }); + await this.getCollection('roles').deleteOne({ name: 'room_viewer' }); console.info('Rollback: Removed ROOM_VIEWER role'); // Remove ROOM_EDITOR role - await this.getCollection('roles').deleteOne({ name: 'ROOM_EDITOR' }); + await this.getCollection('roles').deleteOne({ name: 'room_editor' }); console.info('Rollback: Removed ROOM_EDITOR role'); } } diff --git a/apps/server/src/migrations/mikro-orm/Migration20241111160412.ts b/apps/server/src/migrations/mikro-orm/Migration20241111160412.ts new file mode 100644 index 00000000000..e642e195e69 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241111160412.ts @@ -0,0 +1,27 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241111160412 extends Migration { + async up(): Promise { + // Rename ROOM_VIEWER role from room_viewer to roomviewer + await this.getCollection('roles').updateMany({ name: 'room_viewer' }, { $set: { name: 'roomviewer' } }); + + console.info('Renamed ROOM_VIEWER role from room_viewer to roomviewer'); + + // Rename ROOM_EDITOR role from room_editor to roomeditor + await this.getCollection('roles').updateMany({ name: 'room_editor' }, { $set: { name: 'roomeditor' } }); + + console.info('Renamed ROOM_EDITOR role from room_editor to roomeditor'); + } + + async down(): Promise { + // Rename ROOM_VIEWER role from roomviewer to room_viewer + await this.getCollection('roles').updateMany({ name: 'roomviewer' }, { $set: { name: 'room_viewer' } }); + + console.info('Rollback: Renamed ROOM_VIEWER role from roomviewer to room_viewer'); + + // Rename ROOM_EDITOR role from roomeditor to room_editor + await this.getCollection('roles').updateMany({ name: 'roomeditor' }, { $set: { name: 'room_editor' } }); + + console.info('Rollback: Renamed ROOM_EDITOR role from roomeditor to room_editor'); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241112163538.ts b/apps/server/src/migrations/mikro-orm/Migration20241112163538.ts new file mode 100644 index 00000000000..26e50089fd7 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241112163538.ts @@ -0,0 +1,31 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241112163538 extends Migration { + async up(): Promise { + const collection = this.getCollection('room-members'); + + await collection.updateMany({ roomId: { $type: 'string' } }, [ + { + $set: { + roomId: { + $convert: { + input: '$roomId', + to: 'objectId', + onError: '$roomId', // Keep the original value if conversion fails + onNull: '$roomId', // Keep the original value if the input is null + }, + }, + }, + }, + ]); + console.info('Converted roomId from string to ObjectId'); + + await collection.updateMany({}, { $rename: { roomId: 'room' } }); + console.info('Renamed roomId to room'); + } + + async down(): Promise { + await Promise.resolve(); + console.error(`Migration down not implemented. You might need to restore database from backup!`); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts new file mode 100644 index 00000000000..d233b984596 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts @@ -0,0 +1,67 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241113100535 extends Migration { + async up(): Promise { + const teacherRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'teacher' }, + { + $addToSet: { + permissions: { + $each: ['ROOM_CREATE'], + }, + }, + } + ); + + if (teacherRoleUpdate.modifiedCount > 0) { + console.info('Permissions ROOM_CREATE added to role teacher.'); + } + + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $addToSet: { + permissions: { + $each: ['ROOM_DELETE'], + }, + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info('Permissions ROOM_DELETE added to role roomeditor.'); + } + } + + async down(): Promise { + const teacherRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'teacher' }, + { + $pull: { + permissions: { + $in: ['ROOM_CREATE'], + }, + }, + } + ); + + if (teacherRoleUpdate.modifiedCount > 0) { + console.info('Rollback: Permission ROOM_CREATE added to role teacher.'); + } + + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $pull: { + permissions: { + $in: ['ROOM_DELETE'], + }, + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info('Rollback: Permission ROOM_DELETE added to role roomeditor.'); + } + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241113152001.ts b/apps/server/src/migrations/mikro-orm/Migration20241113152001.ts new file mode 100644 index 00000000000..9914935c0a9 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241113152001.ts @@ -0,0 +1,102 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241113152001 extends Migration { + async up(): Promise { + const roomsToSchoolView = [ + { + $lookup: { + from: 'rooms', + localField: 'room', + foreignField: '_id', + as: 'roomDetails', + }, + }, + { + $unwind: '$roomDetails', + }, + { + $match: { + 'roomDetails.school': { $exists: false, $eq: null }, + }, + }, + { + $lookup: { + from: 'groups', + localField: 'userGroup', + foreignField: '_id', + as: 'groupDetails', + }, + }, + { + $unwind: '$groupDetails', + }, + { + $unwind: '$groupDetails.users', + }, + { + $lookup: { + from: 'roles', + localField: 'groupDetails.users.role', + foreignField: '_id', + as: 'roleDetails', + }, + }, + { + $unwind: '$roleDetails', + }, + { + $match: { + 'roleDetails.name': 'roomeditor', + }, + }, + { + $lookup: { + from: 'users', + localField: 'groupDetails.users.user', + foreignField: '_id', + as: 'userDetails', + }, + }, + { + $unwind: '$userDetails', + }, + { + $group: { + _id: '$userDetails.schoolId', + rooms: { $push: '$roomDetails._id' }, + }, + }, + { + $project: { + _id: 0, + school: '$_id', + rooms: 1, + }, + }, + ]; + + const mappings = await this.driver.aggregate('room-members', roomsToSchoolView); + + for await (const mapping of mappings) { + const schoolUpdate = await this.driver.nativeUpdate( + 'rooms', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + { _id: { $in: mapping.rooms } }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + { $set: { school: mapping.school } } + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call + console.info(`Updated ${schoolUpdate.affectedRows} rooms with school ${mapping.school.toHexString()}`); + } + + if (mappings.length === 0) { + console.info(`No rooms without school to update`); + } + } + + async down(): Promise { + await Promise.resolve(); + console.error(`Migration down not implemented. You might need to restore database from backup!`); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241127195120.ts b/apps/server/src/migrations/mikro-orm/Migration20241127195120.ts new file mode 100644 index 00000000000..0b74ac61e63 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241127195120.ts @@ -0,0 +1,15 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241127195120 extends Migration { + async up(): Promise { + const db = this.driver.getConnection().getDb(); + await db.renameCollection('room-members', 'room-memberships'); + console.info('Collection renamed from room-members to room-memberships'); + } + + async down(): Promise { + const db = this.driver.getConnection().getDb(); + await db.renameCollection('room-memberships', 'room-members'); + console.info('Collection renamed from room-memberships to room-members'); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241128155801.ts b/apps/server/src/migrations/mikro-orm/Migration20241128155801.ts new file mode 100644 index 00000000000..725ef54ecd3 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241128155801.ts @@ -0,0 +1,61 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241128155801 extends Migration { + async up(): Promise { + const roomMembershipToSchoolView = [ + { + $match: { + school: { $exists: false, $eq: null }, + }, + }, + { + $lookup: { + from: 'rooms', + localField: 'room', + foreignField: '_id', + as: 'roomDetails', + }, + }, + { + $unwind: '$roomDetails', + }, + { + $group: { + _id: '$roomDetails.school', + roomMemberships: { $push: '$_id' }, + }, + }, + { + $project: { + _id: 0, + school: '$_id', + roomMemberships: 1, + }, + }, + ]; + + const mappings = await this.driver.aggregate('room-memberships', roomMembershipToSchoolView); + + for await (const mapping of mappings) { + const schoolUpdate = await this.driver.nativeUpdate( + 'room-memberships', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + { _id: { $in: mapping.roomMemberships } }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + { $set: { school: mapping.school } } + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call + console.info(`Updated ${schoolUpdate.affectedRows} rooms with school ${mapping.school.toHexString()}`); + } + + if (mappings.length === 0) { + console.info(`No roomMemberships without school to update`); + } + } + + async down(): Promise { + await Promise.resolve(); + console.error(`Migration down not implemented. You might need to restore database from backup!`); + } +} diff --git a/apps/server/src/modules/board/board-api.module.ts b/apps/server/src/modules/board/board-api.module.ts index 3431851ef32..e4fa5ac9cb0 100644 --- a/apps/server/src/modules/board/board-api.module.ts +++ b/apps/server/src/modules/board/board-api.module.ts @@ -2,7 +2,7 @@ import { AuthorizationModule } from '@modules/authorization'; import { forwardRef, Module } from '@nestjs/common'; import { CourseRepo } from '@shared/repo/course'; import { LoggerModule } from '@src/core/logger'; -import { RoomMemberModule } from '../room-member'; +import { RoomMembershipModule } from '@src/modules/room-membership'; import { BoardModule } from './board.module'; import { BoardController, @@ -13,9 +13,10 @@ import { } from './controller'; import { BoardNodePermissionService } from './service'; import { BoardUc, CardUc, ColumnUc, ElementUc, SubmissionItemUc } from './uc'; +import { RoomModule } from '../room'; @Module({ - imports: [BoardModule, LoggerModule, RoomMemberModule, forwardRef(() => AuthorizationModule)], + imports: [BoardModule, LoggerModule, RoomMembershipModule, RoomModule, forwardRef(() => AuthorizationModule)], controllers: [BoardController, ColumnController, CardController, ElementController, BoardSubmissionController], providers: [BoardUc, BoardNodePermissionService, ColumnUc, CardUc, ElementUc, SubmissionItemUc, CourseRepo], }) diff --git a/apps/server/src/modules/board/board-ws-api.module.ts b/apps/server/src/modules/board/board-ws-api.module.ts index 1a39907dacd..663b1417b5d 100644 --- a/apps/server/src/modules/board/board-ws-api.module.ts +++ b/apps/server/src/modules/board/board-ws-api.module.ts @@ -3,15 +3,23 @@ import { UserModule } from '@modules/user'; import { forwardRef, Module } from '@nestjs/common'; import { CourseRepo } from '@shared/repo/course'; import { LoggerModule } from '@src/core/logger'; -import { RoomMemberModule } from '../room-member'; +import { RoomMembershipModule } from '../room-membership'; import { BoardModule } from './board.module'; import { BoardCollaborationGateway } from './gateway/board-collaboration.gateway'; import { MetricsService } from './metrics/metrics.service'; import { BoardNodePermissionService } from './service'; import { BoardUc, CardUc, ColumnUc, ElementUc } from './uc'; +import { RoomModule } from '../room'; @Module({ - imports: [BoardModule, forwardRef(() => AuthorizationModule), LoggerModule, UserModule, RoomMemberModule], + imports: [ + BoardModule, + forwardRef(() => AuthorizationModule), + LoggerModule, + UserModule, + RoomMembershipModule, + RoomModule, + ], providers: [ BoardCollaborationGateway, BoardNodePermissionService, diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 7a2142e1204..e8d703431bd 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -10,7 +10,7 @@ import { CqrsModule } from '@nestjs/cqrs'; import { CourseRepo } from '@shared/repo/course'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '../authorization'; -import { RoomMemberModule } from '../room-member'; +import { RoomMembershipModule } from '../room-membership'; import { BoardNodeRule } from './authorisation/board-node.rule'; import { BoardNodeFactory } from './domain'; import { BoardNodeRepo } from './repo'; @@ -46,7 +46,7 @@ import { CqrsModule, CollaborativeTextEditorModule, AuthorizationModule, - RoomMemberModule, + RoomMembershipModule, ], providers: [ // TODO: move BoardDoAuthorizableService, BoardDoRepo, BoardDoService, BoardNodeRepo in separate module and move mediaboard related services in mediaboard module diff --git a/apps/server/src/modules/board/controller/api-test/board-context-in-rooms.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-context-in-rooms.api.spec.ts index 1d701b7a157..15b42ea15a8 100644 --- a/apps/server/src/modules/board/controller/api-test/board-context-in-rooms.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-context-in-rooms.api.spec.ts @@ -6,7 +6,7 @@ import { TestApiClient, cleanupCollections, groupEntityFactory, roleFactory, use import { Permission, RoleName } from '@shared/domain/interface'; import { accountFactory } from '@src/modules/account/testing'; import { GroupEntityTypes } from '@src/modules/group/entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; import { roomEntityFactory } from '@src/modules/room/testing'; import { columnBoardEntityFactory } from '../../testing'; import { BoardExternalReferenceType } from '../../domain'; @@ -48,11 +48,11 @@ describe('board get context in room (api)', () => { const noAccessAccount = accountFactory.withUser(noAccessUser).build(); const roleRoomEdit = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const roleRoomView = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); @@ -66,7 +66,7 @@ describe('board get context in room (api)', () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); await em.persistAndFlush([ accountWithEditRole, @@ -79,7 +79,7 @@ describe('board get context in room (api)', () => { roleRoomView, userGroup, room, - roomMember, + roomMembership, ]); const columnBoardNode = columnBoardEntityFactory.build({ diff --git a/apps/server/src/modules/board/controller/api-test/board-copy-in-course.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-copy-in-course.api.spec.ts index 51a5cf227c1..854cf4e36e9 100644 --- a/apps/server/src/modules/board/controller/api-test/board-copy-in-course.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-copy-in-course.api.spec.ts @@ -6,7 +6,7 @@ import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, courseFac import { CopyApiResponse, CopyElementType, CopyStatusEnum } from '@modules/copy-helper'; import { BoardNodeEntity } from '../../repo'; import { columnBoardEntityFactory } from '../../testing'; -import { BoardExternalReferenceType } from '../../domain'; +import { BoardExternalReferenceType, ColumnBoardProps } from '../../domain'; const baseRouteName = '/boards'; @@ -35,13 +35,14 @@ describe(`board copy with course relation (api)`, () => { }); describe('with valid user', () => { - const setup = async () => { + const setup = async (columnBoardProps: Partial = {}) => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const course = courseFactory.build({ teachers: [teacherUser] }); await em.persistAndFlush([teacherAccount, teacherUser, course]); const columnBoardNode = columnBoardEntityFactory.build({ + ...columnBoardProps, context: { id: course.id, type: BoardExternalReferenceType.Course }, }); @@ -71,6 +72,7 @@ describe(`board copy with course relation (api)`, () => { id: expect.any(String), type: CopyElementType.COLUMNBOARD, status: CopyStatusEnum.SUCCESS, + destinationId: columnBoardNode.context?.id, }; expect(body).toEqual(expectedBody); @@ -81,6 +83,27 @@ describe(`board copy with course relation (api)`, () => { expect(result).toBeDefined(); }); + it('should set draft status on the board copy', async () => { + const { loggedInClient, columnBoardNode } = await setup({ isVisible: true }); + + const response = await loggedInClient.post(`${columnBoardNode.id}/copy`); + const body = response.body as CopyApiResponse; + + const expectedBody: CopyApiResponse = { + id: expect.any(String), + type: CopyElementType.COLUMNBOARD, + status: CopyStatusEnum.SUCCESS, + destinationId: columnBoardNode.context?.id, + }; + + expect(body).toEqual(expectedBody); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = await em.findOneOrFail(BoardNodeEntity, body.id!); + + expect(result.isVisible).toBe(false); + }); + describe('with invalid id', () => { it('should return status 400', async () => { const { loggedInClient } = await setup(); diff --git a/apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts new file mode 100644 index 00000000000..d833eb510b2 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts @@ -0,0 +1,78 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + cleanupCollections, + groupEntityFactory, + roleFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { ServerTestModule } from '@src/modules/server'; +import { BoardExternalReferenceType, ColumnBoardProps } from '../../domain'; +import { columnBoardEntityFactory } from '../../testing'; + +const baseRouteName = '/boards'; + +describe(`board copy with room relation (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + describe('with valid user', () => { + const setup = async (columnBoardProps: Partial = {}) => { + const room = roomEntityFactory.buildWithId(); + const role = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ role, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const columnBoardNode = columnBoardEntityFactory.build({ + ...columnBoardProps, + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role, columnBoardNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, columnBoardNode }; + }; + + it('should return status 201', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const response = await loggedInClient.post(`${columnBoardNode.id}/copy`); + + expect(response.status).toEqual(201); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts index 50d573674ef..bb10e3fb33c 100644 --- a/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts @@ -7,7 +7,7 @@ import { RoleName } from '@shared/domain/interface/rolename.enum'; import { cleanupCollections, groupEntityFactory, roleFactory, TestApiClient, userFactory } from '@shared/testing'; import { accountFactory } from '@src/modules/account/testing'; import { GroupEntityTypes } from '@src/modules/group/entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; import { roomEntityFactory } from '@src/modules/room/testing'; import { BoardExternalReferenceType, BoardLayout } from '../../domain'; import { BoardNodeEntity } from '../../repo'; @@ -45,7 +45,7 @@ describe(`create board in room (api)`, () => { const user = userFactory.buildWithId(); const account = accountFactory.withUser(user).build(); - const role = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + const role = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, @@ -54,9 +54,9 @@ describe(`create board in room (api)`, () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); - await em.persistAndFlush([account, user, role, userGroup, room, roomMember]); + await em.persistAndFlush([account, user, role, userGroup, room, roomMembership]); em.clear(); const loggedInClient = await testApiClient.login(account); @@ -161,7 +161,7 @@ describe(`create board in room (api)`, () => { const user = userFactory.buildWithId(); const account = accountFactory.withUser(user).build(); - const role = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_VIEW] }); + const role = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW] }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, @@ -170,9 +170,9 @@ describe(`create board in room (api)`, () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); - await em.persistAndFlush([account, user, role, userGroup, room, roomMember]); + await em.persistAndFlush([account, user, role, userGroup, room, roomMembership]); em.clear(); const loggedInClient = await testApiClient.login(account); @@ -229,7 +229,7 @@ describe(`create board in room (api)`, () => { const user = userFactory.buildWithId(); const account = accountFactory.withUser(user).build(); - const role = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + const role = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, @@ -238,9 +238,9 @@ describe(`create board in room (api)`, () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); - await em.persistAndFlush([account, user, role, userGroup, room, roomMember]); + await em.persistAndFlush([account, user, role, userGroup, room, roomMembership]); em.clear(); const loggedInClient = await testApiClient.login(account); diff --git a/apps/server/src/modules/board/controller/api-test/board-delete-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-delete-in-room.api.spec.ts index 59d819352c8..c46ae270dfd 100644 --- a/apps/server/src/modules/board/controller/api-test/board-delete-in-room.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-delete-in-room.api.spec.ts @@ -7,7 +7,7 @@ import { accountFactory } from '@src/modules/account/testing'; import { Permission, RoleName } from '@shared/domain/interface'; import { GroupEntityTypes } from '@src/modules/group/entity'; import { roomEntityFactory } from '@src/modules/room/testing'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; import { columnBoardEntityFactory, columnEntityFactory } from '../../testing'; import { BoardNodeEntity } from '../../repo'; import { BoardExternalReferenceType } from '../../domain'; @@ -48,8 +48,8 @@ describe(`board delete in room (api)`, () => { const noAccessUser = userFactory.buildWithId(); const noAccessAccount = accountFactory.withUser(noAccessUser).build(); - const roleRoomEdit = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); - const roleRoomView = roleFactory.buildWithId({ name: RoleName.ROOM_VIEWER, permissions: [Permission.ROOM_VIEW] }); + const roleRoomEdit = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); + const roleRoomView = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW] }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, @@ -61,7 +61,7 @@ describe(`board delete in room (api)`, () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); await em.persistAndFlush([ accountWithEditRole, @@ -74,7 +74,7 @@ describe(`board delete in room (api)`, () => { roleRoomView, userGroup, room, - roomMember, + roomMembership, ]); const columnBoardNode = columnBoardEntityFactory.build({ diff --git a/apps/server/src/modules/board/controller/api-test/board-lookup-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-lookup-in-room.api.spec.ts index c6701f81c94..0f1dea74740 100644 --- a/apps/server/src/modules/board/controller/api-test/board-lookup-in-room.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-lookup-in-room.api.spec.ts @@ -7,7 +7,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, TestApiClient, use import { Permission, RoleName } from '@shared/domain/interface'; import { accountFactory } from '@src/modules/account/testing'; import { GroupEntityTypes } from '@src/modules/group/entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; import { roomEntityFactory } from '@src/modules/room/testing'; import { BoardExternalReferenceType, BoardLayout } from '../../domain'; import { BoardResponse } from '../dto'; @@ -49,8 +49,8 @@ describe(`board lookup in room (api)`, () => { const noAccessUser = userFactory.buildWithId(); const noAccessAccount = accountFactory.withUser(noAccessUser).build(); - const roleRoomEdit = roleFactory.buildWithId({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); - const roleRoomView = roleFactory.buildWithId({ name: RoleName.ROOM_VIEWER, permissions: [Permission.ROOM_VIEW] }); + const roleRoomEdit = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); + const roleRoomView = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW] }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, @@ -62,7 +62,7 @@ describe(`board lookup in room (api)`, () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); await em.persistAndFlush([ accountWithEditRole, @@ -75,7 +75,7 @@ describe(`board lookup in room (api)`, () => { roleRoomView, userGroup, room, - roomMember, + roomMembership, ]); const columnBoardNode = columnBoardEntityFactory.build({ diff --git a/apps/server/src/modules/board/controller/api-test/board-update-title-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-update-title-in-room.api.spec.ts index d5988437838..168f2ac77f6 100644 --- a/apps/server/src/modules/board/controller/api-test/board-update-title-in-room.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-update-title-in-room.api.spec.ts @@ -7,7 +7,7 @@ import { TestApiClient, cleanupCollections, groupEntityFactory, roleFactory, use import { Permission, RoleName } from '@shared/domain/interface'; import { accountFactory } from '@src/modules/account/testing'; import { GroupEntityTypes } from '@src/modules/group/entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; import { roomEntityFactory } from '@src/modules/room/testing'; import { BoardNodeEntity } from '../../repo'; import { columnBoardEntityFactory } from '../../testing'; @@ -50,11 +50,11 @@ describe(`board update title with room relation (api)`, () => { const noAccessAccount = accountFactory.withUser(noAccessUser).build(); const roleRoomEdit = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const roleRoomView = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); @@ -68,7 +68,7 @@ describe(`board update title with room relation (api)`, () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); await em.persistAndFlush([ accountWithEditRole, @@ -81,7 +81,7 @@ describe(`board update title with room relation (api)`, () => { roleRoomView, userGroup, room, - roomMember, + roomMembership, ]); const originalTitle = 'old title'; diff --git a/apps/server/src/modules/board/controller/api-test/board-visibility-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-visibility-in-room.api.spec.ts index c9a6aec631a..8bd6a4b9eab 100644 --- a/apps/server/src/modules/board/controller/api-test/board-visibility-in-room.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-visibility-in-room.api.spec.ts @@ -6,7 +6,7 @@ import { TestApiClient, cleanupCollections, groupEntityFactory, roleFactory, use import { Permission, RoleName } from '@shared/domain/interface'; import { accountFactory } from '@src/modules/account/testing'; import { GroupEntityTypes } from '@src/modules/group/entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; import { roomEntityFactory } from '@src/modules/room/testing'; import { BoardExternalReferenceType } from '../../domain'; import { columnBoardEntityFactory } from '../../testing'; @@ -49,11 +49,11 @@ describe(`board update visibility with room relation (api)`, () => { const noAccessAccount = accountFactory.withUser(noAccessUser).build(); const roleRoomEdit = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const roleRoomView = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); @@ -67,7 +67,7 @@ describe(`board update visibility with room relation (api)`, () => { const room = roomEntityFactory.buildWithId(); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); await em.persistAndFlush([ accountWithEditRole, @@ -80,7 +80,7 @@ describe(`board update visibility with room relation (api)`, () => { roleRoomView, userGroup, room, - roomMember, + roomMembership, ]); const columnBoardNode = columnBoardEntityFactory.build({ diff --git a/apps/server/src/modules/board/service/column-board.service.spec.ts b/apps/server/src/modules/board/service/column-board.service.spec.ts index a514242ea97..ac2986ff473 100644 --- a/apps/server/src/modules/board/service/column-board.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board.service.spec.ts @@ -1,14 +1,14 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain/types'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ColumnBoardService } from './column-board.service'; +import { StorageLocation } from '@src/modules/files-storage/interface'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '../../copy-helper'; +import { BoardExternalReference, BoardExternalReferenceType, ColumnBoard } from '../domain'; import { BoardNodeRepo } from '../repo'; +import { columnBoardFactory } from '../testing'; import { BoardNodeService } from './board-node.service'; +import { ColumnBoardService } from './column-board.service'; import { ColumnBoardCopyService, ColumnBoardLinkService } from './internal'; -import { ColumnBoard, BoardExternalReference, BoardExternalReferenceType } from '../domain'; - -import { columnBoardFactory } from '../testing'; -import { CopyElementType, CopyStatus, CopyStatusEnum } from '../../copy-helper'; describe('ColumnBoardService', () => { let module: TestingModule; @@ -107,10 +107,12 @@ describe('ColumnBoardService', () => { columnBoardCopyService.copyColumnBoard.mockResolvedValueOnce(copyStatus); const result = await service.copyColumnBoard({ originalColumnBoardId: '1', - destinationExternalReference: { + targetExternalReference: { type: BoardExternalReferenceType.Course, id: '1', }, + sourceStorageLocationReference: { id: '1', type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: '1', type: StorageLocation.SCHOOL }, userId: '1', }); diff --git a/apps/server/src/modules/board/service/column-board.service.ts b/apps/server/src/modules/board/service/column-board.service.ts index 3985af9bd17..3f225cb5285 100644 --- a/apps/server/src/modules/board/service/column-board.service.ts +++ b/apps/server/src/modules/board/service/column-board.service.ts @@ -4,7 +4,7 @@ import { EntityId } from '@shared/domain/types'; import { BoardExternalReference, BoardExternalReferenceType, ColumnBoard, isColumnBoard } from '../domain'; import { BoardNodeRepo } from '../repo'; import { BoardNodeService } from './board-node.service'; -import { ColumnBoardCopyService, ColumnBoardLinkService } from './internal'; +import { ColumnBoardCopyService, ColumnBoardLinkService, CopyColumnBoardParams } from './internal'; @Injectable() export class ColumnBoardService { @@ -44,13 +44,8 @@ export class ColumnBoardService { await this.boardNodeRepo.delete(boardNodes); } - async copyColumnBoard(props: { - originalColumnBoardId: EntityId; - destinationExternalReference: BoardExternalReference; - userId: EntityId; - copyTitle?: string; - }): Promise { - const copyStatus = await this.columnBoardCopyService.copyColumnBoard(props); + async copyColumnBoard(params: CopyColumnBoardParams): Promise { + const copyStatus = await this.columnBoardCopyService.copyColumnBoard(params); return copyStatus; } diff --git a/apps/server/src/modules/board/service/internal/board-context.service.spec.ts b/apps/server/src/modules/board/service/internal/board-context.service.spec.ts index a1f6f2341a5..489c09f8a8f 100644 --- a/apps/server/src/modules/board/service/internal/board-context.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-context.service.spec.ts @@ -1,30 +1,30 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; +import { GroupTypes } from '@modules/group'; +import { RoomMembershipService } from '@modules/room-membership'; +import { roomMembershipFactory } from '@modules/room-membership/testing'; +import { roomFactory } from '@modules/room/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission, RoleName } from '@shared/domain/interface'; import { CourseRepo } from '@shared/repo/course'; import { courseFactory, groupFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { GroupTypes } from '@src/modules/group'; -import { RoomMemberService } from '@src/modules/room-member'; -import { roomMemberFactory } from '@src/modules/room-member/testing'; -import { roomFactory } from '@src/modules/room/testing'; import { BoardExternalReferenceType, BoardRoles, UserWithBoardRoles } from '../../domain'; import { columnBoardFactory, columnFactory } from '../../testing'; import { BoardContextService } from './board-context.service'; -describe(`${BoardContextService.name}`, () => { +describe(BoardContextService.name, () => { let module: TestingModule; let service: BoardContextService; let courseRepo: DeepMocked; - let roomMemberService: DeepMocked; + let roomMembershipService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ BoardContextService, { - provide: RoomMemberService, - useValue: createMock(), + provide: RoomMembershipService, + useValue: createMock(), }, { provide: CourseRepo, @@ -34,7 +34,7 @@ describe(`${BoardContextService.name}`, () => { }).compile(); service = module.get(BoardContextService); - roomMemberService = module.get(RoomMemberService); + roomMembershipService = module.get(RoomMembershipService); courseRepo = module.get(CourseRepo); await setupEntities(); @@ -218,10 +218,10 @@ describe(`${BoardContextService.name}`, () => { describe('when user with editor role is associated with the room', () => { const setup = () => { const user = userFactory.buildWithId(); - const role = roleFactory.build({ name: RoleName.ROOM_EDITOR, permissions: [Permission.ROOM_EDIT] }); + const role = roleFactory.build({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); const group = groupFactory.build({ type: GroupTypes.ROOM, users: [{ userId: user.id, roleId: role.id }] }); const room = roomFactory.build(); - roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); const columnBoard = columnBoardFactory.build({ context: { id: room.id, type: BoardExternalReferenceType.Room }, }); @@ -232,7 +232,7 @@ describe(`${BoardContextService.name}`, () => { it('should return their information + editor role', async () => { const { columnBoard, role, user } = setup(); - roomMemberService.getRoomMemberAuthorizable.mockResolvedValue({ + roomMembershipService.getRoomMembershipAuthorizable.mockResolvedValue({ id: 'foo', roomId: columnBoard.context.id, members: [{ userId: user.id, roles: [role] }], @@ -253,10 +253,10 @@ describe(`${BoardContextService.name}`, () => { describe('when user with view role is associated with the room', () => { const setup = () => { const user = userFactory.buildWithId(); - const role = roleFactory.build({ name: RoleName.ROOM_VIEWER, permissions: [Permission.ROOM_VIEW] }); + const role = roleFactory.build({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW] }); const group = groupFactory.build({ type: GroupTypes.ROOM, users: [{ userId: user.id, roleId: role.id }] }); const room = roomFactory.build(); - roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); const columnBoard = columnBoardFactory.build({ context: { id: room.id, type: BoardExternalReferenceType.Room }, }); @@ -267,7 +267,7 @@ describe(`${BoardContextService.name}`, () => { it('should return their information + reader role', async () => { const { columnBoard, role, user } = setup(); - roomMemberService.getRoomMemberAuthorizable.mockResolvedValue({ + roomMembershipService.getRoomMembershipAuthorizable.mockResolvedValue({ id: 'foo', roomId: columnBoard.context.id, members: [{ userId: user.id, roles: [role] }], @@ -291,7 +291,7 @@ describe(`${BoardContextService.name}`, () => { const role = roleFactory.build(); const group = groupFactory.build({ type: GroupTypes.ROOM, users: [{ userId: user.id, roleId: role.id }] }); const room = roomFactory.build(); - roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); const columnBoard = columnBoardFactory.build({ context: { id: room.id, type: BoardExternalReferenceType.Room }, }); @@ -302,7 +302,7 @@ describe(`${BoardContextService.name}`, () => { it('should return their information + no role', async () => { const { columnBoard, role, user } = setup(); - roomMemberService.getRoomMemberAuthorizable.mockResolvedValue({ + roomMembershipService.getRoomMembershipAuthorizable.mockResolvedValue({ id: 'foo', roomId: columnBoard.context.id, members: [{ userId: user.id, roles: [role] }], diff --git a/apps/server/src/modules/board/service/internal/board-context.service.ts b/apps/server/src/modules/board/service/internal/board-context.service.ts index 66c78a1ac1f..efb1e51ddf2 100644 --- a/apps/server/src/modules/board/service/internal/board-context.service.ts +++ b/apps/server/src/modules/board/service/internal/board-context.service.ts @@ -2,13 +2,12 @@ import { Injectable } from '@nestjs/common'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo/course'; -import { RoomMemberService } from '@src/modules/room-member'; -import { UserWithRoomRoles } from '@src/modules/room-member/do/room-member-authorizable.do'; +import { RoomMembershipService, UserWithRoomRoles } from '@src/modules/room-membership'; import { AnyBoardNode, BoardExternalReferenceType, BoardRoles, UserWithBoardRoles } from '../../domain'; @Injectable() export class BoardContextService { - constructor(private readonly courseRepo: CourseRepo, private readonly roomMemberService: RoomMemberService) {} + constructor(private readonly courseRepo: CourseRepo, private readonly roomMembershipService: RoomMembershipService) {} async getUsersWithBoardRoles(rootNode: AnyBoardNode): Promise { if (!('context' in rootNode)) { @@ -31,9 +30,9 @@ export class BoardContextService { } private async getFromRoom(roomId: EntityId): Promise { - const roomMemberAuthorizable = await this.roomMemberService.getRoomMemberAuthorizable(roomId); - const usersWithRoles: UserWithBoardRoles[] = roomMemberAuthorizable.members.map((member) => { - const roles = this.getBoardRolesFromRoomMember(member); + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(roomId); + const usersWithRoles: UserWithBoardRoles[] = roomMembershipAuthorizable.members.map((member) => { + const roles = this.getBoardRolesFromRoomMembership(member); return { userId: member.userId, roles, @@ -85,7 +84,7 @@ export class BoardContextService { return usersWithRoles; } - private getBoardRolesFromRoomMember(member: UserWithRoomRoles): BoardRoles[] { + private getBoardRolesFromRoomMembership(member: UserWithRoomRoles): BoardRoles[] { const isReader = member.roles.flatMap((role) => role.permissions ?? []).includes(Permission.ROOM_VIEW); const isEditor = member.roles.flatMap((role) => role.permissions ?? []).includes(Permission.ROOM_EDIT); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts index 412c4e83ecb..a65ef01e651 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts @@ -3,16 +3,14 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { StorageLocation } from '@modules/files-storage/interface'; import { FileRecordParentType } from '@src/infra/rabbitmq'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; -import { BoardNodeCopyContext } from './board-node-copy-context'; +import { BoardNodeCopyContext, BoardNodeCopyContextProps } from './board-node-copy-context'; describe(BoardNodeCopyContext.name, () => { describe('copyFilesOfParent', () => { const setup = () => { - const contextProps = { - sourceStorageLocationId: new ObjectId().toHexString(), - targetStorageLocationId: new ObjectId().toHexString(), - sourceStorageLocation: StorageLocation.SCHOOL, - targetStorageLocation: StorageLocation.SCHOOL, + const contextProps: BoardNodeCopyContextProps = { + sourceStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), }; @@ -34,14 +32,14 @@ describe(BoardNodeCopyContext.name, () => { source: { parentId: sourceParentId, parentType: FileRecordParentType.BoardNode, - storageLocationId: contextProps.sourceStorageLocationId, - storageLocation: contextProps.sourceStorageLocation, + storageLocationId: contextProps.sourceStorageLocationReference.id, + storageLocation: contextProps.sourceStorageLocationReference.type, }, target: { parentId: targetParentId, parentType: FileRecordParentType.BoardNode, - storageLocationId: contextProps.targetStorageLocationId, - storageLocation: contextProps.targetStorageLocation, + storageLocationId: contextProps.targetStorageLocationReference.id, + storageLocation: contextProps.targetStorageLocationReference.type, }, userId: contextProps.userId, }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-context.ts b/apps/server/src/modules/board/service/internal/board-node-copy-context.ts index e9cab871c79..f32fe232534 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-context.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-context.ts @@ -5,12 +5,15 @@ import { EntityId } from '@shared/domain/types'; import { FileRecordParentType } from '@src/infra/rabbitmq'; import { CopyContext } from './board-node-copy.service'; +export type StorageLocationReference = { + id: EntityId; + type: StorageLocation; +}; + export type BoardNodeCopyContextProps = { - sourceStorageLocationId: EntityId; - targetStorageLocationId: EntityId; + sourceStorageLocationReference: StorageLocationReference; + targetStorageLocationReference: StorageLocationReference; userId: EntityId; - sourceStorageLocation: StorageLocation; - targetStorageLocation: StorageLocation; filesStorageClientAdapterService: FilesStorageClientAdapterService; }; @@ -22,14 +25,14 @@ export class BoardNodeCopyContext implements CopyContext { source: { parentId: sourceParentId, parentType: FileRecordParentType.BoardNode, - storageLocationId: this.props.sourceStorageLocationId, - storageLocation: this.props.sourceStorageLocation, + storageLocationId: this.props.sourceStorageLocationReference.id, + storageLocation: this.props.sourceStorageLocationReference.type, }, target: { parentId: targetParentId, parentType: FileRecordParentType.BoardNode, - storageLocationId: this.props.targetStorageLocationId, - storageLocation: this.props.targetStorageLocation, + storageLocationId: this.props.targetStorageLocationReference.id, + storageLocation: this.props.targetStorageLocationReference.type, }, userId: this.props.userId, }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts index 1c6c4bfafe9..71ea6f8ab56 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts @@ -66,10 +66,8 @@ describe(BoardNodeCopyService.name, () => { const setup = () => { const contextProps: BoardNodeCopyContextProps = { - sourceStorageLocationId: new ObjectId().toHexString(), - sourceStorageLocation: StorageLocation.SCHOOL, - targetStorageLocationId: new ObjectId().toHexString(), - targetStorageLocation: StorageLocation.SCHOOL, + sourceStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), }; diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index 5c05465ad7e..44de4316a5c 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -40,7 +40,7 @@ import { submissionContainerElementFactory, submissionItemFactory, } from '../../testing'; -import { BoardNodeCopyContext } from './board-node-copy-context'; +import { BoardNodeCopyContext, BoardNodeCopyContextProps } from './board-node-copy-context'; import { BoardNodeCopyService } from './board-node-copy.service'; describe(BoardNodeCopyService.name, () => { @@ -98,11 +98,9 @@ describe(BoardNodeCopyService.name, () => { }); const setupContext = () => { - const contextProps = { - sourceStorageLocationId: new ObjectId().toHexString(), - sourceStorageLocation: StorageLocation.SCHOOL, - targetStorageLocationId: new ObjectId().toHexString(), - targetStorageLocation: StorageLocation.SCHOOL, + const contextProps: BoardNodeCopyContextProps = { + sourceStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), }; diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts index 0a6792bdc46..e78022f73ef 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts @@ -1,17 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { UserService } from '@modules/user/service/user.service'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { FilesStorageClientAdapterService } from '@modules/files-storage-client/service'; +import { StorageLocation } from '@modules/files-storage/interface'; import { Test, TestingModule } from '@nestjs/testing'; -import { CourseRepo } from '@shared/repo/course/course.repo'; -import { courseFactory, setupEntities, userDoFactory } from '@shared/testing'; -import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; -import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client/service/files-storage-client.service'; -import { BoardExternalReferenceType } from '../../domain'; -import { columnBoardFactory } from '../../testing'; +import { courseFactory, setupEntities } from '@shared/testing'; +import { BoardExternalReferenceType } from '../../domain/types'; +import { columnBoardFactory } from '../../testing/column-board.factory'; import { BoardNodeService } from '../board-node.service'; -import { ColumnBoardCopyService } from './column-board-copy.service'; +import { ColumnBoardCopyService, CopyColumnBoardParams } from './column-board-copy.service'; import { ColumnBoardTitleService } from './column-board-title.service'; -// Important: Don't move the BoardNodeCopyService import up to prevent import cycle! +// Warning: do not move the BoardNodeCopyService import up. Otherwise it will lead to dependency cycle. import { BoardNodeCopyService } from './board-node-copy.service'; describe(ColumnBoardCopyService.name, () => { @@ -19,8 +18,6 @@ describe(ColumnBoardCopyService.name, () => { let service: ColumnBoardCopyService; let boardNodeService: DeepMocked; - let courseRepo: DeepMocked; - let userService: DeepMocked; let boardNodeCopyService: DeepMocked; let columnBoardTitleService: DeepMocked; @@ -36,14 +33,6 @@ describe(ColumnBoardCopyService.name, () => { provide: ColumnBoardTitleService, useValue: createMock(), }, - { - provide: CourseRepo, - useValue: createMock(), - }, - { - provide: UserService, - useValue: createMock(), - }, { provide: BoardNodeCopyService, useValue: createMock(), @@ -52,17 +41,11 @@ describe(ColumnBoardCopyService.name, () => { provide: FilesStorageClientAdapterService, useValue: createMock(), }, - { - provide: ColumnBoardTitleService, - useValue: createMock(), - }, ], }).compile(); service = module.get(ColumnBoardCopyService); boardNodeService = module.get(BoardNodeService); - courseRepo = module.get(CourseRepo); - userService = module.get(UserService); boardNodeCopyService = module.get(BoardNodeCopyService); columnBoardTitleService = module.get(ColumnBoardTitleService); @@ -80,10 +63,7 @@ describe(ColumnBoardCopyService.name, () => { describe('copyColumnBoard', () => { const setup = () => { const userId = new ObjectId().toHexString(); - const user = userDoFactory.build({ id: userId }); - userService.findById.mockResolvedValueOnce(user); const course = courseFactory.buildWithId(); - courseRepo.findById.mockResolvedValueOnce(course); const originalBoard = columnBoardFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, }); @@ -99,66 +79,40 @@ describe(ColumnBoardCopyService.name, () => { }; boardNodeCopyService.copy.mockResolvedValueOnce(status); - return { originalBoard, userId }; - }; - - it('should find the original board', async () => { - const { originalBoard, userId } = setup(); - - await service.copyColumnBoard({ + const copyParams: CopyColumnBoardParams = { originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, + targetExternalReference: originalBoard.context, + sourceStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, userId, - }); - - expect(boardNodeService.findByClassAndId).toHaveBeenCalled(); - }); - - it('should find the user', async () => { - const { originalBoard, userId } = setup(); + copyTitle: 'Board Copy', + }; - await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, - }); + return { originalBoard, userId, copyParams }; + }; - expect(userService.findById).toHaveBeenCalled(); - }); + it('should find the original board', async () => { + const { copyParams } = setup(); - it('should find the course', async () => { - const { originalBoard, userId } = setup(); + await service.copyColumnBoard(copyParams); - await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, - }); - - expect(courseRepo.findById).toHaveBeenCalled(); + expect(boardNodeService.findByClassAndId).toHaveBeenCalled(); }); it('should call service to copy the board', async () => { - const { originalBoard, userId } = setup(); + const { copyParams } = setup(); - await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, - copyTitle: 'Another Title', - }); + await service.copyColumnBoard(copyParams); expect(boardNodeCopyService.copy).toHaveBeenCalled(); }); it('should set the title of the copied board', async () => { - const { originalBoard, userId } = setup(); + const { copyParams } = setup(); const copyTitle = 'Another Title'; await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, + ...copyParams, copyTitle, }); @@ -166,56 +120,43 @@ describe(ColumnBoardCopyService.name, () => { }); it('should derive the title of the copied board', async () => { - const { originalBoard, userId } = setup(); + const { copyParams } = setup(); const derivedTitle = 'Derived Title'; columnBoardTitleService.deriveColumnBoardTitle.mockResolvedValueOnce(derivedTitle); await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, + ...copyParams, + copyTitle: undefined, }); expect(boardNodeService.addRoot).toHaveBeenCalledWith(expect.objectContaining({ title: derivedTitle })); }); it('should set the context of the copied board', async () => { - const { originalBoard, userId } = setup(); - const destinationExternalReference = { + const { copyParams } = setup(); + const targetExternalReference = { id: new ObjectId().toHexString(), type: BoardExternalReferenceType.Course, }; - await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference, - userId, - }); + await service.copyColumnBoard({ ...copyParams, targetExternalReference }); expect(boardNodeService.addRoot).toHaveBeenCalledWith( - expect.objectContaining({ context: destinationExternalReference }) + expect.objectContaining({ context: targetExternalReference }) ); }); it('should return the copy status', async () => { - const { originalBoard, userId } = setup(); - const copyStatus = await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, - }); + const { copyParams } = setup(); + const copyStatus = await service.copyColumnBoard(copyParams); expect(copyStatus).toBeDefined(); expect(copyStatus.copyEntity).toBeDefined(); }); it('should not affect the original board', async () => { - const { originalBoard, userId } = setup(); - const copyStatus = await service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, - }); + const { copyParams, originalBoard } = setup(); + const copyStatus = await service.copyColumnBoard(copyParams); expect(copyStatus.originalEntity).toBe(originalBoard); }); @@ -224,20 +165,26 @@ describe(ColumnBoardCopyService.name, () => { describe('when the copy response is not a ColumnBoard', () => { const setup = () => { const userId = new ObjectId().toHexString(); - const user = userDoFactory.build({ id: userId }); - userService.findById.mockResolvedValueOnce(user); const course = courseFactory.buildWithId(); - courseRepo.findById.mockResolvedValueOnce(course); const originalBoard = columnBoardFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, }); boardNodeService.findByClassAndId.mockResolvedValueOnce(originalBoard); - return { originalBoard, userId }; + const copyParams: CopyColumnBoardParams = { + originalColumnBoardId: originalBoard.id, + targetExternalReference: originalBoard.context, + sourceStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, + userId, + copyTitle: 'Board Copy', + }; + + return { originalBoard, userId, copyParams }; }; it('should throw an error if the board is not a column board', async () => { - const { originalBoard, userId } = setup(); + const { originalBoard, copyParams } = setup(); const boardCopy = { ...originalBoard, id: new ObjectId().toHexString(), type: 'not-a-column-board' }; const status: CopyStatus = { @@ -247,13 +194,9 @@ describe(ColumnBoardCopyService.name, () => { }; boardNodeCopyService.copy.mockResolvedValueOnce(status); - await expect( - service.copyColumnBoard({ - originalColumnBoardId: originalBoard.id, - destinationExternalReference: originalBoard.context, - userId, - }) - ).rejects.toThrowError('expected copy of columnboard to be a columnboard'); + await expect(service.copyColumnBoard(copyParams)).rejects.toThrowError( + 'expected copy of columnboard to be a columnboard' + ); }); }); }); diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts index a33c6a7b1e6..531ec8c3510 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts @@ -1,48 +1,40 @@ import { CopyStatus } from '@modules/copy-helper'; -import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { StorageLocation } from '@modules/files-storage/interface'; -import { UserService } from '@modules/user'; +import { FilesStorageClientAdapterService } from '@modules/files-storage-client/service'; import { Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { CourseRepo } from '@shared/repo/course'; import { BoardExternalReference, BoardExternalReferenceType, ColumnBoard, isColumnBoard } from '../../domain'; import { BoardNodeService } from '../board-node.service'; -import { BoardNodeCopyContext } from './board-node-copy-context'; +import { BoardNodeCopyContext, StorageLocationReference } from './board-node-copy-context'; import { BoardNodeCopyService } from './board-node-copy.service'; import { ColumnBoardTitleService } from './column-board-title.service'; +export type CopyColumnBoardParams = { + originalColumnBoardId: EntityId; + targetExternalReference: BoardExternalReference; + sourceStorageLocationReference: StorageLocationReference; + targetStorageLocationReference: StorageLocationReference; + userId: EntityId; + copyTitle?: string; +}; + @Injectable() export class ColumnBoardCopyService { constructor( private readonly boardNodeService: BoardNodeService, private readonly columnBoardTitleService: ColumnBoardTitleService, - private readonly courseRepo: CourseRepo, - private readonly userService: UserService, private readonly boardNodeCopyService: BoardNodeCopyService, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService ) {} - async copyColumnBoard(props: { - originalColumnBoardId: EntityId; - destinationExternalReference: BoardExternalReference; - userId: EntityId; - copyTitle?: string; - }): Promise { - const originalBoard = await this.boardNodeService.findByClassAndId(ColumnBoard, props.originalColumnBoardId); + async copyColumnBoard(params: CopyColumnBoardParams): Promise { + const originalBoard = await this.boardNodeService.findByClassAndId(ColumnBoard, params.originalColumnBoardId); - const user = await this.userService.findById(props.userId); - /* istanbul ignore next */ - if (originalBoard.context.type !== BoardExternalReferenceType.Course) { - throw new NotImplementedException('only courses are supported as board parents'); - } - const course = await this.courseRepo.findById(originalBoard.context.id); // TODO: get rid of this + this.checkSupportedExternalReferenceType(params.targetExternalReference.type); const copyContext = new BoardNodeCopyContext({ - sourceStorageLocationId: course.school.id, - targetStorageLocationId: user.schoolId, - sourceStorageLocation: StorageLocation.SCHOOL, - targetStorageLocation: StorageLocation.SCHOOL, - userId: props.userId, + sourceStorageLocationReference: params.sourceStorageLocationReference, + targetStorageLocationReference: params.targetStorageLocationReference, + userId: params.userId, filesStorageClientAdapterService: this.filesStorageClientAdapterService, }); @@ -53,18 +45,26 @@ export class ColumnBoardCopyService { throw new InternalServerErrorException('expected copy of columnboard to be a columnboard'); } - if (props.copyTitle) { - copyStatus.copyEntity.title = props.copyTitle; + if (params.copyTitle) { + copyStatus.copyEntity.title = params.copyTitle; } else { copyStatus.copyEntity.title = await this.columnBoardTitleService.deriveColumnBoardTitle( originalBoard.title, - props.destinationExternalReference + params.targetExternalReference ); } - copyStatus.copyEntity.context = props.destinationExternalReference; + copyStatus.copyEntity.context = params.targetExternalReference; + copyStatus.copyEntity.isVisible = false; await this.boardNodeService.addRoot(copyStatus.copyEntity); copyStatus.originalEntity = originalBoard; return copyStatus; } + + private checkSupportedExternalReferenceType(type: BoardExternalReferenceType) { + /* istanbul ignore next */ + if (type !== BoardExternalReferenceType.Course && type !== BoardExternalReferenceType.Room) { + throw new NotImplementedException('Only room and course external reference types are supported'); + } + } } diff --git a/apps/server/src/modules/board/service/internal/column-board-title.service.ts b/apps/server/src/modules/board/service/internal/column-board-title.service.ts index e43cbda1afe..07f6c1b6faa 100644 --- a/apps/server/src/modules/board/service/internal/column-board-title.service.ts +++ b/apps/server/src/modules/board/service/internal/column-board-title.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { CopyHelperService } from '@modules/copy-helper'; +import { CopyHelperService } from '@modules/copy-helper/service/copy-helper.service'; import { BoardExternalReference } from '../../domain'; import { ColumnBoardReferenceService } from './column-board-reference.service'; diff --git a/apps/server/src/modules/board/uc/board.uc.spec.ts b/apps/server/src/modules/board/uc/board.uc.spec.ts index 8b760cfa862..ff22353895b 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -7,7 +7,8 @@ import { CourseRepo } from '@shared/repo/course'; import { setupEntities, userFactory } from '@shared/testing'; import { courseFactory } from '@shared/testing/factory'; import { LegacyLogger } from '@src/core/logger'; -import { RoomMemberService } from '@src/modules/room-member'; +import { RoomService } from '@src/modules/room'; +import { RoomMembershipService } from '@src/modules/room-membership'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '../../copy-helper'; import { BoardExternalReferenceType, BoardLayout, BoardNodeFactory, Column, ColumnBoard } from '../domain'; import { BoardNodePermissionService, BoardNodeService, ColumnBoardService } from '../service'; @@ -48,13 +49,17 @@ describe(BoardUc.name, () => { provide: CourseRepo, useValue: createMock(), }, + { + provide: RoomService, + useValue: createMock(), + }, { provide: BoardNodeFactory, useValue: createMock(), }, { - provide: RoomMemberService, - useValue: createMock(), + provide: RoomMembershipService, + useValue: createMock(), }, { provide: LegacyLogger, @@ -427,8 +432,15 @@ describe(BoardUc.name, () => { }); describe('copyBoard', () => { + const setup = () => { + const { user, board, boardId } = globalSetup(); + boardNodeService.findByClassAndId.mockResolvedValueOnce(board); + + return { user, board, boardId }; + }; + it('should call the service to find the user', async () => { - const { user, boardId } = globalSetup(); + const { user, boardId } = setup(); await uc.copyBoard(user.id, boardId); @@ -436,7 +448,7 @@ describe(BoardUc.name, () => { }); it('should call the service to find the board', async () => { - const { user, boardId } = globalSetup(); + const { user, boardId } = setup(); await uc.copyBoard(user.id, boardId); @@ -444,7 +456,7 @@ describe(BoardUc.name, () => { }); it('[deprecated] should call course repo to find the course', async () => { - const { user, boardId } = globalSetup(); + const { user, boardId } = setup(); await uc.copyBoard(user.id, boardId); @@ -452,8 +464,7 @@ describe(BoardUc.name, () => { }); it('should call Board Permission Service to check permission', async () => { - const { user, board } = globalSetup(); - boardNodeService.findByClassAndId.mockResolvedValueOnce(board); + const { user, board } = setup(); await uc.copyBoard(user.id, board.id); @@ -461,7 +472,7 @@ describe(BoardUc.name, () => { }); it('should call authorization to check course permissions', async () => { - const { user, boardId } = globalSetup(); + const { user, boardId } = setup(); const course = courseFactory.build(); // TODO should not use course repo @@ -471,12 +482,12 @@ describe(BoardUc.name, () => { expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, course, { action: Action.write, - requiredPermissions: [], + requiredPermissions: ['COURSE_EDIT'], }); }); it('should call the service to copy the board', async () => { - const { user, boardId } = globalSetup(); + const { user, boardId } = setup(); await uc.copyBoard(user.id, boardId); @@ -486,7 +497,7 @@ describe(BoardUc.name, () => { }); it('should return the copy status', async () => { - const { user, boardId } = globalSetup(); + const { user, boardId } = setup(); const copyStatus: CopyStatus = { type: CopyElementType.BOARD, diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index 8beb95d5260..3dae0f22bb2 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -5,10 +5,13 @@ import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo/course'; import { LegacyLogger } from '@src/core/logger'; -import { RoomMemberService } from '@src/modules/room-member'; +import { StorageLocation } from '@src/modules/files-storage/interface'; +import { RoomService } from '@src/modules/room'; +import { RoomMembershipService } from '@src/modules/room-membership'; import { CreateBoardBodyParams } from '../controller/dto'; import { BoardExternalReference, BoardExternalReferenceType, BoardNodeFactory, Column, ColumnBoard } from '../domain'; import { BoardNodePermissionService, BoardNodeService, ColumnBoardService } from '../service'; +import { StorageLocationReference } from '../service/internal'; @Injectable() export class BoardUc { @@ -16,11 +19,12 @@ export class BoardUc { @Inject(forwardRef(() => AuthorizationService)) // TODO is this needed? private readonly authorizationService: AuthorizationService, private readonly boardPermissionService: BoardNodePermissionService, - private readonly roomMemberService: RoomMemberService, + private readonly roomMembershipService: RoomMembershipService, private readonly boardNodeService: BoardNodeService, private readonly columnBoardService: ColumnBoardService, private readonly logger: LegacyLogger, private readonly courseRepo: CourseRepo, + private readonly roomService: RoomService, private readonly boardNodeFactory: BoardNodeFactory ) { this.logger.setContext(BoardUc.name); @@ -29,7 +33,7 @@ export class BoardUc { async createBoard(userId: EntityId, params: CreateBoardBodyParams): Promise { this.logger.debug({ action: 'createBoard', userId, title: params.title }); - await this.checkParentWritePermission(userId, { type: params.parentType, id: params.parentId }); + await this.checkReferenceWritePermission(userId, { type: params.parentType, id: params.parentId }); const board = this.boardNodeFactory.buildColumnBoard({ context: { type: params.parentType, id: params.parentId }, @@ -115,22 +119,19 @@ export class BoardUc { async copyBoard(userId: EntityId, boardId: EntityId): Promise { this.logger.debug({ action: 'copyBoard', userId, boardId }); - const user = await this.authorizationService.getUserWithPermissions(userId); const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); - // TODO - should not use course repo - const course = await this.courseRepo.findById(board.context.id); - await this.boardPermissionService.checkPermission(userId, board, Action.read); - this.authorizationService.checkPermission(user, course, { - action: Action.write, - requiredPermissions: [], // TODO - what permissions are required? COURSE_EDIT? - }); + await this.checkReferenceWritePermission(userId, board.context); + + const storageLocationReference = await this.getStorageLocationReference(board.context); const copyStatus = await this.columnBoardService.copyColumnBoard({ - userId, originalColumnBoardId: boardId, - destinationExternalReference: board.context, + targetExternalReference: board.context, + sourceStorageLocationReference: storageLocationReference, + targetStorageLocationReference: storageLocationReference, + userId, }); return copyStatus; @@ -144,7 +145,9 @@ export class BoardUc { return board; } - private async checkParentWritePermission(userId: EntityId, context: BoardExternalReference) { + // ---- Move to shared service? (see apps/server/src/modules/sharing/uc/share-token.uc.ts) + + private async checkReferenceWritePermission(userId: EntityId, context: BoardExternalReference) { const user = await this.authorizationService.getUserWithPermissions(userId); if (context.type === BoardExternalReferenceType.Course) { @@ -155,9 +158,9 @@ export class BoardUc { requiredPermissions: [Permission.COURSE_EDIT], }); } else if (context.type === BoardExternalReferenceType.Room) { - const roomMemberAuthorizable = await this.roomMemberService.getRoomMemberAuthorizable(context.id); + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(context.id); - this.authorizationService.checkPermission(user, roomMemberAuthorizable, { + this.authorizationService.checkPermission(user, roomMembershipAuthorizable, { action: Action.write, requiredPermissions: [], }); @@ -165,4 +168,20 @@ export class BoardUc { throw new Error(`Unsupported context type ${context.type as string}`); } } + + private async getStorageLocationReference(context: BoardExternalReference): Promise { + if (context.type === BoardExternalReferenceType.Course) { + const course = await this.courseRepo.findById(context.id); + + return { id: course.school.id, type: StorageLocation.SCHOOL }; + } + + if (context.type === BoardExternalReferenceType.Room) { + const room = await this.roomService.getSingleRoom(context.id); + + return { id: room.schoolId, type: StorageLocation.SCHOOL }; + } + /* istanbul ignore next */ + throw new Error(`Unsupported board reference type ${context.type as string}`); + } } diff --git a/apps/server/src/modules/board/uc/element.uc.spec.ts b/apps/server/src/modules/board/uc/element.uc.spec.ts index 2cf247b80ca..3309571196c 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -3,7 +3,8 @@ import { Action } from '@modules/authorization'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { InputFormat } from '@shared/domain/types'; -import { setupEntities, userFactory } from '@shared/testing'; +import { setupEntities } from '@shared/testing/setup-entities'; +import { userFactory } from '@shared/testing/factory'; import { Logger } from '@src/core/logger'; import { RichTextContentBody } from '../controller/dto'; import { BoardNodeFactory } from '../domain'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/copy-api-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/copy-api-response.ts index 4921f0d6484..3c78d846edc 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/copy-api-response.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/copy-api-response.ts @@ -5,110 +5,106 @@ * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. * * The version of the OpenAPI document: 3.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ - - /** - * + * * @export * @interface CopyApiResponse */ export interface CopyApiResponse { - /** - * Id of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'id'?: string; - /** - * Title of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'title'?: string; - /** - * Type of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'type': CopyApiResponseType; - /** - * Id of destination course - * @type {string} - * @memberof CopyApiResponse - */ - 'destinationCourseId'?: string; - /** - * Copy progress status of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'status': CopyApiResponseStatus; - /** - * List of included sub elements with recursive type structure - * @type {Array} - * @memberof CopyApiResponse - */ - 'elements'?: Array; + /** + * Id of copied element + * @type {string} + * @memberof CopyApiResponse + */ + id?: string; + /** + * Title of copied element + * @type {string} + * @memberof CopyApiResponse + */ + title?: string; + /** + * Type of copied element + * @type {string} + * @memberof CopyApiResponse + */ + type: CopyApiResponseType; + /** + * Id of destination parent reference + * @type {string} + * @memberof CopyApiResponse + */ + destinationId?: string; + /** + * Copy progress status of copied element + * @type {string} + * @memberof CopyApiResponse + */ + status: CopyApiResponseStatus; + /** + * List of included sub elements with recursive type structure + * @type {Array} + * @memberof CopyApiResponse + */ + elements?: Array; } export const CopyApiResponseType = { - BOARD: 'BOARD', - CARD: 'CARD', - COLLABORATIVE_TEXT_EDITOR_ELEMENT: 'COLLABORATIVE_TEXT_EDITOR_ELEMENT', - COLUMN: 'COLUMN', - COLUMNBOARD: 'COLUMNBOARD', - CONTENT: 'CONTENT', - COURSE: 'COURSE', - COURSEGROUP_GROUP: 'COURSEGROUP_GROUP', - EXTERNAL_TOOL: 'EXTERNAL_TOOL', - EXTERNAL_TOOL_ELEMENT: 'EXTERNAL_TOOL_ELEMENT', - FILE: 'FILE', - FILE_ELEMENT: 'FILE_ELEMENT', - DRAWING_ELEMENT: 'DRAWING_ELEMENT', - FILE_GROUP: 'FILE_GROUP', - LEAF: 'LEAF', - LESSON: 'LESSON', - LESSON_CONTENT_ETHERPAD: 'LESSON_CONTENT_ETHERPAD', - LESSON_CONTENT_GEOGEBRA: 'LESSON_CONTENT_GEOGEBRA', - LESSON_CONTENT_GROUP: 'LESSON_CONTENT_GROUP', - LESSON_CONTENT_LERNSTORE: 'LESSON_CONTENT_LERNSTORE', - LESSON_CONTENT_NEXBOARD: 'LESSON_CONTENT_NEXBOARD', - LESSON_CONTENT_TASK: 'LESSON_CONTENT_TASK', - LESSON_CONTENT_TEXT: 'LESSON_CONTENT_TEXT', - LERNSTORE_MATERIAL: 'LERNSTORE_MATERIAL', - LERNSTORE_MATERIAL_GROUP: 'LERNSTORE_MATERIAL_GROUP', - LINK_ELEMENT: 'LINK_ELEMENT', - LTITOOL_GROUP: 'LTITOOL_GROUP', - MEDIA_BOARD: 'MEDIA_BOARD', - MEDIA_LINE: 'MEDIA_LINE', - MEDIA_EXTERNAL_TOOL_ELEMENT: 'MEDIA_EXTERNAL_TOOL_ELEMENT', - METADATA: 'METADATA', - RICHTEXT_ELEMENT: 'RICHTEXT_ELEMENT', - SUBMISSION_CONTAINER_ELEMENT: 'SUBMISSION_CONTAINER_ELEMENT', - SUBMISSION_ITEM: 'SUBMISSION_ITEM', - SUBMISSION_GROUP: 'SUBMISSION_GROUP', - TASK: 'TASK', - TASK_GROUP: 'TASK_GROUP', - TIME_GROUP: 'TIME_GROUP', - USER_GROUP: 'USER_GROUP' + BOARD: 'BOARD', + CARD: 'CARD', + COLLABORATIVE_TEXT_EDITOR_ELEMENT: 'COLLABORATIVE_TEXT_EDITOR_ELEMENT', + COLUMN: 'COLUMN', + COLUMNBOARD: 'COLUMNBOARD', + CONTENT: 'CONTENT', + COURSE: 'COURSE', + COURSEGROUP_GROUP: 'COURSEGROUP_GROUP', + EXTERNAL_TOOL: 'EXTERNAL_TOOL', + EXTERNAL_TOOL_ELEMENT: 'EXTERNAL_TOOL_ELEMENT', + FILE: 'FILE', + FILE_ELEMENT: 'FILE_ELEMENT', + DRAWING_ELEMENT: 'DRAWING_ELEMENT', + FILE_GROUP: 'FILE_GROUP', + LEAF: 'LEAF', + LESSON: 'LESSON', + LESSON_CONTENT_ETHERPAD: 'LESSON_CONTENT_ETHERPAD', + LESSON_CONTENT_GEOGEBRA: 'LESSON_CONTENT_GEOGEBRA', + LESSON_CONTENT_GROUP: 'LESSON_CONTENT_GROUP', + LESSON_CONTENT_LERNSTORE: 'LESSON_CONTENT_LERNSTORE', + LESSON_CONTENT_NEXBOARD: 'LESSON_CONTENT_NEXBOARD', + LESSON_CONTENT_TASK: 'LESSON_CONTENT_TASK', + LESSON_CONTENT_TEXT: 'LESSON_CONTENT_TEXT', + LERNSTORE_MATERIAL: 'LERNSTORE_MATERIAL', + LERNSTORE_MATERIAL_GROUP: 'LERNSTORE_MATERIAL_GROUP', + LINK_ELEMENT: 'LINK_ELEMENT', + LTITOOL_GROUP: 'LTITOOL_GROUP', + MEDIA_BOARD: 'MEDIA_BOARD', + MEDIA_LINE: 'MEDIA_LINE', + MEDIA_EXTERNAL_TOOL_ELEMENT: 'MEDIA_EXTERNAL_TOOL_ELEMENT', + METADATA: 'METADATA', + RICHTEXT_ELEMENT: 'RICHTEXT_ELEMENT', + SUBMISSION_CONTAINER_ELEMENT: 'SUBMISSION_CONTAINER_ELEMENT', + SUBMISSION_ITEM: 'SUBMISSION_ITEM', + SUBMISSION_GROUP: 'SUBMISSION_GROUP', + TASK: 'TASK', + TASK_GROUP: 'TASK_GROUP', + TIME_GROUP: 'TIME_GROUP', + USER_GROUP: 'USER_GROUP', } as const; export type CopyApiResponseType = typeof CopyApiResponseType[keyof typeof CopyApiResponseType]; export const CopyApiResponseStatus = { - SUCCESS: 'success', - FAILURE: 'failure', - NOT_DOING: 'not-doing', - NOT_IMPLEMENTED: 'not-implemented', - PARTIAL: 'partial' + SUCCESS: 'success', + FAILURE: 'failure', + NOT_DOING: 'not-doing', + NOT_IMPLEMENTED: 'not-implemented', + PARTIAL: 'partial', } as const; export type CopyApiResponseStatus = typeof CopyApiResponseStatus[keyof typeof CopyApiResponseStatus]; - - diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-api-client/models/copy-api-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-api-client/models/copy-api-response.ts index dd356d1e871..a4b72536f13 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-api-client/models/copy-api-response.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-api-client/models/copy-api-response.ts @@ -5,111 +5,107 @@ * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. * * The version of the OpenAPI document: 3.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ - - /** - * + * * @export * @interface CopyApiResponse */ export interface CopyApiResponse { - /** - * Id of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'id'?: string; - /** - * Title of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'title'?: string; - /** - * Type of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'type': CopyApiResponseType; - /** - * Id of destination course - * @type {string} - * @memberof CopyApiResponse - */ - 'destinationCourseId'?: string; - /** - * Copy progress status of copied element - * @type {string} - * @memberof CopyApiResponse - */ - 'status': CopyApiResponseStatus; - /** - * List of included sub elements with recursive type structure - * @type {Array} - * @memberof CopyApiResponse - */ - 'elements'?: Array; + /** + * Id of copied element + * @type {string} + * @memberof CopyApiResponse + */ + id?: string; + /** + * Title of copied element + * @type {string} + * @memberof CopyApiResponse + */ + title?: string; + /** + * Type of copied element + * @type {string} + * @memberof CopyApiResponse + */ + type: CopyApiResponseType; + /** + * Id of destination parent reference + * @type {string} + * @memberof CopyApiResponse + */ + destinationId?: string; + /** + * Copy progress status of copied element + * @type {string} + * @memberof CopyApiResponse + */ + status: CopyApiResponseStatus; + /** + * List of included sub elements with recursive type structure + * @type {Array} + * @memberof CopyApiResponse + */ + elements?: Array; } export const CopyApiResponseType = { - BOARD: 'BOARD', - CARD: 'CARD', - COLLABORATIVE_TEXT_EDITOR_ELEMENT: 'COLLABORATIVE_TEXT_EDITOR_ELEMENT', - COLUMN: 'COLUMN', - COLUMNBOARD: 'COLUMNBOARD', - CONTENT: 'CONTENT', - COURSE: 'COURSE', - COURSEGROUP_GROUP: 'COURSEGROUP_GROUP', - DELETED_ELEMENT: 'DELETED_ELEMENT', - EXTERNAL_TOOL: 'EXTERNAL_TOOL', - EXTERNAL_TOOL_ELEMENT: 'EXTERNAL_TOOL_ELEMENT', - FILE: 'FILE', - FILE_ELEMENT: 'FILE_ELEMENT', - DRAWING_ELEMENT: 'DRAWING_ELEMENT', - FILE_GROUP: 'FILE_GROUP', - LEAF: 'LEAF', - LESSON: 'LESSON', - LESSON_CONTENT_ETHERPAD: 'LESSON_CONTENT_ETHERPAD', - LESSON_CONTENT_GEOGEBRA: 'LESSON_CONTENT_GEOGEBRA', - LESSON_CONTENT_GROUP: 'LESSON_CONTENT_GROUP', - LESSON_CONTENT_LERNSTORE: 'LESSON_CONTENT_LERNSTORE', - LESSON_CONTENT_NEXBOARD: 'LESSON_CONTENT_NEXBOARD', - LESSON_CONTENT_TASK: 'LESSON_CONTENT_TASK', - LESSON_CONTENT_TEXT: 'LESSON_CONTENT_TEXT', - LERNSTORE_MATERIAL: 'LERNSTORE_MATERIAL', - LERNSTORE_MATERIAL_GROUP: 'LERNSTORE_MATERIAL_GROUP', - LINK_ELEMENT: 'LINK_ELEMENT', - LTITOOL_GROUP: 'LTITOOL_GROUP', - MEDIA_BOARD: 'MEDIA_BOARD', - MEDIA_LINE: 'MEDIA_LINE', - MEDIA_EXTERNAL_TOOL_ELEMENT: 'MEDIA_EXTERNAL_TOOL_ELEMENT', - METADATA: 'METADATA', - RICHTEXT_ELEMENT: 'RICHTEXT_ELEMENT', - SUBMISSION_CONTAINER_ELEMENT: 'SUBMISSION_CONTAINER_ELEMENT', - SUBMISSION_ITEM: 'SUBMISSION_ITEM', - SUBMISSION_GROUP: 'SUBMISSION_GROUP', - TASK: 'TASK', - TASK_GROUP: 'TASK_GROUP', - TIME_GROUP: 'TIME_GROUP', - USER_GROUP: 'USER_GROUP' + BOARD: 'BOARD', + CARD: 'CARD', + COLLABORATIVE_TEXT_EDITOR_ELEMENT: 'COLLABORATIVE_TEXT_EDITOR_ELEMENT', + COLUMN: 'COLUMN', + COLUMNBOARD: 'COLUMNBOARD', + CONTENT: 'CONTENT', + COURSE: 'COURSE', + COURSEGROUP_GROUP: 'COURSEGROUP_GROUP', + DELETED_ELEMENT: 'DELETED_ELEMENT', + EXTERNAL_TOOL: 'EXTERNAL_TOOL', + EXTERNAL_TOOL_ELEMENT: 'EXTERNAL_TOOL_ELEMENT', + FILE: 'FILE', + FILE_ELEMENT: 'FILE_ELEMENT', + DRAWING_ELEMENT: 'DRAWING_ELEMENT', + FILE_GROUP: 'FILE_GROUP', + LEAF: 'LEAF', + LESSON: 'LESSON', + LESSON_CONTENT_ETHERPAD: 'LESSON_CONTENT_ETHERPAD', + LESSON_CONTENT_GEOGEBRA: 'LESSON_CONTENT_GEOGEBRA', + LESSON_CONTENT_GROUP: 'LESSON_CONTENT_GROUP', + LESSON_CONTENT_LERNSTORE: 'LESSON_CONTENT_LERNSTORE', + LESSON_CONTENT_NEXBOARD: 'LESSON_CONTENT_NEXBOARD', + LESSON_CONTENT_TASK: 'LESSON_CONTENT_TASK', + LESSON_CONTENT_TEXT: 'LESSON_CONTENT_TEXT', + LERNSTORE_MATERIAL: 'LERNSTORE_MATERIAL', + LERNSTORE_MATERIAL_GROUP: 'LERNSTORE_MATERIAL_GROUP', + LINK_ELEMENT: 'LINK_ELEMENT', + LTITOOL_GROUP: 'LTITOOL_GROUP', + MEDIA_BOARD: 'MEDIA_BOARD', + MEDIA_LINE: 'MEDIA_LINE', + MEDIA_EXTERNAL_TOOL_ELEMENT: 'MEDIA_EXTERNAL_TOOL_ELEMENT', + METADATA: 'METADATA', + RICHTEXT_ELEMENT: 'RICHTEXT_ELEMENT', + SUBMISSION_CONTAINER_ELEMENT: 'SUBMISSION_CONTAINER_ELEMENT', + SUBMISSION_ITEM: 'SUBMISSION_ITEM', + SUBMISSION_GROUP: 'SUBMISSION_GROUP', + TASK: 'TASK', + TASK_GROUP: 'TASK_GROUP', + TIME_GROUP: 'TIME_GROUP', + USER_GROUP: 'USER_GROUP', } as const; export type CopyApiResponseType = typeof CopyApiResponseType[keyof typeof CopyApiResponseType]; export const CopyApiResponseStatus = { - SUCCESS: 'success', - FAILURE: 'failure', - NOT_DOING: 'not-doing', - NOT_IMPLEMENTED: 'not-implemented', - PARTIAL: 'partial' + SUCCESS: 'success', + FAILURE: 'failure', + NOT_DOING: 'not-doing', + NOT_IMPLEMENTED: 'not-implemented', + PARTIAL: 'partial', } as const; export type CopyApiResponseStatus = typeof CopyApiResponseStatus[keyof typeof CopyApiResponseStatus]; - - diff --git a/apps/server/src/modules/copy-helper/dto/copy.response.ts b/apps/server/src/modules/copy-helper/dto/copy.response.ts index 549dcac7014..f255432eb1e 100644 --- a/apps/server/src/modules/copy-helper/dto/copy.response.ts +++ b/apps/server/src/modules/copy-helper/dto/copy.response.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { CopyElementType, CopyStatusEnum } from '@modules/copy-helper/types/copy.types'; +import { CopyElementType, CopyStatusEnum } from '../types/copy.types'; /** * DTO for returning a copy status document via api. @@ -29,9 +29,9 @@ export class CopyApiResponse { type: CopyElementType; @ApiPropertyOptional({ - description: 'Id of destination course', + description: 'Id of destination parent reference', }) - destinationCourseId?: string; + destinationId?: string; @ApiProperty({ type: 'string', diff --git a/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts b/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts index 581e33161bd..aa5e8d44af0 100644 --- a/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts +++ b/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts @@ -1,9 +1,11 @@ import { LessonCopyApiParams } from '@modules/learnroom/controller/dto/lesson/lesson-copy.params'; -import { LessonCopyParentParams } from '@modules/lesson/types'; +import { LessonCopyParentParams } from '@modules/lesson/types/lesson-copy-parent.params'; import { TaskCopyApiParams } from '@modules/task/controller/dto/task-copy.params'; -import { TaskCopyParentParams } from '@modules/task/types'; -import { LessonEntity, Task } from '@shared/domain/entity'; +import { TaskCopyParentParams } from '@modules/task/types/task-copy-parent.params'; +import { LessonEntity } from '@shared/domain/entity/lesson.entity'; +import { Task } from '@shared/domain/entity/task.entity'; import { EntityId } from '@shared/domain/types'; +import { ColumnBoard } from '@modules/board/domain/colum-board.do'; import { CopyApiResponse } from '../dto/copy.response'; import { CopyStatus, CopyStatusEnum } from '../types/copy.types'; @@ -18,7 +20,12 @@ export class CopyMapper { if (copyStatus.copyEntity) { const copyEntity = copyStatus.copyEntity as LessonEntity | Task; dto.id = copyEntity.id; - dto.destinationCourseId = copyEntity.course?.id; + if (copyEntity instanceof LessonEntity || copyEntity instanceof Task) { + dto.destinationId = copyEntity.course?.id; + } + if (copyEntity instanceof ColumnBoard) { + dto.destinationId = copyEntity.context?.id; + } } if (copyStatus.status !== CopyStatusEnum.SUCCESS && copyStatus.elements) { dto.elements = copyStatus.elements diff --git a/apps/server/src/modules/files-storage-client/files-storage-client.module.ts b/apps/server/src/modules/files-storage-client/files-storage-client.module.ts index 0c6095fc206..185654114c7 100644 --- a/apps/server/src/modules/files-storage-client/files-storage-client.module.ts +++ b/apps/server/src/modules/files-storage-client/files-storage-client.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; // The files-storage-client should not know the copy-helper -import { CopyHelperModule } from '@modules/copy-helper'; +import { CopyHelperModule } from '@modules/copy-helper/copy-helper.module'; import { CqrsModule } from '@nestjs/cqrs'; import { CopyFilesService, FilesStorageClientAdapterService, FilesStorageProducer } from './service'; diff --git a/apps/server/src/modules/files-storage-client/service/copy-files.service.ts b/apps/server/src/modules/files-storage-client/service/copy-files.service.ts index 2ca2466f1a5..8003065f094 100644 --- a/apps/server/src/modules/files-storage-client/service/copy-files.service.ts +++ b/apps/server/src/modules/files-storage-client/service/copy-files.service.ts @@ -1,4 +1,5 @@ -import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { CopyHelperService } from '@modules/copy-helper/service/copy-helper.service'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper/types/copy.types'; import { StorageLocation } from '@modules/files-storage/interface'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index a8bdcbeba4c..c06158e4a38 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -20,7 +20,6 @@ import { UserRepo, } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { BoardNodeRepo } from '../board/repo'; import { COURSE_REPO } from './domain'; import { CommonCartridgeExportMapper } from './mapper/common-cartridge-export.mapper'; import { CommonCartridgeImportMapper } from './mapper/common-cartridge-import.mapper'; @@ -66,7 +65,6 @@ import { CommonCartridgeFileValidatorPipe } from './utils'; useClass: DashboardRepo, }, BoardCopyService, - BoardNodeRepo, CommonCartridgeExportService, CommonCartridgeFileValidatorPipe, CommonCartridgeImportService, diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts index 7fd621811db..0f82cd9f790 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts @@ -22,7 +22,9 @@ import { userFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; +import { CopyColumnBoardParams } from '@src/modules/board/service/internal'; import { columnBoardFactory } from '@src/modules/board/testing'; +import { StorageLocation } from '@src/modules/files-storage/interface'; import { ColumnBoardNodeRepo } from '../repo'; import { BoardCopyService } from './board-copy.service'; @@ -101,28 +103,48 @@ describe('board copy service', () => { it('should return copy type "board"', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); expect(status.type).toEqual(CopyElementType.BOARD); }); it('should set title copy status to "board"', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); expect(status.title).toEqual('board'); }); it('should set original entity in status', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); expect(status.originalEntity).toEqual(originalBoard); }); it('should create a copy', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); const board = status.copyEntity as LegacyBoard; expect(board.id).not.toEqual(originalBoard.id); }); @@ -130,7 +152,12 @@ describe('board copy service', () => { it('should set destination course of copy', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); const board = status.copyEntity as LegacyBoard; expect(board.course.id).toEqual(destinationCourse.id); }); @@ -158,7 +185,7 @@ describe('board copy service', () => { it('should call taskCopyService with original task', async () => { const { destinationCourse, originalBoard, user, originalTask } = setup(); - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); expect(taskCopyService.copyTask).toHaveBeenCalledWith({ originalTaskId: originalTask.id, destinationCourse, @@ -169,14 +196,19 @@ describe('board copy service', () => { it('should call copyHelperService', async () => { const { destinationCourse, originalBoard, user } = setup(); - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); expect(copyHelperService.deriveStatusFromElements).toHaveBeenCalledTimes(1); }); it('should add copy of task to board copy', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); const board = status.copyEntity as LegacyBoard; expect(board.getElements().length).toEqual(1); }); @@ -184,7 +216,12 @@ describe('board copy service', () => { it('should add status of copying task to board copy status', async () => { const { destinationCourse, originalBoard, user, originalTask } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); const taskStatus = status.elements?.find( (el) => el.type === CopyElementType.TASK && el.title === originalTask.name ); @@ -221,13 +258,18 @@ describe('board copy service', () => { user, }; - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); expect(lessonCopyService.copyLesson).toHaveBeenCalledWith(expected); }); it('should add lessonCopy to board copy', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); const board = status.copyEntity as LegacyBoard; expect(board.getElements().length).toEqual(1); @@ -235,7 +277,12 @@ describe('board copy service', () => { it('should add status of lessonCopy to board copy status', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); const lessonStatus = status.elements?.find((el) => el.type === CopyElementType.LESSON); expect(lessonStatus).toBeDefined(); @@ -268,22 +315,29 @@ describe('board copy service', () => { it('should call columnBoardCopyService with original columnBoard', async () => { const { destinationCourse, originalBoard, user, columnBoardTarget } = setup(); - const expected = { + const expected: CopyColumnBoardParams = { originalColumnBoardId: columnBoardTarget.id, - destinationExternalReference: { + targetExternalReference: { type: BoardExternalReferenceType.Course, id: destinationCourse.id, }, + sourceStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, userId: user.id, }; - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); expect(columnBoardService.copyColumnBoard).toHaveBeenCalledWith(expected); }); it('should add columnBoard copy to board copy', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + originalCourse: destinationCourse, + user, + destinationCourse, + }); const board = status.copyEntity as LegacyBoard; expect(board.getElements().length).toEqual(1); @@ -291,7 +345,12 @@ describe('board copy service', () => { it('should add status of columnBoard copy to board copy status', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + originalCourse: destinationCourse, + user, + destinationCourse, + }); const lessonStatus = status.elements?.find((el) => el.type === CopyElementType.COLUMNBOARD); expect(lessonStatus).toBeDefined(); @@ -365,14 +424,14 @@ describe('board copy service', () => { it('should trigger swapping ids for board', async () => { const { destinationCourse, originalBoard, user, columnBoardCopy } = setup(); - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); expect(columnBoardService.swapLinkedIds).toHaveBeenCalledWith(columnBoardCopy.id, expect.anything()); }); it('should pass task for swapping ids', async () => { const { destinationCourse, originalBoard, user, originalTask, taskCopy } = setup(); - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); const map = columnBoardService.swapLinkedIds.mock.calls[0][1]; expect(map.get(originalTask.id)).toEqual(taskCopy.id); @@ -380,7 +439,7 @@ describe('board copy service', () => { it('should pass lesson for swapping ids', async () => { const { destinationCourse, originalBoard, user, originalLesson, lessonCopy } = setup(); - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); const map = columnBoardService.swapLinkedIds.mock.calls[0][1]; expect(map.get(originalLesson.id)).toEqual(lessonCopy.id); @@ -388,7 +447,7 @@ describe('board copy service', () => { it('should pass course for swapping ids', async () => { const { originalCourse, destinationCourse, originalBoard, user } = setup(); - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); const map = columnBoardService.swapLinkedIds.mock.calls[0][1]; expect(map.get(originalCourse.id)).toEqual(destinationCourse.id); @@ -417,7 +476,7 @@ describe('board copy service', () => { it('should call deriveStatusFromElements', async () => { const { destinationCourse, originalBoard, user } = setup(); - await copyService.copyBoard({ originalBoard, user, destinationCourse }); + await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); expect(copyHelperService.deriveStatusFromElements).toHaveBeenCalled(); }); @@ -425,7 +484,12 @@ describe('board copy service', () => { it('should use returned value from deriveStatusFromElements', async () => { const { destinationCourse, originalBoard, user } = setup(); copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.PARTIAL); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); expect(status.status).toEqual(CopyStatusEnum.PARTIAL); }); @@ -458,7 +522,12 @@ describe('board copy service', () => { it('should skip boardelements that contain a corrupted reference', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); const board = status.copyEntity as LegacyBoard; expect(board.references).toHaveLength(0); @@ -489,7 +558,12 @@ describe('board copy service', () => { it('should return status fail', async () => { const { destinationCourse, originalBoard, user } = setup(); - const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const status = await copyService.copyBoard({ + originalBoard, + user, + originalCourse: destinationCourse, + destinationCourse, + }); expect(status.status).toEqual(CopyStatusEnum.FAIL); }); diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.ts b/apps/server/src/modules/learnroom/service/board-copy.service.ts index b77dd910e6f..2678f13d3e4 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.ts @@ -22,11 +22,13 @@ import { import { EntityId } from '@shared/domain/types'; import { LegacyBoardRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; +import { StorageLocation } from '@src/modules/files-storage/interface'; import { sortBy } from 'lodash'; import { ColumnBoardNodeRepo } from '../repo'; -type BoardCopyParams = { +export type BoardCopyParams = { originalBoard: LegacyBoard; + originalCourse: Course; destinationCourse: Course; user: User; }; @@ -45,10 +47,10 @@ export class BoardCopyService { ) {} async copyBoard(params: BoardCopyParams): Promise { - const { originalBoard, user, destinationCourse } = params; + const { originalBoard, user, originalCourse, destinationCourse } = params; const boardElements: LegacyBoardElement[] = originalBoard.getElements(); - const elements: CopyStatus[] = await this.copyBoardElements(boardElements, user, destinationCourse); + const elements: CopyStatus[] = await this.copyBoardElements(boardElements, user, originalCourse, destinationCourse); const references: LegacyBoardElement[] = await this.extractReferences(elements); @@ -82,6 +84,7 @@ export class BoardCopyService { private async copyBoardElements( boardElements: LegacyBoardElement[], user: User, + originalCourse: Course, destinationCourse: Course ): Promise { const promises: Promise<[number, CopyStatus]>[] = boardElements.map((element, pos) => { @@ -101,7 +104,10 @@ export class BoardCopyService { element.boardElementType === LegacyBoardElementType.ColumnBoard && element.target instanceof ColumnBoardNode ) { - return this.copyColumnBoard(element.target, user, destinationCourse).then((status) => [pos, status]); + return this.copyColumnBoard(element.target, user, originalCourse, destinationCourse).then((status) => [ + pos, + status, + ]); } /* istanbul ignore next */ @@ -135,15 +141,18 @@ export class BoardCopyService { private async copyColumnBoard( columnBoard: ColumnBoardNode, user: User, + originalCourse: Course, destinationCourse: Course ): Promise { return this.columnBoardService.copyColumnBoard({ originalColumnBoardId: columnBoard.id, - userId: user.id, - destinationExternalReference: { + targetExternalReference: { id: destinationCourse.id, type: BoardExternalReferenceType.Course, }, + sourceStorageLocationReference: { id: originalCourse.school.id, type: StorageLocation.SCHOOL }, + targetStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, + userId: user.id, }); } diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.ts b/apps/server/src/modules/learnroom/service/course-copy.service.ts index 5edb0937621..66da0757f29 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.ts @@ -70,7 +70,12 @@ export class CourseCopyService { ); } - const boardStatus = await this.boardCopyService.copyBoard({ originalBoard, destinationCourse: courseCopy, user }); + const boardStatus = await this.boardCopyService.copyBoard({ + originalBoard, + originalCourse, + destinationCourse: courseCopy, + user, + }); const finishedCourseCopy = await this.finishCourseCopying(courseCopy); const courseStatus = this.deriveCourseStatus(originalCourse, finishedCourseCopy, boardStatus); diff --git a/apps/server/src/modules/room-member/do/room-member.do.spec.ts b/apps/server/src/modules/room-member/do/room-member.do.spec.ts deleted file mode 100644 index d7ed3fb03ac..00000000000 --- a/apps/server/src/modules/room-member/do/room-member.do.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { EntityId } from '@shared/domain/types'; -import { roomMemberFactory } from '../testing'; -import { RoomMember, RoomMemberProps } from './room-member.do'; - -describe('RoomMember', () => { - let roomMember: RoomMember; - const roomMemberId: EntityId = 'roomMemberId'; - const roomMemberProps: RoomMemberProps = { - id: roomMemberId, - roomId: 'roomId', - userGroupId: 'userGroupId', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }; - - beforeEach(() => { - roomMember = new RoomMember(roomMemberProps); - }); - - it('should props without domainObject', () => { - const mockDomainObject = roomMemberFactory.build(); - // this tests the hotfix for the mikro-orm issue - // eslint-disable-next-line @typescript-eslint/dot-notation - roomMember['domainObject'] = mockDomainObject; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const { domainObject, ...props } = roomMember.getProps(); - - expect(domainObject).toEqual(undefined); - expect(props).toEqual(roomMemberProps); - }); - - it('should get roomId', () => { - expect(roomMember.roomId).toEqual(roomMemberProps.roomId); - }); - - it('should get userGroupId', () => { - expect(roomMember.userGroupId).toEqual(roomMemberProps.userGroupId); - }); -}); diff --git a/apps/server/src/modules/room-member/index.ts b/apps/server/src/modules/room-member/index.ts deleted file mode 100644 index 772b42aa05a..00000000000 --- a/apps/server/src/modules/room-member/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RoomMemberEntity } from './repo/entity'; -import { RoomMemberRepo } from './repo/room-member.repo'; -import { RoomMemberService } from './service/room-member.service'; - -export * from './do/room-member.do'; -export * from './room-member.module'; -export { RoomMemberEntity, RoomMemberRepo, RoomMemberService }; - -export { UserWithRoomRoles, RoomMemberAuthorizable } from './do/room-member-authorizable.do'; diff --git a/apps/server/src/modules/room-member/repo/entity/index.ts b/apps/server/src/modules/room-member/repo/entity/index.ts deleted file mode 100644 index 845b87253da..00000000000 --- a/apps/server/src/modules/room-member/repo/entity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './room-member.entity'; diff --git a/apps/server/src/modules/room-member/repo/entity/room-member.entity.ts b/apps/server/src/modules/room-member/repo/entity/room-member.entity.ts deleted file mode 100644 index 95cbf802452..00000000000 --- a/apps/server/src/modules/room-member/repo/entity/room-member.entity.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Entity, Property, Unique } from '@mikro-orm/core'; -import { AuthorizableObject } from '@shared/domain/domain-object'; -import { ObjectIdType } from '@shared/repo/types/object-id.type'; -import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { EntityId } from '@shared/domain/types'; -import { RoomMember } from '../../do/room-member.do'; - -export interface RoomMemberEntityProps extends AuthorizableObject { - id: EntityId; - roomId: EntityId; - userGroupId: EntityId; - createdAt: Date; - updatedAt: Date; -} - -@Entity({ tableName: 'room-members' }) -@Unique({ properties: ['roomId', 'userGroupId'] }) -export class RoomMemberEntity extends BaseEntityWithTimestamps implements RoomMemberEntityProps { - @Property() - @Unique() - @Property({ type: ObjectIdType }) - roomId!: EntityId; - - @Property({ type: ObjectIdType }) - userGroupId!: EntityId; - - @Property({ persist: false }) - domainObject: RoomMember | undefined; -} diff --git a/apps/server/src/modules/room-member/repo/room-member-domain.mapper.spec.ts b/apps/server/src/modules/room-member/repo/room-member-domain.mapper.spec.ts deleted file mode 100644 index d2629ebef65..00000000000 --- a/apps/server/src/modules/room-member/repo/room-member-domain.mapper.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { RoomMember, RoomMemberProps } from '../do/room-member.do'; -import { roomMemberEntityFactory } from '../testing'; -import { RoomMemberEntity } from './entity'; -import { RoomMemberDomainMapper } from './room-member-domain.mapper'; - -describe('RoomMemberDomainMapper', () => { - describe('mapEntityToDo', () => { - it('should correctly map roomMemberEntity to RoomMember domain object', () => { - const roomMemberEntity = { - id: '1', - } as RoomMemberEntity; - - const result = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntity); - - expect(result).toBeInstanceOf(RoomMember); - expect(result.getProps()).toEqual({ - id: '1', - }); - }); - - it('should return existing domainObject if present, regardless of entity properties', () => { - const existingRoomMember = new RoomMember({ - id: '1', - roomId: 'r1', - userGroupId: 'ug1', - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - }); - - const roomMemberEntity = { - id: '1', - domainObject: existingRoomMember, - } as RoomMemberEntity; - - const result = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntity); - - expect(result).toBe(existingRoomMember); - expect(result).toBeInstanceOf(RoomMember); - expect(result.getProps()).toEqual({ - id: '1', - roomId: 'r1', - userGroupId: 'ug1', - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - }); - expect(result.getProps().id).toBe('1'); - expect(result.getProps().id).toBe(roomMemberEntity.id); - }); - - it('should wrap the actual entity reference in the domain object', () => { - const roomMemberEntity = { - id: '1', - } as RoomMemberEntity; - - const result = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntity); - // @ts-expect-error check necessary - const { props } = result; - - expect(props === roomMemberEntity).toBe(true); - }); - }); - - describe('mapDoToEntity', () => { - describe('when domain object props are instanceof roomMemberEntity', () => { - it('should return the entity', () => { - const roomMemberEntity = roomMemberEntityFactory.build(); - const roomMember = new RoomMember(roomMemberEntity); - - const result = RoomMemberDomainMapper.mapDoToEntity(roomMember); - - expect(result).toBe(roomMemberEntity); - }); - }); - - describe('when domain object props are not instanceof roomMemberEntity', () => { - it('should convert them to an entity and return it', () => { - const roomMemberEntity: RoomMemberProps = { - id: '66d581c3ef74c548a4efea1d', - roomId: '66d581c3ef74c548a4efea1a', - userGroupId: '66d581c3ef74c548a4efea1b', - createdAt: new Date('2024-10-1'), - updatedAt: new Date('2024-10-1'), - }; - const room = new RoomMember(roomMemberEntity); - - const result = RoomMemberDomainMapper.mapDoToEntity(room); - - expect(result).toBeInstanceOf(RoomMemberEntity); - expect(result).toMatchObject(roomMemberEntity); - }); - }); - }); -}); diff --git a/apps/server/src/modules/room-member/repo/room-member-domain.mapper.ts b/apps/server/src/modules/room-member/repo/room-member-domain.mapper.ts deleted file mode 100644 index 73163a07769..00000000000 --- a/apps/server/src/modules/room-member/repo/room-member-domain.mapper.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { RoomMember } from '../do/room-member.do'; -import { RoomMemberEntity } from './entity'; - -export class RoomMemberDomainMapper { - static mapEntityToDo(roomMemberEntity: RoomMemberEntity): RoomMember { - // check identity map reference - if (roomMemberEntity.domainObject) { - return roomMemberEntity.domainObject; - } - - const roomMember = new RoomMember(roomMemberEntity); - - // attach to identity map - roomMemberEntity.domainObject = roomMember; - - return roomMember; - } - - static mapDoToEntity(roomMember: RoomMember): RoomMemberEntity { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const { props } = roomMember; - - if (!(props instanceof RoomMemberEntity)) { - const entity = new RoomMemberEntity(); - Object.assign(entity, props); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - roomMember.props = entity; - - return entity; - } - - return props; - } -} diff --git a/apps/server/src/modules/room-member/repo/room-member.repo.spec.ts b/apps/server/src/modules/room-member/repo/room-member.repo.spec.ts deleted file mode 100644 index 3cf9364019e..00000000000 --- a/apps/server/src/modules/room-member/repo/room-member.repo.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { NotFoundError } from '@mikro-orm/core'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { cleanupCollections } from '@shared/testing'; -import { RoomMember } from '../do/room-member.do'; -import { roomMemberEntityFactory, roomMemberFactory } from '../testing'; -import { RoomMemberEntity } from './entity'; -import { RoomMemberRepo } from './room-member.repo'; - -describe('RoomMemberRepo', () => { - let module: TestingModule; - let repo: RoomMemberRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [RoomMemberRepo], - }).compile(); - - repo = module.get(RoomMemberRepo); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - }); - - describe('findByRoomId', () => { - const setup = async () => { - const roomMemberEntity = roomMemberEntityFactory.buildWithId(); - await em.persistAndFlush([roomMemberEntity]); - em.clear(); - - return { roomMemberEntity }; - }; - - it('should find room member by roomId', async () => { - const { roomMemberEntity } = await setup(); - - const roomMember = await repo.findByRoomId(roomMemberEntity.roomId); - - expect(roomMember).toBeDefined(); - expect(roomMember?.getProps()).toEqual(roomMemberEntity); - }); - }); - - describe('findByRoomIds', () => { - const setup = async () => { - const roomId1 = new ObjectId().toHexString(); - const roomId2 = new ObjectId().toHexString(); - - const roomMemberEntities = [ - roomMemberEntityFactory.buildWithId({ roomId: roomId1 }), - roomMemberEntityFactory.buildWithId({ roomId: roomId1 }), - roomMemberEntityFactory.buildWithId({ roomId: roomId2 }), - ]; - - await em.persistAndFlush(roomMemberEntities); - em.clear(); - - return { roomMemberEntities, roomId1, roomId2 }; - }; - - it('should find room member by roomIds', async () => { - const { roomId1, roomId2 } = await setup(); - - const roomMembers = await repo.findByRoomIds([roomId1, roomId2]); - - expect(roomMembers).toHaveLength(3); - }); - }); - - describe('findByGroupId', () => { - const setup = async () => { - const groupId = new ObjectId().toHexString(); - const roomMemberEntities = [ - roomMemberEntityFactory.build({ userGroupId: groupId }), - roomMemberEntityFactory.build({ userGroupId: groupId }), - roomMemberEntityFactory.build({ userGroupId: new ObjectId().toHexString() }), - ]; - - await em.persistAndFlush(roomMemberEntities); - em.clear(); - - return { roomMemberEntities, groupId }; - }; - - it('should find room members by groupId', async () => { - const { groupId } = await setup(); - - const roomMembers = await repo.findByGroupId(groupId); - - expect(roomMembers).toHaveLength(2); - }); - }); - - describe('save', () => { - const setup = () => { - const roomMembers = roomMemberFactory.buildList(3); - return { roomMembers }; - }; - - it('should be able to persist a single room member', async () => { - const { roomMembers } = setup(); - - await repo.save(roomMembers[0]); - const result = await em.findOneOrFail(RoomMemberEntity, roomMembers[0].id); - - expect(roomMembers[0].getProps()).toMatchObject(result); - }); - - it('should be able to persist many room members', async () => { - const { roomMembers } = setup(); - - await repo.save(roomMembers); - const result = await em.find(RoomMemberEntity, { id: { $in: roomMembers.map((r) => r.id) } }); - - expect(result.length).toBe(roomMembers.length); - }); - }); - - describe('delete', () => { - const setup = async () => { - const roomMemberEntities = roomMemberEntityFactory.buildListWithId(3); - await em.persistAndFlush(roomMemberEntities); - const roomMembers = roomMemberEntities.map((entity) => new RoomMember(entity)); - em.clear(); - - return { roomMembers }; - }; - - it('should be able to delete a single room member', async () => { - const { roomMembers } = await setup(); - - await repo.delete(roomMembers[0]); - - await expect(em.findOneOrFail(RoomMemberEntity, roomMembers[0].id)).rejects.toThrow(NotFoundError); - }); - - it('should be able to delete many rooms', async () => { - const { roomMembers } = await setup(); - - await repo.delete(roomMembers); - - const remainingCount = await em.count(RoomMemberEntity); - expect(remainingCount).toBe(0); - }); - }); -}); diff --git a/apps/server/src/modules/room-member/repo/room-member.repo.ts b/apps/server/src/modules/room-member/repo/room-member.repo.ts deleted file mode 100644 index 022383fffaf..00000000000 --- a/apps/server/src/modules/room-member/repo/room-member.repo.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Utils } from '@mikro-orm/core'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; -import { RoomMember } from '../do/room-member.do'; -import { RoomMemberEntity } from './entity'; -import { RoomMemberDomainMapper } from './room-member-domain.mapper'; - -@Injectable() -export class RoomMemberRepo { - constructor(private readonly em: EntityManager) {} - - async findByRoomId(roomId: EntityId): Promise { - const roomMemberEntities = await this.em.findOne(RoomMemberEntity, { roomId }); - if (!roomMemberEntities) return null; - - const roomMembers = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntities); - - return roomMembers; - } - - async findByRoomIds(roomIds: EntityId[]): Promise { - const entities = await this.em.find(RoomMemberEntity, { roomId: { $in: roomIds } }); - const roomMembers = entities.map((entity) => RoomMemberDomainMapper.mapEntityToDo(entity)); - - return roomMembers; - } - - async findByGroupId(groupId: EntityId): Promise { - const entities = await this.em.find(RoomMemberEntity, { userGroupId: groupId }); - const roomMembers = entities.map((entity) => RoomMemberDomainMapper.mapEntityToDo(entity)); - - return roomMembers; - } - - async findByGroupIds(groupIds: EntityId[]): Promise { - const entities = await this.em.find(RoomMemberEntity, { userGroupId: { $in: groupIds } }); - const roomMembers = entities.map((entity) => RoomMemberDomainMapper.mapEntityToDo(entity)); - - return roomMembers; - } - - async save(roomMember: RoomMember | RoomMember[]): Promise { - const roomMembers = Utils.asArray(roomMember); - - roomMembers.forEach((member) => { - const entity = RoomMemberDomainMapper.mapDoToEntity(member); - this.em.persist(entity); - }); - - await this.em.flush(); - } - - async delete(roomMember: RoomMember | RoomMember[]): Promise { - const roomMembers = Utils.asArray(roomMember); - - roomMembers.forEach((member) => { - const entity = RoomMemberDomainMapper.mapDoToEntity(member); - this.em.remove(entity); - }); - - await this.em.flush(); - } -} diff --git a/apps/server/src/modules/room-member/room-member.module.ts b/apps/server/src/modules/room-member/room-member.module.ts deleted file mode 100644 index b07ace89160..00000000000 --- a/apps/server/src/modules/room-member/room-member.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { GroupModule } from '@modules/group'; -import { Module } from '@nestjs/common'; -import { CqrsModule } from '@nestjs/cqrs'; -import { AuthorizationModule } from '../authorization'; -import { RoleModule } from '../role'; -import { RoomMemberRule } from './authorization/room-member.rule'; -import { RoomMemberRepo } from './repo/room-member.repo'; -import { RoomMemberService } from './service/room-member.service'; - -@Module({ - imports: [AuthorizationModule, CqrsModule, GroupModule, RoleModule], - providers: [RoomMemberService, RoomMemberRepo, RoomMemberRule], - exports: [RoomMemberService], -}) -export class RoomMemberModule {} diff --git a/apps/server/src/modules/room-member/service/room-member.service.ts b/apps/server/src/modules/room-member/service/room-member.service.ts deleted file mode 100644 index 4fdd5933e16..00000000000 --- a/apps/server/src/modules/room-member/service/room-member.service.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { RoleName } from '@shared/domain/interface'; -import { EntityId } from '@shared/domain/types'; -import { Group, GroupService, GroupTypes } from '@src/modules/group'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { RoleDto, RoleService } from '@src/modules/role'; -import { RoomMember } from '../do/room-member.do'; -import { RoomMemberRepo } from '../repo/room-member.repo'; -import { RoomMemberAuthorizable, UserWithRoomRoles } from '../do/room-member-authorizable.do'; - -@Injectable() -export class RoomMemberService { - constructor( - private readonly groupService: GroupService, - private readonly roomMembersRepo: RoomMemberRepo, - private readonly roleService: RoleService - ) {} - - private async createNewRoomMember( - roomId: EntityId, - userId: EntityId, - roleName: RoleName.ROOM_EDITOR | RoleName.ROOM_VIEWER, - schoolId?: EntityId - ) { - const group = await this.groupService.createGroup(`Room Members for Room ${roomId}`, GroupTypes.ROOM, schoolId); - await this.groupService.addUsersToGroup(group.id, [{ userId, roleName }]); - - const roomMember = new RoomMember({ - id: new ObjectId().toHexString(), - roomId, - userGroupId: group.id, - createdAt: new Date(), - updatedAt: new Date(), - }); - - await this.roomMembersRepo.save(roomMember); - - return roomMember; - } - - private buildRoomMemberAuthorizable(roomId: EntityId, group: Group, roleSet: RoleDto[]): RoomMemberAuthorizable { - const members = group.users.map((groupUser): UserWithRoomRoles => { - const roleDto = roleSet.find((role) => role.id === groupUser.roleId); - if (roleDto === undefined) throw new BadRequestException('Role not found'); - return { - roles: [roleDto], - userId: groupUser.userId, - }; - }); - - const roomMemberAuthorizable = new RoomMemberAuthorizable(roomId, members); - - return roomMemberAuthorizable; - } - - public async deleteRoomMember(roomId: EntityId) { - const roomMember = await this.roomMembersRepo.findByRoomId(roomId); - if (roomMember === null) return; - - const group = await this.groupService.findById(roomMember.userGroupId); - await this.groupService.delete(group); - await this.roomMembersRepo.delete(roomMember); - } - - public async addMembersToRoom( - roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoleName.ROOM_EDITOR | RoleName.ROOM_VIEWER }>, - schoolId?: EntityId - ): Promise { - const roomMember = await this.roomMembersRepo.findByRoomId(roomId); - if (roomMember === null) { - const firstUser = userIdsAndRoles.pop(); - if (firstUser === undefined) { - throw new BadRequestException('No user provided'); - } - const newRoomMember = await this.createNewRoomMember(roomId, firstUser.userId, firstUser.roleName, schoolId); - return newRoomMember.id; - } - - await this.groupService.addUsersToGroup(roomMember.userGroupId, userIdsAndRoles); - - return roomMember.id; - } - - public async removeMembersFromRoom(roomId: EntityId, userIds: EntityId[]): Promise { - const roomMember = await this.roomMembersRepo.findByRoomId(roomId); - if (roomMember === null) { - throw new BadRequestException('Room member not found'); - } - - const group = await this.groupService.findById(roomMember.userGroupId); - await this.groupService.removeUsersFromGroup(group.id, userIds); - } - - public async getRoomMemberAuthorizablesByUserId(userId: EntityId): Promise { - const groupPage = await this.groupService.findGroups({ userId, groupTypes: [GroupTypes.ROOM] }); - const groupIds = groupPage.data.map((group) => group.id); - const roomMembers = await this.roomMembersRepo.findByGroupIds(groupIds); - const roleIds = groupPage.data.flatMap((group) => group.users.map((groupUser) => groupUser.roleId)); - const roleSet = await this.roleService.findByIds(roleIds); - const roomMemberAuthorizables = roomMembers - .map((item) => { - const group = groupPage.data.find((g) => g.id === item.userGroupId); - if (!group) return null; - return this.buildRoomMemberAuthorizable(item.roomId, group, roleSet); - }) - .filter((item): item is RoomMemberAuthorizable => item !== null); - - return roomMemberAuthorizables; - } - - public async getRoomMemberAuthorizable(roomId: EntityId): Promise { - const roomMember = await this.roomMembersRepo.findByRoomId(roomId); - if (roomMember === null) { - return new RoomMemberAuthorizable(roomId, []); - } - const group = await this.groupService.findById(roomMember.userGroupId); - const roleSet = await this.roleService.findByIds(group.users.map((groupUser) => groupUser.roleId)); - - const members = group.users.map((groupUser): UserWithRoomRoles => { - const roleDto = roleSet.find((role) => role.id === groupUser.roleId); - if (roleDto === undefined) throw new BadRequestException('Role not found'); - return { - roles: [roleDto], - userId: groupUser.userId, - }; - }); - - const roomMemberAuthorizable = new RoomMemberAuthorizable(roomId, members); - - return roomMemberAuthorizable; - } -} diff --git a/apps/server/src/modules/room-member/testing/index.ts b/apps/server/src/modules/room-member/testing/index.ts deleted file mode 100644 index 7f1a2950ff5..00000000000 --- a/apps/server/src/modules/room-member/testing/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './room-member-entity.factory'; -export * from './room-member.factory'; diff --git a/apps/server/src/modules/room-member/authorization/room-member.rule.spec.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts similarity index 60% rename from apps/server/src/modules/room-member/authorization/room-member.rule.spec.ts rename to apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts index 1ca26adb941..6946c83aa8d 100644 --- a/apps/server/src/modules/room-member/authorization/room-member.rule.spec.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts @@ -1,22 +1,22 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { roleDtoFactory, setupEntities, userFactory } from '@shared/testing'; -import { Action, AuthorizationHelper, AuthorizationInjectionService } from '@src/modules/authorization'; -import { RoomMemberAuthorizable } from '../do/room-member-authorizable.do'; -import { RoomMemberRule } from './room-member.rule'; +import { Action, AuthorizationHelper, AuthorizationInjectionService } from '@modules/authorization'; +import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do'; +import { RoomMembershipRule } from './room-membership.rule'; -describe(RoomMemberRule.name, () => { - let service: RoomMemberRule; +describe(RoomMembershipRule.name, () => { + let service: RoomMembershipRule; let injectionService: AuthorizationInjectionService; beforeAll(async () => { await setupEntities(); const module: TestingModule = await Test.createTestingModule({ - providers: [RoomMemberRule, AuthorizationHelper, AuthorizationInjectionService], + providers: [RoomMembershipRule, AuthorizationHelper, AuthorizationInjectionService], }).compile(); - service = await module.get(RoomMemberRule); + service = await module.get(RoomMembershipRule); injectionService = await module.get(AuthorizationInjectionService); }); @@ -30,14 +30,14 @@ describe(RoomMemberRule.name, () => { describe('when entity is applicable', () => { const setup = () => { const user = userFactory.buildWithId(); - const roomMemberAuthorizable = new RoomMemberAuthorizable('', []); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', []); - return { user, roomMemberAuthorizable }; + return { user, roomMembershipAuthorizable }; }; it('should return true', () => { - const { user, roomMemberAuthorizable } = setup(); - const result = service.isApplicable(user, roomMemberAuthorizable); + const { user, roomMembershipAuthorizable } = setup(); + const result = service.isApplicable(user, roomMembershipAuthorizable); expect(result).toStrictEqual(true); }); @@ -64,15 +64,15 @@ describe(RoomMemberRule.name, () => { const setup = () => { const user = userFactory.buildWithId(); const roleDto = roleDtoFactory.build({ permissions: [Permission.ROOM_VIEW] }); - const roomMemberAuthorizable = new RoomMemberAuthorizable('', [{ roles: [roleDto], userId: user.id }]); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', [{ roles: [roleDto], userId: user.id }]); - return { user, roomMemberAuthorizable }; + return { user, roomMembershipAuthorizable }; }; it('should return "true" for read action', () => { - const { user, roomMemberAuthorizable } = setup(); + const { user, roomMembershipAuthorizable } = setup(); - const res = service.hasPermission(user, roomMemberAuthorizable, { + const res = service.hasPermission(user, roomMembershipAuthorizable, { action: Action.read, requiredPermissions: [], }); @@ -81,9 +81,9 @@ describe(RoomMemberRule.name, () => { }); it('should return "false" for write action', () => { - const { user, roomMemberAuthorizable } = setup(); + const { user, roomMembershipAuthorizable } = setup(); - const res = service.hasPermission(user, roomMemberAuthorizable, { + const res = service.hasPermission(user, roomMembershipAuthorizable, { action: Action.write, requiredPermissions: [], }); @@ -95,15 +95,15 @@ describe(RoomMemberRule.name, () => { describe('when user is not member of room', () => { const setup = () => { const user = userFactory.buildWithId(); - const roomMemberAuthorizable = new RoomMemberAuthorizable('', []); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', []); - return { user, roomMemberAuthorizable }; + return { user, roomMembershipAuthorizable }; }; it('should return "false" for read action', () => { - const { user, roomMemberAuthorizable } = setup(); + const { user, roomMembershipAuthorizable } = setup(); - const res = service.hasPermission(user, roomMemberAuthorizable, { + const res = service.hasPermission(user, roomMembershipAuthorizable, { action: Action.read, requiredPermissions: [], }); @@ -112,9 +112,9 @@ describe(RoomMemberRule.name, () => { }); it('should return "false" for write action', () => { - const { user, roomMemberAuthorizable } = setup(); + const { user, roomMembershipAuthorizable } = setup(); - const res = service.hasPermission(user, roomMemberAuthorizable, { + const res = service.hasPermission(user, roomMembershipAuthorizable, { action: Action.write, requiredPermissions: [], }); diff --git a/apps/server/src/modules/room-member/authorization/room-member.rule.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts similarity index 69% rename from apps/server/src/modules/room-member/authorization/room-member.rule.ts rename to apps/server/src/modules/room-membership/authorization/room-membership.rule.ts index 7a98c93a215..cfcd11c33af 100644 --- a/apps/server/src/modules/room-member/authorization/room-member.rule.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts @@ -1,22 +1,22 @@ import { Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { AuthorizationInjectionService, Action, AuthorizationContext, Rule } from '@src/modules/authorization'; -import { RoomMemberAuthorizable } from '../do/room-member-authorizable.do'; +import { AuthorizationInjectionService, Action, AuthorizationContext, Rule } from '@modules/authorization'; +import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do'; @Injectable() -export class RoomMemberRule implements Rule { +export class RoomMembershipRule implements Rule { constructor(private readonly authorisationInjectionService: AuthorizationInjectionService) { this.authorisationInjectionService.injectAuthorizationRule(this); } public isApplicable(user: User, object: unknown): boolean { - const isMatched = object instanceof RoomMemberAuthorizable; + const isMatched = object instanceof RoomMembershipAuthorizable; return isMatched; } - public hasPermission(user: User, object: RoomMemberAuthorizable, context: AuthorizationContext): boolean { + public hasPermission(user: User, object: RoomMembershipAuthorizable, context: AuthorizationContext): boolean { const { action } = context; const permissionsThisUserHas = object.members .filter((member) => member.userId === user.id) diff --git a/apps/server/src/modules/room-member/do/room-member-authorizable.do.ts b/apps/server/src/modules/room-membership/do/room-membership-authorizable.do.ts similarity index 79% rename from apps/server/src/modules/room-member/do/room-member-authorizable.do.ts rename to apps/server/src/modules/room-membership/do/room-membership-authorizable.do.ts index 1728d0045b9..61821fa4b82 100644 --- a/apps/server/src/modules/room-member/do/room-member-authorizable.do.ts +++ b/apps/server/src/modules/room-membership/do/room-membership-authorizable.do.ts @@ -1,13 +1,13 @@ import { AuthorizableObject } from '@shared/domain/domain-object'; import { EntityId } from '@shared/domain/types'; -import { RoleDto } from '@src/modules/role'; +import { RoleDto } from '@modules/role'; export type UserWithRoomRoles = { roles: RoleDto[]; userId: EntityId; }; -export class RoomMemberAuthorizable implements AuthorizableObject { +export class RoomMembershipAuthorizable implements AuthorizableObject { public readonly id: EntityId = ''; public readonly roomId: EntityId; diff --git a/apps/server/src/modules/room-membership/do/room-membership.do.spec.ts b/apps/server/src/modules/room-membership/do/room-membership.do.spec.ts new file mode 100644 index 00000000000..5033cdc33b4 --- /dev/null +++ b/apps/server/src/modules/room-membership/do/room-membership.do.spec.ts @@ -0,0 +1,47 @@ +import { EntityId } from '@shared/domain/types'; +import { roomMembershipFactory } from '../testing'; +import { RoomMembership, RoomMembershipProps } from './room-membership.do'; + +describe('RoomMembership', () => { + let roomMembership: RoomMembership; + const roomMemberId: EntityId = 'roomMemberId'; + const roomMembershipProps: RoomMembershipProps = { + id: roomMemberId, + roomId: 'roomId', + userGroupId: 'userGroupId', + schoolId: 'schoolId', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + beforeEach(() => { + roomMembership = new RoomMembership(roomMembershipProps); + }); + + it('should props without domainObject', () => { + const mockDomainObject = roomMembershipFactory.build(); + // this tests the hotfix for the mikro-orm issue + // eslint-disable-next-line @typescript-eslint/dot-notation + roomMembership['domainObject'] = mockDomainObject; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { domainObject, ...props } = roomMembership.getProps(); + + expect(domainObject).toEqual(undefined); + expect(props).toEqual(roomMembershipProps); + }); + + it('should get roomId', () => { + expect(roomMembership.roomId).toEqual(roomMembershipProps.roomId); + }); + + it('should get userGroupId', () => { + expect(roomMembership.userGroupId).toEqual(roomMembershipProps.userGroupId); + }); + + it('should get schoolId', () => { + expect(roomMembership.schoolId).toEqual(roomMembershipProps.schoolId); + }); +}); diff --git a/apps/server/src/modules/room-member/do/room-member.do.ts b/apps/server/src/modules/room-membership/do/room-membership.do.ts similarity index 69% rename from apps/server/src/modules/room-member/do/room-member.do.ts rename to apps/server/src/modules/room-membership/do/room-membership.do.ts index 8aeebfffbda..99062d69545 100644 --- a/apps/server/src/modules/room-member/do/room-member.do.ts +++ b/apps/server/src/modules/room-membership/do/room-membership.do.ts @@ -1,20 +1,21 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { EntityId } from '@shared/domain/types'; -export interface RoomMemberProps extends AuthorizableObject { +export interface RoomMembershipProps extends AuthorizableObject { id: EntityId; roomId: EntityId; userGroupId: EntityId; + schoolId: EntityId; createdAt: Date; updatedAt: Date; } -export class RoomMember extends DomainObject { - public constructor(props: RoomMemberProps) { +export class RoomMembership extends DomainObject { + public constructor(props: RoomMembershipProps) { super(props); } - public getProps(): RoomMemberProps { + public getProps(): RoomMembershipProps { // Note: Propagated hotfix. Will be resolved with mikro-orm update. Look at the comment in board-node.do.ts. // eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -31,4 +32,8 @@ export class RoomMember extends DomainObject { public get userGroupId(): EntityId { return this.props.userGroupId; } + + public get schoolId(): EntityId { + return this.props.schoolId; + } } diff --git a/apps/server/src/modules/room-membership/index.ts b/apps/server/src/modules/room-membership/index.ts new file mode 100644 index 00000000000..be6ccd4b3ac --- /dev/null +++ b/apps/server/src/modules/room-membership/index.ts @@ -0,0 +1,9 @@ +import { RoomMembershipEntity } from './repo/entity'; +import { RoomMembershipRepo } from './repo/room-membership.repo'; +import { RoomMembershipService } from './service/room-membership.service'; + +export * from './do/room-membership.do'; +export * from './room-membership.module'; +export { RoomMembershipEntity, RoomMembershipRepo, RoomMembershipService }; + +export { UserWithRoomRoles, RoomMembershipAuthorizable } from './do/room-membership-authorizable.do'; diff --git a/apps/server/src/modules/room-membership/repo/entity/index.ts b/apps/server/src/modules/room-membership/repo/entity/index.ts new file mode 100644 index 00000000000..43e58e0db14 --- /dev/null +++ b/apps/server/src/modules/room-membership/repo/entity/index.ts @@ -0,0 +1 @@ +export * from './room-membership.entity'; diff --git a/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts b/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts new file mode 100644 index 00000000000..eafbfd3aeab --- /dev/null +++ b/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts @@ -0,0 +1,22 @@ +import { Entity, Property, Unique } from '@mikro-orm/core'; +import { ObjectIdType } from '@shared/repo/types/object-id.type'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain/types'; +import { RoomMembership, RoomMembershipProps } from '../../do/room-membership.do'; + +@Entity({ tableName: 'room-memberships' }) +@Unique({ properties: ['roomId', 'userGroupId'] }) +export class RoomMembershipEntity extends BaseEntityWithTimestamps implements RoomMembershipProps { + @Unique() + @Property({ type: ObjectIdType, fieldName: 'room' }) + roomId!: EntityId; + + @Property({ type: ObjectIdType, fieldName: 'userGroup' }) + userGroupId!: EntityId; + + @Property({ type: ObjectIdType, fieldName: 'school' }) + schoolId!: EntityId; + + @Property({ persist: false }) + domainObject: RoomMembership | undefined; +} diff --git a/apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.spec.ts b/apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.spec.ts new file mode 100644 index 00000000000..a95d86fdb73 --- /dev/null +++ b/apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.spec.ts @@ -0,0 +1,96 @@ +import { RoomMembership, RoomMembershipProps } from '../do/room-membership.do'; +import { roomMembershipEntityFactory } from '../testing'; +import { RoomMembershipEntity } from './entity'; +import { RoomMembershipDomainMapper } from './room-membership-domain.mapper'; + +describe('RoomMembershipDomainMapper', () => { + describe('mapEntityToDo', () => { + it('should correctly map roomMembershipEntity to RoomMembership domain object', () => { + const roomMembershipEntity = { + id: '1', + } as RoomMembershipEntity; + + const result = RoomMembershipDomainMapper.mapEntityToDo(roomMembershipEntity); + + expect(result).toBeInstanceOf(RoomMembership); + expect(result.getProps()).toEqual({ + id: '1', + }); + }); + + it('should return existing domainObject if present, regardless of entity properties', () => { + const existingRoomMembership = new RoomMembership({ + id: '1', + roomId: 'r1', + userGroupId: 'ug1', + schoolId: 's1', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + }); + + const roomMembershipEntity = { + id: '1', + domainObject: existingRoomMembership, + } as RoomMembershipEntity; + + const result = RoomMembershipDomainMapper.mapEntityToDo(roomMembershipEntity); + + expect(result).toBe(existingRoomMembership); + expect(result).toBeInstanceOf(RoomMembership); + expect(result.getProps()).toEqual({ + id: '1', + roomId: 'r1', + userGroupId: 'ug1', + schoolId: 's1', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + }); + expect(result.getProps().id).toBe('1'); + expect(result.getProps().id).toBe(roomMembershipEntity.id); + }); + + it('should wrap the actual entity reference in the domain object', () => { + const roomMembershipEntity = { + id: '1', + } as RoomMembershipEntity; + + const result = RoomMembershipDomainMapper.mapEntityToDo(roomMembershipEntity); + // @ts-expect-error check necessary + const { props } = result; + + expect(props === roomMembershipEntity).toBe(true); + }); + }); + + describe('mapDoToEntity', () => { + describe('when domain object props are instanceof roomMembershipEntity', () => { + it('should return the entity', () => { + const roomMembershipEntity = roomMembershipEntityFactory.build(); + const roomMembership = new RoomMembership(roomMembershipEntity); + + const result = RoomMembershipDomainMapper.mapDoToEntity(roomMembership); + + expect(result).toBe(roomMembershipEntity); + }); + }); + + describe('when domain object props are not instanceof roomMembershipEntity', () => { + it('should convert them to an entity and return it', () => { + const roomMembershipEntity: RoomMembershipProps = { + id: '66d581c3ef74c548a4efea1d', + roomId: '66d581c3ef74c548a4efea1a', + userGroupId: '66d581c3ef74c548a4efea1b', + schoolId: '66d581c3ef74c548a4efea1c', + createdAt: new Date('2024-10-1'), + updatedAt: new Date('2024-10-1'), + }; + const room = new RoomMembership(roomMembershipEntity); + + const result = RoomMembershipDomainMapper.mapDoToEntity(room); + + expect(result).toBeInstanceOf(RoomMembershipEntity); + expect(result).toMatchObject(roomMembershipEntity); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.ts b/apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.ts new file mode 100644 index 00000000000..3538822a17a --- /dev/null +++ b/apps/server/src/modules/room-membership/repo/room-membership-domain.mapper.ts @@ -0,0 +1,37 @@ +import { RoomMembership } from '../do/room-membership.do'; +import { RoomMembershipEntity } from './entity'; + +export class RoomMembershipDomainMapper { + static mapEntityToDo(roomMembershipEntity: RoomMembershipEntity): RoomMembership { + // check identity map reference + if (roomMembershipEntity.domainObject) { + return roomMembershipEntity.domainObject; + } + + const roomMembership = new RoomMembership(roomMembershipEntity); + + // attach to identity map + roomMembershipEntity.domainObject = roomMembership; + + return roomMembership; + } + + static mapDoToEntity(roomMembership: RoomMembership): RoomMembershipEntity { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { props } = roomMembership; + + if (!(props instanceof RoomMembershipEntity)) { + const entity = new RoomMembershipEntity(); + Object.assign(entity, props); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + roomMembership.props = entity; + + return entity; + } + + return props; + } +} diff --git a/apps/server/src/modules/room-membership/repo/room-membership.repo.spec.ts b/apps/server/src/modules/room-membership/repo/room-membership.repo.spec.ts new file mode 100644 index 00000000000..5fb6b3a0932 --- /dev/null +++ b/apps/server/src/modules/room-membership/repo/room-membership.repo.spec.ts @@ -0,0 +1,155 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { NotFoundError } from '@mikro-orm/core'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { RoomMembership } from '../do/room-membership.do'; +import { roomMembershipEntityFactory, roomMembershipFactory } from '../testing'; +import { RoomMembershipEntity } from './entity'; +import { RoomMembershipRepo } from './room-membership.repo'; + +describe('RoomMembershipRepo', () => { + let module: TestingModule; + let repo: RoomMembershipRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [RoomMembershipRepo], + }).compile(); + + repo = module.get(RoomMembershipRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('findByRoomId', () => { + const setup = async () => { + const roomMembershipEntity = roomMembershipEntityFactory.buildWithId(); + await em.persistAndFlush([roomMembershipEntity]); + em.clear(); + + return { roomMembershipEntity }; + }; + + it('should find room member by roomId', async () => { + const { roomMembershipEntity } = await setup(); + + const roomMembership = await repo.findByRoomId(roomMembershipEntity.roomId); + + expect(roomMembership).toBeDefined(); + expect(roomMembership?.getProps()).toEqual(roomMembershipEntity); + }); + }); + + describe('findByRoomIds', () => { + const setup = async () => { + const roomId1 = new ObjectId().toHexString(); + const roomId2 = new ObjectId().toHexString(); + + const roomMemberEntities = [ + roomMembershipEntityFactory.buildWithId({ roomId: roomId1 }), + roomMembershipEntityFactory.buildWithId({ roomId: roomId1 }), + roomMembershipEntityFactory.buildWithId({ roomId: roomId2 }), + ]; + + await em.persistAndFlush(roomMemberEntities); + em.clear(); + + return { roomMemberEntities, roomId1, roomId2 }; + }; + + it('should find room member by roomIds', async () => { + const { roomId1, roomId2 } = await setup(); + + const roomMemberships = await repo.findByRoomIds([roomId1, roomId2]); + + expect(roomMemberships).toHaveLength(3); + }); + }); + + describe('findByGroupId', () => { + const setup = async () => { + const groupId = new ObjectId().toHexString(); + const roomMemberEntities = [ + roomMembershipEntityFactory.build({ userGroupId: groupId }), + roomMembershipEntityFactory.build({ userGroupId: groupId }), + roomMembershipEntityFactory.build({ userGroupId: new ObjectId().toHexString() }), + ]; + + await em.persistAndFlush(roomMemberEntities); + em.clear(); + + return { roomMemberEntities, groupId }; + }; + + it('should find room members by groupId', async () => { + const { groupId } = await setup(); + + const roomMemberships = await repo.findByGroupId(groupId); + + expect(roomMemberships).toHaveLength(2); + }); + }); + + describe('save', () => { + const setup = () => { + const roomMemberships = roomMembershipFactory.buildList(3); + return { roomMemberships }; + }; + + it('should be able to persist a single room member', async () => { + const { roomMemberships } = setup(); + + await repo.save(roomMemberships[0]); + const result = await em.findOneOrFail(RoomMembershipEntity, roomMemberships[0].id); + + expect(roomMemberships[0].getProps()).toMatchObject(result); + }); + + it('should be able to persist many room members', async () => { + const { roomMemberships } = setup(); + + await repo.save(roomMemberships); + const result = await em.find(RoomMembershipEntity, { id: { $in: roomMemberships.map((r) => r.id) } }); + + expect(result.length).toBe(roomMemberships.length); + }); + }); + + describe('delete', () => { + const setup = async () => { + const roomMemberEntities = roomMembershipEntityFactory.buildListWithId(3); + await em.persistAndFlush(roomMemberEntities); + const roomMemberships = roomMemberEntities.map((entity) => new RoomMembership(entity)); + em.clear(); + + return { roomMemberships }; + }; + + it('should be able to delete a single room member', async () => { + const { roomMemberships } = await setup(); + + await repo.delete(roomMemberships[0]); + + await expect(em.findOneOrFail(RoomMembershipEntity, roomMemberships[0].id)).rejects.toThrow(NotFoundError); + }); + + it('should be able to delete many rooms', async () => { + const { roomMemberships } = await setup(); + + await repo.delete(roomMemberships); + + const remainingCount = await em.count(RoomMembershipEntity); + expect(remainingCount).toBe(0); + }); + }); +}); diff --git a/apps/server/src/modules/room-membership/repo/room-membership.repo.ts b/apps/server/src/modules/room-membership/repo/room-membership.repo.ts new file mode 100644 index 00000000000..5db8b3750ce --- /dev/null +++ b/apps/server/src/modules/room-membership/repo/room-membership.repo.ts @@ -0,0 +1,64 @@ +import { Utils } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { RoomMembership } from '../do/room-membership.do'; +import { RoomMembershipEntity } from './entity'; +import { RoomMembershipDomainMapper } from './room-membership-domain.mapper'; + +@Injectable() +export class RoomMembershipRepo { + constructor(private readonly em: EntityManager) {} + + async findByRoomId(roomId: EntityId): Promise { + const roomMembershipEntities = await this.em.findOne(RoomMembershipEntity, { roomId }); + if (!roomMembershipEntities) return null; + + const roomMemberships = RoomMembershipDomainMapper.mapEntityToDo(roomMembershipEntities); + + return roomMemberships; + } + + async findByRoomIds(roomIds: EntityId[]): Promise { + const entities = await this.em.find(RoomMembershipEntity, { roomId: { $in: roomIds } }); + const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity)); + + return roomMemberships; + } + + async findByGroupId(groupId: EntityId): Promise { + const entities = await this.em.find(RoomMembershipEntity, { userGroupId: groupId }); + const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity)); + + return roomMemberships; + } + + async findByGroupIds(groupIds: EntityId[]): Promise { + const entities = await this.em.find(RoomMembershipEntity, { userGroupId: { $in: groupIds } }); + const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity)); + + return roomMemberships; + } + + async save(roomMembership: RoomMembership | RoomMembership[]): Promise { + const roomMemberships = Utils.asArray(roomMembership); + + roomMemberships.forEach((member) => { + const entity = RoomMembershipDomainMapper.mapDoToEntity(member); + this.em.persist(entity); + }); + + await this.em.flush(); + } + + async delete(roomMembership: RoomMembership | RoomMembership[]): Promise { + const roomMemberships = Utils.asArray(roomMembership); + + roomMemberships.forEach((member) => { + const entity = RoomMembershipDomainMapper.mapDoToEntity(member); + this.em.remove(entity); + }); + + await this.em.flush(); + } +} diff --git a/apps/server/src/modules/room-membership/room-membership.module.ts b/apps/server/src/modules/room-membership/room-membership.module.ts new file mode 100644 index 00000000000..a261d8ea475 --- /dev/null +++ b/apps/server/src/modules/room-membership/room-membership.module.ts @@ -0,0 +1,16 @@ +import { GroupModule } from '@modules/group'; +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { AuthorizationModule } from '../authorization'; +import { RoleModule } from '../role'; +import { RoomModule } from '../room/room.module'; +import { RoomMembershipRule } from './authorization/room-membership.rule'; +import { RoomMembershipRepo } from './repo/room-membership.repo'; +import { RoomMembershipService } from './service/room-membership.service'; + +@Module({ + imports: [AuthorizationModule, CqrsModule, GroupModule, RoleModule, RoomModule], + providers: [RoomMembershipService, RoomMembershipRepo, RoomMembershipRule], + exports: [RoomMembershipService], +}) +export class RoomMembershipModule {} diff --git a/apps/server/src/modules/room-member/service/room-member.service.spec.ts b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts similarity index 51% rename from apps/server/src/modules/room-member/service/room-member.service.spec.ts rename to apps/server/src/modules/room-membership/service/room-membership.service.spec.ts index 69ba478a901..d9134cb2867 100644 --- a/apps/server/src/modules/room-member/service/room-member.service.spec.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts @@ -1,48 +1,55 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { GroupService, GroupTypes } from '@modules/group'; +import { RoleService } from '@modules/role'; +import { roomFactory } from '@modules/room/testing'; import { BadRequestException } from '@nestjs/common/exceptions'; import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain/interface'; import { groupFactory, roleDtoFactory, userFactory } from '@shared/testing'; import { MongoMemoryDatabaseModule } from '@src/infra/database'; -import { GroupService, GroupTypes } from '@src/modules/group'; -import { RoleService } from '@src/modules/role'; -import { roomFactory } from '@src/modules/room/testing'; -import { RoomMemberAuthorizable } from '../do/room-member-authorizable.do'; -import { RoomMemberRepo } from '../repo/room-member.repo'; -import { roomMemberFactory } from '../testing'; -import { RoomMemberService } from './room-member.service'; - -describe('RoomMemberService', () => { +import { RoomService } from '@src/modules/room/domain'; +import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do'; +import { RoomMembershipRepo } from '../repo/room-membership.repo'; +import { roomMembershipFactory } from '../testing'; +import { RoomMembershipService } from './room-membership.service'; + +describe('RoomMembershipService', () => { let module: TestingModule; - let service: RoomMemberService; - let roomMemberRepo: DeepMocked; + let service: RoomMembershipService; + let roomMembershipRepo: DeepMocked; let groupService: DeepMocked; let roleService: DeepMocked; + let roomService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ imports: [MongoMemoryDatabaseModule.forRoot()], providers: [ - RoomMemberService, + RoomMembershipService, { provide: GroupService, useValue: createMock(), }, { - provide: RoomMemberRepo, - useValue: createMock(), + provide: RoomMembershipRepo, + useValue: createMock(), }, { provide: RoleService, useValue: createMock(), }, + { + provide: RoomService, + useValue: createMock(), + }, ], }).compile(); - service = module.get(RoomMemberService); - roomMemberRepo = module.get(RoomMemberRepo); + service = module.get(RoomMembershipService); + roomMembershipRepo = module.get(RoomMembershipRepo); groupService = module.get(GroupService); roleService = module.get(RoleService); + roomService = module.get(RoomService); }); afterAll(async () => { @@ -54,16 +61,17 @@ describe('RoomMemberService', () => { }); describe('addMembersToRoom', () => { - describe('when room member does not exist', () => { + describe('when roomMembership does not exist', () => { const setup = () => { const user = userFactory.buildWithId(); const room = roomFactory.build(); const group = groupFactory.build({ type: GroupTypes.ROOM }); - roomMemberRepo.findByRoomId.mockResolvedValue(null); + roomMembershipRepo.findByRoomId.mockResolvedValue(null); groupService.createGroup.mockResolvedValue(group); groupService.addUserToGroup.mockResolvedValue(); - roomMemberRepo.save.mockResolvedValue(); + roomMembershipRepo.save.mockResolvedValue(); + roomService.getSingleRoom.mockResolvedValue(room); return { user, @@ -71,65 +79,77 @@ describe('RoomMemberService', () => { }; }; - it('should create new room member when not exists', async () => { + it('should create new roomMembership when not exists', async () => { + const { user, room } = setup(); + + await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); + + expect(roomMembershipRepo.save).toHaveBeenCalled(); + }); + + it('should save the schoolId of the room in the roomMembership', async () => { const { user, room } = setup(); - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOM_EDITOR }]); + await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - expect(roomMemberRepo.save).toHaveBeenCalled(); + expect(roomMembershipRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + schoolId: room.schoolId, + }) + ); }); describe('when no user is provided', () => { it('should throw an exception', async () => { const { room } = setup(); - roomMemberRepo.findByRoomId.mockResolvedValue(null); + roomMembershipRepo.findByRoomId.mockResolvedValue(null); await expect(service.addMembersToRoom(room.id, [])).rejects.toThrow(); }); }); }); - describe('when room member exists', () => { + describe('when roomMembership exists', () => { const setup = () => { const user = userFactory.buildWithId(); const group = groupFactory.build({ type: GroupTypes.ROOM }); const room = roomFactory.build(); - const roomMember = roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); - roomMemberRepo.findByRoomId.mockResolvedValue(roomMember); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); return { user, room, - roomMember, + roomMembership, group, }; }; - it('should add user to existing room member', async () => { + it('should add user to existing roomMembership', async () => { const { user, room, group } = setup(); - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOM_EDITOR }]); + await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); expect(groupService.addUsersToGroup).toHaveBeenCalledWith(group.id, [ - { userId: user.id, roleName: RoleName.ROOM_EDITOR }, + { userId: user.id, roleName: RoleName.ROOMEDITOR }, ]); }); }); }); describe('removeMembersFromRoom', () => { - describe('when room member does not exist', () => { + describe('when roomMembership does not exist', () => { const setup = () => { const user = userFactory.buildWithId(); const room = roomFactory.build(); const group = groupFactory.build({ type: GroupTypes.ROOM }); - roomMemberRepo.findByRoomId.mockResolvedValue(null); + roomMembershipRepo.findByRoomId.mockResolvedValue(null); groupService.createGroup.mockResolvedValue(group); groupService.addUserToGroup.mockResolvedValue(); - roomMemberRepo.save.mockResolvedValue(); + roomMembershipRepo.save.mockResolvedValue(); return { user, @@ -137,35 +157,35 @@ describe('RoomMemberService', () => { }; }; - describe('when roomMember does not exist', () => { + describe('when roomMembership does not exist', () => { it('should throw an exception', async () => { const { room } = setup(); - roomMemberRepo.findByRoomId.mockResolvedValue(null); + roomMembershipRepo.findByRoomId.mockResolvedValue(null); await expect(service.removeMembersFromRoom(room.id, [])).rejects.toThrowError(BadRequestException); }); }); }); - describe('when room member exists', () => { + describe('when roomMembership exists', () => { const setup = () => { const user = userFactory.buildWithId(); const group = groupFactory.build({ type: GroupTypes.ROOM }); const room = roomFactory.build(); - const roomMember = roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); - roomMemberRepo.findByRoomId.mockResolvedValue(roomMember); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); return { user, room, - roomMember, + roomMembership, group, }; }; - it('should remove room member', async () => { + it('should remove roomMembership', async () => { const { user, room, group } = setup(); await service.removeMembersFromRoom(room.id, [user.id]); @@ -175,82 +195,82 @@ describe('RoomMemberService', () => { }); }); - describe('deleteRoomMember', () => { - describe('when room member does not exist', () => { + describe('deleteRoomMembership', () => { + describe('when roomMembership does not exist', () => { const setup = () => { - roomMemberRepo.findByRoomId.mockResolvedValue(null); + roomMembershipRepo.findByRoomId.mockResolvedValue(null); }; it('no nothing', async () => { setup(); - await service.deleteRoomMember('roomId'); + await service.deleteRoomMembership('roomId'); expect(groupService.delete).not.toHaveBeenCalled(); - expect(roomMemberRepo.delete).not.toHaveBeenCalled(); + expect(roomMembershipRepo.delete).not.toHaveBeenCalled(); }); }); - describe('when room member exists', () => { + describe('when roomMembership exists', () => { const setup = () => { const group = groupFactory.build(); - const roomMember = roomMemberFactory.build({ userGroupId: group.id }); - roomMemberRepo.findByRoomId.mockResolvedValue(roomMember); + const roomMembership = roomMembershipFactory.build({ userGroupId: group.id }); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); - return { roomMember, group }; + return { roomMembership, group }; }; - it('should call delete group and room member', async () => { - const { roomMember, group } = setup(); - await service.deleteRoomMember(roomMember.roomId); + it('should call delete group and roomMembership', async () => { + const { roomMembership, group } = setup(); + await service.deleteRoomMembership(roomMembership.roomId); expect(groupService.delete).toHaveBeenCalledWith(group); - expect(roomMemberRepo.delete).toHaveBeenCalledWith(roomMember); + expect(roomMembershipRepo.delete).toHaveBeenCalledWith(roomMembership); }); }); }); - describe('getRoomMemberAuthorizable', () => { + describe('getRoomMembershipAuthorizable', () => { const setup = () => { const roomId = 'room123'; const userId = 'user456'; const groupId = 'group789'; const roleId = 'role101'; - const roomMember = roomMemberFactory.build({ roomId, userGroupId: groupId }); + const roomMembership = roomMembershipFactory.build({ roomId, userGroupId: groupId }); const group = groupFactory.build({ id: groupId, users: [{ userId, roleId }] }); const role = roleDtoFactory.build({ id: roleId }); - roomMemberRepo.findByRoomId.mockResolvedValue(roomMember); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); roleService.findByIds.mockResolvedValue([role]); - return { roomId, userId, groupId, roleId, roomMember, group, role }; + return { roomId, userId, groupId, roleId, roomMembership, group, role }; }; - it('should return RoomMemberAuthorizable when room member exists', async () => { + it('should return RoomMembershipAuthorizable when roomMembership exists', async () => { const { roomId, userId, roleId } = setup(); - const result = await service.getRoomMemberAuthorizable(roomId); + const result = await service.getRoomMembershipAuthorizable(roomId); - expect(result).toBeInstanceOf(RoomMemberAuthorizable); + expect(result).toBeInstanceOf(RoomMembershipAuthorizable); expect(result.roomId).toBe(roomId); expect(result.members).toHaveLength(1); expect(result.members[0].userId).toBe(userId); expect(result.members[0].roles[0].id).toBe(roleId); }); - it('should return empty RoomMemberAuthorizable when room member not exists', async () => { + it('should return empty RoomMembershipAuthorizable when roomMembership not exists', async () => { const roomId = 'nonexistent'; - roomMemberRepo.findByRoomId.mockResolvedValue(null); + roomMembershipRepo.findByRoomId.mockResolvedValue(null); - const result = await service.getRoomMemberAuthorizable(roomId); + const result = await service.getRoomMembershipAuthorizable(roomId); - expect(result).toBeInstanceOf(RoomMemberAuthorizable); + expect(result).toBeInstanceOf(RoomMembershipAuthorizable); expect(result.roomId).toBe(roomId); expect(result.members).toHaveLength(0); }); }); - describe('getRoomMemberAuthorizablesByUserId', () => { + describe('getRoomMembershipAuthorizablesByUserId', () => { const setup = () => { const userId = 'user123'; const groupId1 = 'group456'; @@ -264,31 +284,31 @@ describe('RoomMemberService', () => { groupFactory.build({ id: groupId1, users: [{ userId, roleId: roleId1 }] }), groupFactory.build({ id: groupId2, users: [{ userId, roleId: roleId2 }] }), ]; - const roomMembers = [ - roomMemberFactory.build({ roomId: roomId1, userGroupId: groupId1 }), - roomMemberFactory.build({ roomId: roomId2, userGroupId: groupId2 }), + const roomMemberships = [ + roomMembershipFactory.build({ roomId: roomId1, userGroupId: groupId1 }), + roomMembershipFactory.build({ roomId: roomId2, userGroupId: groupId2 }), ]; const roles = [roleDtoFactory.build({ id: roleId1 }), roleDtoFactory.build({ id: roleId2 })]; groupService.findGroups.mockResolvedValue({ data: groups, total: groups.length }); - roomMemberRepo.findByGroupIds.mockResolvedValue(roomMembers); + roomMembershipRepo.findByGroupIds.mockResolvedValue(roomMemberships); roleService.findByIds.mockResolvedValue(roles); - return { userId, roomMembers, roles }; + return { userId, roomMemberships, roles }; }; - it('should return RoomMemberAuthorizables for user', async () => { - const { userId, roomMembers, roles } = setup(); + it('should return RoomMembershipAuthorizables for user', async () => { + const { userId, roomMemberships, roles } = setup(); - const result = await service.getRoomMemberAuthorizablesByUserId(userId); + const result = await service.getRoomMembershipAuthorizablesByUserId(userId); expect(result).toHaveLength(2); - expect(result[0]).toBeInstanceOf(RoomMemberAuthorizable); - expect(result[0].roomId).toBe(roomMembers[0].roomId); + expect(result[0]).toBeInstanceOf(RoomMembershipAuthorizable); + expect(result[0].roomId).toBe(roomMemberships[0].roomId); expect(result[0].members[0].userId).toBe(userId); expect(result[0].members[0].roles[0].id).toBe(roles[0].id); - expect(result[1]).toBeInstanceOf(RoomMemberAuthorizable); - expect(result[1].roomId).toBe(roomMembers[1].roomId); + expect(result[1]).toBeInstanceOf(RoomMembershipAuthorizable); + expect(result[1].roomId).toBe(roomMemberships[1].roomId); expect(result[1].members[0].userId).toBe(userId); expect(result[1].members[0].roles[0].id).toBe(roles[1].id); }); @@ -297,7 +317,7 @@ describe('RoomMemberService', () => { const { userId } = setup(); groupService.findGroups.mockResolvedValue({ data: [], total: 0 }); - const result = await service.getRoomMemberAuthorizablesByUserId(userId); + const result = await service.getRoomMembershipAuthorizablesByUserId(userId); expect(result).toHaveLength(0); }); diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.ts b/apps/server/src/modules/room-membership/service/room-membership.service.ts new file mode 100644 index 00000000000..75e6e6922eb --- /dev/null +++ b/apps/server/src/modules/room-membership/service/room-membership.service.ts @@ -0,0 +1,144 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Group, GroupService, GroupTypes } from '@modules/group'; +import { RoleDto, RoleService } from '@modules/role'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { RoleName } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { RoomService } from '@src/modules/room/domain'; +import { RoomMembershipAuthorizable, UserWithRoomRoles } from '../do/room-membership-authorizable.do'; +import { RoomMembership } from '../do/room-membership.do'; +import { RoomMembershipRepo } from '../repo/room-membership.repo'; + +@Injectable() +export class RoomMembershipService { + constructor( + private readonly groupService: GroupService, + private readonly roomMembershipRepo: RoomMembershipRepo, + private readonly roleService: RoleService, + private readonly roomService: RoomService + ) {} + + private async createNewRoomMembership( + roomId: EntityId, + userId: EntityId, + roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER + ) { + const room = await this.roomService.getSingleRoom(roomId); + + const group = await this.groupService.createGroup( + `Room Members for Room ${roomId}`, + GroupTypes.ROOM, + room.schoolId + ); + await this.groupService.addUsersToGroup(group.id, [{ userId, roleName }]); + + const roomMembership = new RoomMembership({ + id: new ObjectId().toHexString(), + roomId, + userGroupId: group.id, + schoolId: room.schoolId, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await this.roomMembershipRepo.save(roomMembership); + + return roomMembership; + } + + private buildRoomMembershipAuthorizable( + roomId: EntityId, + group: Group, + roleSet: RoleDto[] + ): RoomMembershipAuthorizable { + const members = group.users.map((groupUser): UserWithRoomRoles => { + const roleDto = roleSet.find((role) => role.id === groupUser.roleId); + if (roleDto === undefined) throw new BadRequestException('Role not found'); + return { + roles: [roleDto], + userId: groupUser.userId, + }; + }); + + const roomMembershipAuthorizable = new RoomMembershipAuthorizable(roomId, members); + + return roomMembershipAuthorizable; + } + + public async deleteRoomMembership(roomId: EntityId) { + const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); + if (roomMembership === null) return; + + const group = await this.groupService.findById(roomMembership.userGroupId); + await this.groupService.delete(group); + await this.roomMembershipRepo.delete(roomMembership); + } + + public async addMembersToRoom( + roomId: EntityId, + userIdsAndRoles: Array<{ userId: EntityId; roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER }> + ): Promise { + const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); + if (roomMembership === null) { + const firstUser = userIdsAndRoles.shift(); + if (firstUser === undefined) { + throw new BadRequestException('No user provided'); + } + const newRoomMembership = await this.createNewRoomMembership(roomId, firstUser.userId, firstUser.roleName); + return newRoomMembership.id; + } + + await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles); + + return roomMembership.id; + } + + public async removeMembersFromRoom(roomId: EntityId, userIds: EntityId[]): Promise { + const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); + if (roomMembership === null) { + throw new BadRequestException('Room member not found'); + } + + const group = await this.groupService.findById(roomMembership.userGroupId); + await this.groupService.removeUsersFromGroup(group.id, userIds); + } + + public async getRoomMembershipAuthorizablesByUserId(userId: EntityId): Promise { + const groupPage = await this.groupService.findGroups({ userId, groupTypes: [GroupTypes.ROOM] }); + const groupIds = groupPage.data.map((group) => group.id); + const roomMemberships = await this.roomMembershipRepo.findByGroupIds(groupIds); + const roleIds = groupPage.data.flatMap((group) => group.users.map((groupUser) => groupUser.roleId)); + const roleSet = await this.roleService.findByIds(roleIds); + const roomMembershipAuthorizables = roomMemberships + .map((item) => { + const group = groupPage.data.find((g) => g.id === item.userGroupId); + if (!group) return null; + return this.buildRoomMembershipAuthorizable(item.roomId, group, roleSet); + }) + .filter((item): item is RoomMembershipAuthorizable => item !== null); + + return roomMembershipAuthorizables; + } + + public async getRoomMembershipAuthorizable(roomId: EntityId): Promise { + const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); + if (roomMembership === null) { + return new RoomMembershipAuthorizable(roomId, []); + } + const group = await this.groupService.findById(roomMembership.userGroupId); + const roleSet = await this.roleService.findByIds(group.users.map((groupUser) => groupUser.roleId)); + + const members = group.users.map((groupUser): UserWithRoomRoles => { + const roleDto = roleSet.find((role) => role.id === groupUser.roleId); + if (roleDto === undefined) throw new BadRequestException('Role not found'); + return { + roles: [roleDto], + userId: groupUser.userId, + }; + }); + + const roomMembershipAuthorizable = new RoomMembershipAuthorizable(roomId, members); + + return roomMembershipAuthorizable; + } +} diff --git a/apps/server/src/modules/room-membership/testing/index.ts b/apps/server/src/modules/room-membership/testing/index.ts new file mode 100644 index 00000000000..a29c3fae4a1 --- /dev/null +++ b/apps/server/src/modules/room-membership/testing/index.ts @@ -0,0 +1,2 @@ +export { roomMembershipEntityFactory } from './room-membership-entity.factory'; +export { roomMembershipFactory } from './room-membership.factory'; diff --git a/apps/server/src/modules/room-member/testing/room-member-entity.factory.ts b/apps/server/src/modules/room-membership/testing/room-membership-entity.factory.ts similarity index 50% rename from apps/server/src/modules/room-member/testing/room-member-entity.factory.ts rename to apps/server/src/modules/room-membership/testing/room-membership-entity.factory.ts index b63f70ecffd..8ea19809b55 100644 --- a/apps/server/src/modules/room-member/testing/room-member-entity.factory.ts +++ b/apps/server/src/modules/room-membership/testing/room-membership-entity.factory.ts @@ -1,14 +1,16 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { EntityFactory } from '@shared/testing/factory/entity.factory'; -import { RoomMemberEntity, RoomMemberEntityProps } from '../repo/entity/room-member.entity'; +import { RoomMembershipEntity } from '../repo/entity/room-membership.entity'; +import { RoomMembershipProps } from '../do/room-membership.do'; -export const roomMemberEntityFactory = EntityFactory.define( - RoomMemberEntity, +export const roomMembershipEntityFactory = EntityFactory.define( + RoomMembershipEntity, () => { return { id: new ObjectId().toHexString(), roomId: new ObjectId().toHexString(), userGroupId: new ObjectId().toHexString(), + schoolId: new ObjectId().toHexString(), createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/server/src/modules/room-member/testing/room-member.factory.ts b/apps/server/src/modules/room-membership/testing/room-membership.factory.ts similarity index 51% rename from apps/server/src/modules/room-member/testing/room-member.factory.ts rename to apps/server/src/modules/room-membership/testing/room-membership.factory.ts index 829f0f1708c..64c294187cb 100644 --- a/apps/server/src/modules/room-member/testing/room-member.factory.ts +++ b/apps/server/src/modules/room-membership/testing/room-membership.factory.ts @@ -1,12 +1,13 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '@shared/testing'; -import { RoomMember, RoomMemberProps } from '../do/room-member.do'; +import { RoomMembership, RoomMembershipProps } from '../do/room-membership.do'; -export const roomMemberFactory = BaseFactory.define(RoomMember, () => { - const props: RoomMemberProps = { +export const roomMembershipFactory = BaseFactory.define(RoomMembership, () => { + const props: RoomMembershipProps = { id: new ObjectId().toHexString(), roomId: new ObjectId().toHexString(), userGroupId: new ObjectId().toHexString(), + schoolId: new ObjectId().toHexString(), createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/server/src/modules/room/api/dto/request/create-room.body.params.ts b/apps/server/src/modules/room/api/dto/request/create-room.body.params.ts index 286abeba50c..42e4e5b586e 100644 --- a/apps/server/src/modules/room/api/dto/request/create-room.body.params.ts +++ b/apps/server/src/modules/room/api/dto/request/create-room.body.params.ts @@ -1,10 +1,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SanitizeHtml } from '@shared/controller'; -import { RoomCreateProps } from '@src/modules/room/domain'; -import { RoomColor } from '@src/modules/room/domain/type'; +import { NullToUndefined, SanitizeHtml } from '@shared/controller'; +import { RoomCreateProps } from '@modules/room/domain'; +import { RoomColor } from '@modules/room/domain/type'; import { IsDate, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; -export class CreateRoomBodyParams implements RoomCreateProps { +export class CreateRoomBodyParams implements Omit { @ApiProperty({ description: 'The name of the room', required: true, @@ -23,8 +23,9 @@ export class CreateRoomBodyParams implements RoomCreateProps { @IsEnum(RoomColor) color!: RoomColor; - @IsDate() @IsOptional() + @NullToUndefined() + @IsDate() @ApiPropertyOptional({ description: 'Start date of the room', required: false, @@ -32,8 +33,9 @@ export class CreateRoomBodyParams implements RoomCreateProps { }) startDate?: Date; - @IsDate() @IsOptional() + @NullToUndefined() + @IsDate() @ApiPropertyOptional({ description: 'End date of the room', required: false, diff --git a/apps/server/src/modules/room/api/dto/request/update-room.body.params.ts b/apps/server/src/modules/room/api/dto/request/update-room.body.params.ts index 8afebced7d3..71bf7ac10a1 100644 --- a/apps/server/src/modules/room/api/dto/request/update-room.body.params.ts +++ b/apps/server/src/modules/room/api/dto/request/update-room.body.params.ts @@ -1,7 +1,7 @@ +import { RoomUpdateProps } from '@modules/room/domain'; +import { RoomColor } from '@modules/room/domain/type'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SanitizeHtml } from '@shared/controller'; -import { RoomUpdateProps } from '@src/modules/room/domain'; -import { RoomColor } from '@src/modules/room/domain/type'; +import { NullToUndefined, SanitizeHtml } from '@shared/controller'; import { IsDate, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; export class UpdateRoomBodyParams implements RoomUpdateProps { @@ -25,6 +25,7 @@ export class UpdateRoomBodyParams implements RoomUpdateProps { @IsDate() @IsOptional() + @NullToUndefined() @ApiPropertyOptional({ description: 'Start date of the room', required: false, @@ -34,6 +35,7 @@ export class UpdateRoomBodyParams implements RoomUpdateProps { @IsDate() @IsOptional() + @NullToUndefined() @ApiPropertyOptional({ description: 'Start date of the room', required: false, diff --git a/apps/server/src/modules/room/api/dto/response/room-board-item.response.ts b/apps/server/src/modules/room/api/dto/response/room-board-item.response.ts index 0840c1c1d87..f96d927741b 100644 --- a/apps/server/src/modules/room/api/dto/response/room-board-item.response.ts +++ b/apps/server/src/modules/room/api/dto/response/room-board-item.response.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { BoardLayout } from '@src/modules/board'; +import { BoardLayout } from '@modules/board'; export class RoomBoardItemResponse { @ApiProperty() diff --git a/apps/server/src/modules/room/api/dto/response/room-details.response.ts b/apps/server/src/modules/room/api/dto/response/room-details.response.ts index f0d39b68284..bb3a385941e 100644 --- a/apps/server/src/modules/room/api/dto/response/room-details.response.ts +++ b/apps/server/src/modules/room/api/dto/response/room-details.response.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { RoomColor } from '@src/modules/room/domain/type'; +import { Permission } from '@shared/domain/interface'; +import { RoomColor } from '@modules/room/domain/type'; import { IsEnum } from 'class-validator'; export class RoomDetailsResponse { @@ -13,6 +14,9 @@ export class RoomDetailsResponse { @IsEnum(RoomColor) color: RoomColor; + @ApiProperty() + schoolId: string; + @ApiPropertyOptional({ type: Date }) startDate?: Date; @@ -25,14 +29,20 @@ export class RoomDetailsResponse { @ApiProperty({ type: Date }) updatedAt: Date; + @ApiProperty({ enum: Permission, isArray: true, enumName: 'Permission' }) + permissions: Permission[]; + constructor(room: RoomDetailsResponse) { this.id = room.id; this.name = room.name; this.color = room.color; + this.schoolId = room.schoolId; this.startDate = room.startDate; this.endDate = room.endDate; this.createdAt = room.createdAt; this.updatedAt = room.updatedAt; + + this.permissions = room.permissions; } } diff --git a/apps/server/src/modules/room/api/dto/response/room-item.response.ts b/apps/server/src/modules/room/api/dto/response/room-item.response.ts index 88b13ec3dab..3c8bffded23 100644 --- a/apps/server/src/modules/room/api/dto/response/room-item.response.ts +++ b/apps/server/src/modules/room/api/dto/response/room-item.response.ts @@ -13,6 +13,9 @@ export class RoomItemResponse { @IsEnum(RoomColor) color: RoomColor; + @ApiProperty() + schoolId: string; + @ApiPropertyOptional({ type: Date }) startDate?: Date; @@ -29,6 +32,7 @@ export class RoomItemResponse { this.id = room.id; this.name = room.name; this.color = room.color; + this.schoolId = room.schoolId; this.startDate = room.startDate; this.endDate = room.endDate; diff --git a/apps/server/src/modules/room/api/mapper/room.mapper.ts b/apps/server/src/modules/room/api/mapper/room.mapper.ts index 02fd43a659f..1f7d18ebc65 100644 --- a/apps/server/src/modules/room/api/mapper/room.mapper.ts +++ b/apps/server/src/modules/room/api/mapper/room.mapper.ts @@ -1,5 +1,6 @@ +import { ColumnBoard } from '@modules/board'; import { Page } from '@shared/domain/domainobject'; -import { ColumnBoard } from '@src/modules/board'; +import { Permission } from '@shared/domain/interface'; import { Room } from '../../domain/do/room.do'; import { RoomPaginationParams } from '../dto/request/room-pagination.params'; import { RoomBoardItemResponse } from '../dto/response/room-board-item.response'; @@ -14,6 +15,7 @@ export class RoomMapper { id: room.id, name: room.name, color: room.color, + schoolId: room.schoolId, startDate: room.startDate, endDate: room.endDate, createdAt: room.createdAt, @@ -32,15 +34,17 @@ export class RoomMapper { return response; } - static mapToRoomDetailsResponse(room: Room): RoomDetailsResponse { + static mapToRoomDetailsResponse(room: Room, permissions: Permission[]): RoomDetailsResponse { const response = new RoomDetailsResponse({ id: room.id, name: room.name, color: room.color, + schoolId: room.schoolId, startDate: room.startDate, endDate: room.endDate, createdAt: room.createdAt, updatedAt: room.updatedAt, + permissions, }); return response; diff --git a/apps/server/src/modules/room/api/room.controller.ts b/apps/server/src/modules/room/api/room.controller.ts index 2f54d0f65a8..b9a125787cc 100644 --- a/apps/server/src/modules/room/api/room.controller.ts +++ b/apps/server/src/modules/room/api/room.controller.ts @@ -11,6 +11,7 @@ import { Param, Patch, Post, + Put, Query, UnauthorizedException, } from '@nestjs/common'; @@ -89,9 +90,9 @@ export class RoomController { @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams ): Promise { - const room = await this.roomUc.getSingleRoom(currentUser.userId, urlParams.roomId); + const { room, permissions } = await this.roomUc.getSingleRoom(currentUser.userId, urlParams.roomId); - const response = RoomMapper.mapToRoomDetailsResponse(room); + const response = RoomMapper.mapToRoomDetailsResponse(room, permissions); return response; } @@ -115,8 +116,8 @@ export class RoomController { return response; } - @Patch(':roomId') - @ApiOperation({ summary: 'Create a new room' }) + @Put(':roomId') + @ApiOperation({ summary: 'Update an existing room' }) @ApiResponse({ status: HttpStatus.OK, description: 'Returns the details of a room', type: RoomDetailsResponse }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) @@ -128,9 +129,9 @@ export class RoomController { @Param() urlParams: RoomUrlParams, @Body() updateRoomParams: UpdateRoomBodyParams ): Promise { - const room = await this.roomUc.updateRoom(currentUser.userId, urlParams.roomId, updateRoomParams); + const { room, permissions } = await this.roomUc.updateRoom(currentUser.userId, urlParams.roomId, updateRoomParams); - const response = RoomMapper.mapToRoomDetailsResponse(room); + const response = RoomMapper.mapToRoomDetailsResponse(room, permissions); return response; } diff --git a/apps/server/src/modules/room/api/room.uc.spec.ts b/apps/server/src/modules/room/api/room.uc.spec.ts index ab0e958ae6c..8910130093e 100644 --- a/apps/server/src/modules/room/api/room.uc.spec.ts +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationService } from '@modules/authorization'; -import { RoomMemberRepo, RoomMemberService } from '@modules/room-member'; +import { RoomMembershipRepo, RoomMembershipService } from '@src/modules/room-membership'; import { UserService } from '@modules/user'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; @@ -8,7 +8,7 @@ import { FeatureDisabledLoggableException } from '@shared/common/loggable-except import { Page } from '@shared/domain/domainobject'; import { IFindOptions } from '@shared/domain/interface'; import { setupEntities, userFactory } from '@shared/testing'; -import { ColumnBoardService } from '@src/modules/board'; +import { ColumnBoardService } from '@modules/board'; import { Room, RoomService } from '../domain'; import { RoomColor } from '../domain/type'; import { roomFactory } from '../testing'; @@ -20,7 +20,7 @@ describe('RoomUc', () => { let configService: DeepMocked; let roomService: DeepMocked; let authorizationService: DeepMocked; - let roomMemberService: DeepMocked; + let roomMembershipService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ @@ -34,8 +34,8 @@ describe('RoomUc', () => { useValue: createMock(), }, { - provide: RoomMemberService, - useValue: createMock(), + provide: RoomMembershipService, + useValue: createMock(), }, { provide: ColumnBoardService, @@ -46,8 +46,8 @@ describe('RoomUc', () => { useValue: createMock(), }, { - provide: RoomMemberRepo, - useValue: createMock(), + provide: RoomMembershipRepo, + useValue: createMock(), }, { provide: UserService, @@ -60,7 +60,7 @@ describe('RoomUc', () => { configService = module.get(ConfigService); roomService = module.get(RoomService); authorizationService = module.get(AuthorizationService); - roomMemberService = module.get(RoomMemberService); + roomMembershipService = module.get(RoomMembershipService); await setupEntities(); }); @@ -117,7 +117,7 @@ describe('RoomUc', () => { authorizationService.checkOneOfPermissions.mockReturnValue(undefined); const room = roomFactory.build(); roomService.createRoom.mockResolvedValue(room); - roomMemberService.addMembersToRoom.mockRejectedValue(new Error('test')); + roomMembershipService.addMembersToRoom.mockRejectedValue(new Error('test')); return { user, room }; }; diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index 1691df897a9..a80e2838c66 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -1,5 +1,5 @@ import { Action, AuthorizationService } from '@modules/authorization'; -import { RoomMemberService, UserWithRoomRoles } from '@modules/room-member'; +import { RoomMembershipAuthorizable, RoomMembershipService, UserWithRoomRoles } from '@src/modules/room-membership'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -7,9 +7,11 @@ import { FeatureDisabledLoggableException } from '@shared/common/loggable-except import { Page, UserDO } from '@shared/domain/domainobject'; import { IFindOptions, Permission, RoleName, RoomRole } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { BoardExternalReferenceType, ColumnBoard, ColumnBoardService } from '@src/modules/board'; -import { Room, RoomCreateProps, RoomService, RoomUpdateProps } from '../domain'; +import { BoardExternalReferenceType, ColumnBoard, ColumnBoardService } from '@modules/board'; +import { Room, RoomService } from '../domain'; import { RoomConfig } from '../room.config'; +import { CreateRoomBodyParams } from './dto/request/create-room.body.params'; +import { UpdateRoomBodyParams } from './dto/request/update-room.body.params'; import { RoomMemberResponse } from './dto/response/room-member.response'; @Injectable() @@ -17,7 +19,7 @@ export class RoomUc { constructor( private readonly configService: ConfigService, private readonly roomService: RoomService, - private readonly roomMemberService: RoomMemberService, + private readonly roomMembershipService: RoomMembershipService, private readonly columnBoardService: ColumnBoardService, private readonly userService: UserService, private readonly authorizationService: AuthorizationService @@ -31,28 +33,31 @@ export class RoomUc { return rooms; } - public async createRoom(userId: EntityId, props: RoomCreateProps): Promise { + public async createRoom(userId: EntityId, props: CreateRoomBodyParams): Promise { this.checkFeatureEnabled(); - const user = await this.authorizationService.getUserWithPermissions(userId); - const room = await this.roomService.createRoom(props); - // NOTE: currently only teacher are allowed to create rooms. Could not find simpler way to check this. - this.authorizationService.checkOneOfPermissions(user, [Permission.COURSE_CREATE]); - await this.roomMemberService - .addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOM_EDITOR }], user.school.id) + const room = await this.roomService.createRoom({ ...props, schoolId: user.school.id }); + + this.authorizationService.checkOneOfPermissions(user, [Permission.ROOM_CREATE]); + + await this.roomMembershipService + .addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]) .catch(async (err) => { await this.roomService.deleteRoom(room); throw err; }); + return room; } - public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise { + public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise<{ room: Room; permissions: Permission[] }> { this.checkFeatureEnabled(); const room = await this.roomService.getSingleRoom(roomId); - await this.checkRoomAuthorization(userId, roomId, Action.read); - return room; + const roomMembershipAuthorizable = await this.checkRoomAuthorization(userId, roomId, Action.read); + const permissions = this.getPermissions(userId, roomMembershipAuthorizable); + + return { room, permissions }; } public async getRoomBoards(userId: EntityId, roomId: EntityId): Promise { @@ -72,38 +77,45 @@ export class RoomUc { return boards; } - public async updateRoom(userId: EntityId, roomId: EntityId, props: RoomUpdateProps): Promise { + public async updateRoom( + userId: EntityId, + roomId: EntityId, + props: UpdateRoomBodyParams + ): Promise<{ room: Room; permissions: Permission[] }> { this.checkFeatureEnabled(); const room = await this.roomService.getSingleRoom(roomId); - await this.checkRoomAuthorization(userId, roomId, Action.write); + const roomMembershipAuthorizable = await this.checkRoomAuthorization(userId, roomId, Action.write); + const permissions = this.getPermissions(userId, roomMembershipAuthorizable); + await this.roomService.updateRoom(room, props); - return room; + return { room, permissions }; } public async deleteRoom(userId: EntityId, roomId: EntityId): Promise { this.checkFeatureEnabled(); const room = await this.roomService.getSingleRoom(roomId); - await this.checkRoomAuthorization(userId, roomId, Action.write); + await this.checkRoomAuthorization(userId, roomId, Action.write, [Permission.ROOM_DELETE]); await this.roomService.deleteRoom(room); + await this.roomMembershipService.deleteRoomMembership(roomId); } public async getRoomMembers(userId: EntityId, roomId: EntityId): Promise { this.checkFeatureEnabled(); - const roomMemberAuthorizable = await this.roomMemberService.getRoomMemberAuthorizable(roomId); + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(roomId); const currentUser = await this.authorizationService.getUserWithPermissions(userId); - this.authorizationService.checkPermission(currentUser, roomMemberAuthorizable, { + this.authorizationService.checkPermission(currentUser, roomMembershipAuthorizable, { action: Action.read, requiredPermissions: [], }); - const userIds = roomMemberAuthorizable.members.map((member) => member.userId); + const userIds = roomMembershipAuthorizable.members.map((member) => member.userId); const users = await this.userService.findByIds(userIds); const memberResponses = users.map((user) => { - const member = roomMemberAuthorizable.members.find((item) => item.userId === user.id); + const member = roomMembershipAuthorizable.members.find((item) => item.userId === user.id); if (!member) { /* istanbul ignore next */ throw new Error('User not found in room members'); @@ -121,7 +133,7 @@ export class RoomUc { ): Promise { this.checkFeatureEnabled(); await this.checkRoomAuthorization(currentUserId, roomId, Action.write); - await this.roomMemberService.addMembersToRoom(roomId, userIdsAndRoles); + await this.roomMembershipService.addMembersToRoom(roomId, userIdsAndRoles); } private mapToMember(member: UserWithRoomRoles, user: UserDO) { @@ -137,12 +149,12 @@ export class RoomUc { public async removeMembersFromRoom(currentUserId: EntityId, roomId: EntityId, userIds: EntityId[]): Promise { this.checkFeatureEnabled(); await this.checkRoomAuthorization(currentUserId, roomId, Action.write); - await this.roomMemberService.removeMembersFromRoom(roomId, userIds); + await this.roomMembershipService.removeMembersFromRoom(roomId, userIds); } private async getAuthorizedRoomIds(userId: EntityId, action: Action): Promise { const user = await this.authorizationService.getUserWithPermissions(userId); - const roomAuthorizables = await this.roomMemberService.getRoomMemberAuthorizablesByUserId(userId); + const roomAuthorizables = await this.roomMembershipService.getRoomMembershipAuthorizablesByUserId(userId); const authorizedRoomIds = roomAuthorizables.filter((item) => this.authorizationService.hasPermission(user, item, { action, requiredPermissions: [] }) @@ -151,10 +163,26 @@ export class RoomUc { return authorizedRoomIds.map((item) => item.roomId); } - private async checkRoomAuthorization(userId: EntityId, roomId: EntityId, action: Action): Promise { - const roomMemberAuthorizable = await this.roomMemberService.getRoomMemberAuthorizable(roomId); + private async checkRoomAuthorization( + userId: EntityId, + roomId: EntityId, + action: Action, + requiredPermissions: Permission[] = [] + ): Promise { + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(roomId); const user = await this.authorizationService.getUserWithPermissions(userId); - this.authorizationService.checkPermission(user, roomMemberAuthorizable, { action, requiredPermissions: [] }); + this.authorizationService.checkPermission(user, roomMembershipAuthorizable, { action, requiredPermissions }); + + return roomMembershipAuthorizable; + } + + private getPermissions(userId: EntityId, roomMembershipAuthorizable: RoomMembershipAuthorizable): Permission[] { + const permissions = roomMembershipAuthorizable.members + .filter((member) => member.userId === userId) + .flatMap((member) => member.roles) + .flatMap((role) => role.permissions ?? []); + + return permissions; } private checkFeatureEnabled(): void { diff --git a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts index 40f46dd24f1..5fab1cced7e 100644 --- a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts @@ -10,9 +10,9 @@ import { groupEntityFactory, roleFactory, } from '@shared/testing'; -import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing/room-member-entity.factory'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { GroupEntityTypes } from '@modules/group/entity/group.entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; describe('Room Controller (API)', () => { @@ -50,7 +50,7 @@ describe('Room Controller (API)', () => { const { teacherAccount: otherTeacherAccount, teacherUser: otherTeacherUser } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const role = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], }); // TODO: add more than one user @@ -61,10 +61,10 @@ describe('Room Controller (API)', () => { externalSource: undefined, }); - const roomMembers = roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMemberships = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); await em.persistAndFlush([ room, - roomMembers, + roomMemberships, teacherAccount, teacherUser, otherTeacherUser, @@ -120,7 +120,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room, otherTeacherUser } = await setupRoomWithMembers(); const response = await loggedInClient.patch(`/${room.id}/members/add`, { - userIdsAndRoles: [{ userId: otherTeacherUser.id, roleName: RoleName.ROOM_EDITOR }], + userIdsAndRoles: [{ userId: otherTeacherUser.id, roleName: RoleName.ROOMEDITOR }], }); expect(response.status).toBe(HttpStatus.OK); diff --git a/apps/server/src/modules/room/api/test/room-create.api.spec.ts b/apps/server/src/modules/room/api/test/room-create.api.spec.ts index a2a44adc20a..eeca260725b 100644 --- a/apps/server/src/modules/room/api/test/room-create.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-create.api.spec.ts @@ -2,9 +2,9 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, roleFactory } from '@shared/testing'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; -import { RoomMemberEntity } from '@src/modules/room-member'; -import { GroupEntity } from '@src/modules/group/entity'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; +import { RoomMembershipEntity } from '@src/modules/room-membership'; +import { GroupEntity } from '@modules/group/entity'; import { Permission, RoleName } from '@shared/domain/interface'; import { RoomEntity } from '../../repo'; @@ -69,7 +69,7 @@ describe('Room Controller (API)', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const role = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT, Permission.ROOM_VIEW], }); await em.persistAndFlush([teacherAccount, teacherUser, role]); @@ -98,13 +98,13 @@ describe('Room Controller (API)', () => { const response = await loggedInClient.post(undefined, params); const roomId = (response.body as { id: string }).id; - const roomMember = await em.findOneOrFail(RoomMemberEntity, { roomId }); + const roomMembership = await em.findOneOrFail(RoomMembershipEntity, { roomId }); const userGroup = await em.findOneOrFail(GroupEntity, { - id: roomMember.userGroupId, + id: roomMembership.userGroupId, }); - expect(roomMember).toBeDefined(); + expect(roomMembership).toBeDefined(); expect(userGroup).toBeDefined(); expect(userGroup.users).toHaveLength(1); expect(userGroup.users[0].user.id).toBe(teacherUser.id); diff --git a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts index b19d523f752..22f74c7edc8 100644 --- a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts @@ -1,4 +1,6 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { GroupEntityTypes } from '@modules/group/entity/group.entity'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; import { HttpStatus, INestApplication, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Permission, RoleName } from '@shared/domain/interface'; @@ -9,9 +11,8 @@ import { groupEntityFactory, roleFactory, } from '@shared/testing'; -import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing/room-member-entity.factory'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { RoomMembershipEntity } from '@src/modules/room-membership'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; import { RoomEntity } from '../../repo'; import { roomEntityFactory } from '../../testing/room-entity.factory'; @@ -95,7 +96,7 @@ describe('Room Controller (API)', () => { const setup = async () => { const room = roomEntityFactory.build(); const role = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); @@ -103,8 +104,8 @@ describe('Room Controller (API)', () => { type: GroupEntityTypes.ROOM, users: [{ role, user: teacherUser }], }); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); - await em.persistAndFlush([room, roomMember, teacherAccount, teacherUser, userGroup, role]); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -120,6 +121,16 @@ describe('Room Controller (API)', () => { expect(response.status).toBe(HttpStatus.NO_CONTENT); await expect(em.findOneOrFail(RoomEntity, room.id)).rejects.toThrow(NotFoundException); }); + + it('should delete the roomMembership', async () => { + const { loggedInClient, room } = await setup(); + + await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).resolves.not.toThrow(); + + const response = await loggedInClient.delete(room.id); + expect(response.status).toBe(HttpStatus.NO_CONTENT); + await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).rejects.toThrow(NotFoundException); + }); }); describe('when the room does not exist', () => { diff --git a/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts b/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts index b727b66355a..4f1646ec708 100644 --- a/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts @@ -9,11 +9,11 @@ import { TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; -import { BoardExternalReferenceType } from '@src/modules/board'; -import { columnBoardEntityFactory } from '@src/modules/board/testing'; -import { GroupEntityTypes } from '@src/modules/group/entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; -import { serverConfig, ServerConfig, ServerTestModule } from '@src/modules/server'; +import { BoardExternalReferenceType } from '@modules/board'; +import { columnBoardEntityFactory } from '@modules/board/testing'; +import { GroupEntityTypes } from '@modules/group/entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { serverConfig, ServerConfig, ServerTestModule } from '@modules/server'; import { roomEntityFactory } from '../../testing'; describe('Room Controller (API)', () => { @@ -100,7 +100,7 @@ describe('Room Controller (API)', () => { }); const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); const role = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); const userGroupEntity = groupEntityFactory.buildWithId({ @@ -109,8 +109,8 @@ describe('Room Controller (API)', () => { organization: studentUser.school, externalSource: undefined, }); - const roomMember = roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); - await em.persistAndFlush([room, board, studentAccount, studentUser, role, userGroupEntity, roomMember]); + const roomMembership = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + await em.persistAndFlush([room, board, studentAccount, studentUser, role, userGroupEntity, roomMembership]); em.clear(); const loggedInClient = await testApiClient.login(studentAccount); diff --git a/apps/server/src/modules/room/api/test/room-get.api.spec.ts b/apps/server/src/modules/room/api/test/room-get.api.spec.ts index fe842db3d7f..719889d82ec 100644 --- a/apps/server/src/modules/room/api/test/room-get.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-get.api.spec.ts @@ -9,9 +9,9 @@ import { groupEntityFactory, roleFactory, } from '@shared/testing'; -import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { GroupEntityTypes } from '@modules/group/entity/group.entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; describe('Room Controller (API)', () => { @@ -95,7 +95,7 @@ describe('Room Controller (API)', () => { const room = roomEntityFactory.build(); const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); const role = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); const userGroupEntity = groupEntityFactory.buildWithId({ @@ -104,8 +104,8 @@ describe('Room Controller (API)', () => { organization: studentUser.school, externalSource: undefined, }); - const roomMember = roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); - await em.persistAndFlush([room, studentAccount, studentUser, role, userGroupEntity, roomMember]); + const roomMembership = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + await em.persistAndFlush([room, studentAccount, studentUser, role, userGroupEntity, roomMembership]); em.clear(); const loggedInClient = await testApiClient.login(studentAccount); @@ -114,10 +114,12 @@ describe('Room Controller (API)', () => { id: room.id, name: room.name, color: room.color, + schoolId: room.schoolId, startDate: room.startDate?.toISOString(), endDate: room.endDate?.toISOString(), createdAt: room.createdAt.toISOString(), updatedAt: room.updatedAt.toISOString(), + permissions: [Permission.ROOM_VIEW], }; return { loggedInClient, room, expectedResponse }; diff --git a/apps/server/src/modules/room/api/test/room-index.api.spec.ts b/apps/server/src/modules/room/api/test/room-index.api.spec.ts index ca0a1c22658..cbb68d0f38c 100644 --- a/apps/server/src/modules/room/api/test/room-index.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-index.api.spec.ts @@ -10,9 +10,9 @@ import { groupEntityFactory, roleFactory, } from '@shared/testing'; -import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing/room-member-entity.factory'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { GroupEntityTypes } from '@modules/group/entity/group.entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; import { RoomListResponse } from '../dto/response/room-list.response'; @@ -86,6 +86,7 @@ describe('Room Controller (API)', () => { id: room.id, name: room.name, color: room.color, + schoolId: room.schoolId, startDate: room.startDate?.toISOString(), endDate: room.endDate?.toISOString(), createdAt: room.createdAt.toISOString(), @@ -130,7 +131,7 @@ describe('Room Controller (API)', () => { const rooms = roomEntityFactory.buildListWithId(2); const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); const role = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); const userGroupEntity = groupEntityFactory.buildWithId({ @@ -139,10 +140,10 @@ describe('Room Controller (API)', () => { organization: studentUser.school, externalSource: undefined, }); - const roomMembers = rooms.map((room) => - roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }) + const roomMemberships = rooms.map((room) => + roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }) ); - await em.persistAndFlush([...rooms, ...roomMembers, studentAccount, studentUser, userGroupEntity]); + await em.persistAndFlush([...rooms, ...roomMemberships, studentAccount, studentUser, userGroupEntity]); em.clear(); const loggedInClient = await testApiClient.login(studentAccount); @@ -152,6 +153,7 @@ describe('Room Controller (API)', () => { id: room.id, name: room.name, color: room.color, + schoolId: room.schoolId, startDate: room.startDate?.toISOString(), endDate: room.endDate?.toISOString(), createdAt: room.createdAt.toISOString(), diff --git a/apps/server/src/modules/room/api/test/room-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-members.api.spec.ts index 9e6663109b3..c509e59c41b 100644 --- a/apps/server/src/modules/room/api/test/room-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-members.api.spec.ts @@ -11,9 +11,9 @@ import { roleFactory, userFactory, } from '@shared/testing'; -import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing/room-member-entity.factory'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { GroupEntityTypes } from '@modules/group/entity/group.entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; import { RoomMemberListResponse } from '../dto/response/room-member.response'; @@ -50,11 +50,11 @@ describe('Room Controller (API)', () => { const room = roomEntityFactory.buildWithId(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const editRole = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], }); const viewerRole = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); const students = userFactory.buildList(2); @@ -71,10 +71,10 @@ describe('Room Controller (API)', () => { organization: teacherUser.school, externalSource: undefined, }); - const roomMembers = roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMemberships = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); await em.persistAndFlush([ room, - roomMembers, + roomMemberships, teacherAccount, teacherUser, userGroupEntity, diff --git a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts index f470f25c3d5..d87d0e68314 100644 --- a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts @@ -10,9 +10,9 @@ import { groupEntityFactory, roleFactory, } from '@shared/testing'; -import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing/room-member-entity.factory'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { GroupEntityTypes } from '@modules/group/entity/group.entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; describe('Room Controller (API)', () => { @@ -46,11 +46,11 @@ describe('Room Controller (API)', () => { describe('PATCH /rooms/:roomId/members/remove', () => { const setupRoomRoles = () => { const editorRole = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], }); const viewerRole = roleFactory.buildWithId({ - name: RoleName.ROOM_VIEWER, + name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); return { editorRole, viewerRole }; @@ -81,9 +81,9 @@ describe('Room Controller (API)', () => { externalSource: undefined, }); - const roomMembers = roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMemberships = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); - await em.persistAndFlush([...Object.values(users), room, roomMembers, teacherAccount, userGroupEntity]); + await em.persistAndFlush([...Object.values(users), room, roomMemberships, teacherAccount, userGroupEntity]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/room/api/test/room-update.api.spec.ts b/apps/server/src/modules/room/api/test/room-update.api.spec.ts index a756962f5af..782c23961d4 100644 --- a/apps/server/src/modules/room/api/test/room-update.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-update.api.spec.ts @@ -9,8 +9,8 @@ import { groupEntityFactory, roleFactory, } from '@shared/testing'; -import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; -import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; import { RoomEntity } from '../../repo'; import { roomEntityFactory } from '../../testing'; @@ -42,11 +42,11 @@ describe('Room Controller (API)', () => { await app.close(); }); - describe('PATCH /rooms/:id', () => { + describe('PUT /rooms/:id', () => { describe('when the user is not authenticated', () => { it('should return a 401 error', async () => { const someId = new ObjectId().toHexString(); - const response = await testApiClient.patch(someId); + const response = await testApiClient.put(someId); expect(response.status).toBe(HttpStatus.UNAUTHORIZED); }); }); @@ -68,7 +68,7 @@ describe('Room Controller (API)', () => { const { loggedInClient } = await setup(); const someId = new ObjectId().toHexString(); const params = { name: 'Room #101', color: 'green' }; - const response = await loggedInClient.patch(someId, params); + const response = await loggedInClient.put(someId, params); expect(response.status).toBe(HttpStatus.FORBIDDEN); }); }); @@ -87,7 +87,7 @@ describe('Room Controller (API)', () => { it('should return a 400 error', async () => { const { loggedInClient } = await setup(); const params = { name: 'Room #101', color: 'green' }; - const response = await loggedInClient.patch('42', params); + const response = await loggedInClient.put('42', params); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); }); @@ -99,15 +99,15 @@ describe('Room Controller (API)', () => { endDate: new Date('2024-10-20'), }); const role = roleFactory.buildWithId({ - name: RoleName.ROOM_EDITOR, + name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const userGroup = groupEntityFactory.buildWithId({ users: [{ role, user: teacherUser }], }); - const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); - await em.persistAndFlush([room, roomMember, teacherAccount, teacherUser, userGroup, role]); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -121,7 +121,7 @@ describe('Room Controller (API)', () => { const someId = new ObjectId().toHexString(); const params = { name: 'Room #101', color: 'green' }; - const response = await loggedInClient.patch(someId, params); + const response = await loggedInClient.put(someId, params); expect(response.status).toBe(HttpStatus.NOT_FOUND); }); @@ -132,7 +132,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'green' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.OK); await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ @@ -147,7 +147,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const params = { name: '', color: 'red' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); @@ -158,7 +158,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: '' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); @@ -169,7 +169,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'fancy-color' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); @@ -181,7 +181,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'green', startDate: '2024-10-02' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.OK); await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ @@ -194,7 +194,7 @@ describe('Room Controller (API)', () => { it('should return a 400 error', async () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'green', startDate: 'invalid date' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); }); @@ -204,23 +204,35 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'green', startDate: null }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.OK); - await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ - id: room.id, - startDate: null, - }); + const resultRoom = await em.findOneOrFail(RoomEntity, room.id); + expect(resultRoom.startDate).toBe(undefined); }); }); }); + describe('when the startDate is omitted', () => { + it('should unset the property', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green' }; + + const response = await loggedInClient.put(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + + const resultRoom = await em.findOneOrFail(RoomEntity, room.id); + expect(resultRoom.endDate).toBe(undefined); + }); + }); + describe('when an end date is given', () => { it('should update the room', async () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'green', endDate: '2024-10-18' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.OK); await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ @@ -233,7 +245,7 @@ describe('Room Controller (API)', () => { it('should return a 400 error', async () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'green', endDate: 'invalid date' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); }); @@ -241,19 +253,32 @@ describe('Room Controller (API)', () => { describe('when the date is null', () => { it('should unset the property', async () => { const { loggedInClient, room } = await setup(); - const params = { name: 'Room #101', color: 'green', endDate: null }; + const params = { name: 'Room #101', color: 'green', startDate: '2024-10-02', endDate: null }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.OK); - await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ - id: room.id, - endDate: null, - }); + + const resultRoom = await em.findOneOrFail(RoomEntity, room.id); + expect(resultRoom.endDate).toBe(undefined); }); }); }); + describe('when the endDate is omitted', () => { + it('should unset the property', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green', startDate: '2024-10-02' }; + + const response = await loggedInClient.put(room.id, params); + + expect(response.status).toBe(HttpStatus.OK); + + const resultRoom = await em.findOneOrFail(RoomEntity, room.id); + expect(resultRoom.endDate).toBe(undefined); + }); + }); + describe('when the start date is before the end date', () => { it('should update the room', async () => { const { loggedInClient, room } = await setup(); @@ -264,7 +289,7 @@ describe('Room Controller (API)', () => { endDate: '2024-10-18', }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.OK); await expect(em.findOneOrFail(RoomEntity, room.id)).resolves.toMatchObject({ @@ -285,7 +310,7 @@ describe('Room Controller (API)', () => { endDate: '2024-10-05', }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.BAD_REQUEST); }); @@ -313,7 +338,7 @@ describe('Room Controller (API)', () => { const someId = new ObjectId().toHexString(); const params = { name: 'Room #101', color: 'green' }; - const response = await loggedInClient.patch(someId, params); + const response = await loggedInClient.put(someId, params); expect(response.status).toBe(HttpStatus.NOT_FOUND); }); @@ -324,7 +349,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const params = { name: 'Room #101', color: 'green' }; - const response = await loggedInClient.patch(room.id, params); + const response = await loggedInClient.put(room.id, params); expect(response.status).toBe(HttpStatus.FORBIDDEN); }); diff --git a/apps/server/src/modules/room/domain/do/room.do.spec.ts b/apps/server/src/modules/room/domain/do/room.do.spec.ts index 465fc4b95e8..7995aab7088 100644 --- a/apps/server/src/modules/room/domain/do/room.do.spec.ts +++ b/apps/server/src/modules/room/domain/do/room.do.spec.ts @@ -1,3 +1,4 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { EntityId } from '@shared/domain/types'; import { roomFactory } from '../../testing'; import { RoomColor } from '../type'; @@ -12,6 +13,7 @@ describe('Room', () => { color: RoomColor.BLUE, startDate: new Date('2024-01-01'), endDate: new Date('2024-12-31'), + schoolId: new ObjectId().toHexString(), createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; diff --git a/apps/server/src/modules/room/domain/do/room.do.ts b/apps/server/src/modules/room/domain/do/room.do.ts index 1cdd5093a63..af6ccf005d4 100644 --- a/apps/server/src/modules/room/domain/do/room.do.ts +++ b/apps/server/src/modules/room/domain/do/room.do.ts @@ -8,12 +8,13 @@ export interface RoomProps extends AuthorizableObject { color: RoomColor; startDate?: Date; endDate?: Date; + schoolId: EntityId; createdAt: Date; updatedAt: Date; } -export type RoomCreateProps = Pick; -export type RoomUpdateProps = RoomCreateProps; // will probably change in the future +export type RoomCreateProps = Pick; +export type RoomUpdateProps = Omit; export class Room extends DomainObject { public constructor(props: RoomProps) { @@ -46,11 +47,15 @@ export class Room extends DomainObject { this.props.color = value; } + public get schoolId(): EntityId { + return this.props.schoolId; + } + public get startDate(): Date | undefined { return this.props.startDate; } - public set startDate(value: Date) { + public set startDate(value: Date | undefined) { this.props.startDate = value; } @@ -58,7 +63,7 @@ export class Room extends DomainObject { return this.props.endDate; } - public set endDate(value: Date) { + public set endDate(value: Date | undefined) { this.props.endDate = value; } diff --git a/apps/server/src/modules/room/domain/service/room.service.spec.ts b/apps/server/src/modules/room/domain/service/room.service.spec.ts index 2eb114a4f1f..08191e7409d 100644 --- a/apps/server/src/modules/room/domain/service/room.service.spec.ts +++ b/apps/server/src/modules/room/domain/service/room.service.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; @@ -70,6 +71,7 @@ describe('RoomService', () => { const props: RoomCreateProps = { name: 'room #1', color: RoomColor.ORANGE, + schoolId: new ObjectId().toHexString(), }; return { props }; }; diff --git a/apps/server/src/modules/room/domain/service/room.service.ts b/apps/server/src/modules/room/domain/service/room.service.ts index 4ec2c62c779..241b26a191a 100644 --- a/apps/server/src/modules/room/domain/service/room.service.ts +++ b/apps/server/src/modules/room/domain/service/room.service.ts @@ -26,7 +26,12 @@ export class RoomService { public async createRoom(props: RoomCreateProps): Promise { const roomProps: RoomProps = { id: new ObjectId().toHexString(), - ...props, + name: props.name, + color: props.color, + schoolId: props.schoolId, + // make sure that the dates are not null at runtime + startDate: props.startDate ?? undefined, + endDate: props.endDate ?? undefined, createdAt: new Date(), updatedAt: new Date(), }; @@ -46,7 +51,12 @@ export class RoomService { public async updateRoom(room: Room, props: RoomUpdateProps): Promise { this.validateTimeSpan(props, room.id); - Object.assign(room, props); + + room.name = props.name; + room.color = props.color; + // make sure that the dates are not null at runtime + room.startDate = props.startDate ?? undefined; + room.endDate = props.endDate ?? undefined; await this.roomRepo.save(room); } diff --git a/apps/server/src/modules/room/index.ts b/apps/server/src/modules/room/index.ts index 7be3bdc7474..31c66bccfac 100644 --- a/apps/server/src/modules/room/index.ts +++ b/apps/server/src/modules/room/index.ts @@ -2,3 +2,4 @@ export * from './domain'; export { RoomConfig } from './room.config'; export * from './room.module'; export * from './repo/entity'; +export { roomFactory } from './testing'; diff --git a/apps/server/src/modules/room/repo/entity/room.entity.ts b/apps/server/src/modules/room/repo/entity/room.entity.ts index 3a174be1d9d..0539f7c469c 100644 --- a/apps/server/src/modules/room/repo/entity/room.entity.ts +++ b/apps/server/src/modules/room/repo/entity/room.entity.ts @@ -1,5 +1,7 @@ import { Entity, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain/types'; +import { ObjectIdType } from '@shared/repo/types/object-id.type'; import { Room, RoomProps } from '../../domain/do/room.do'; import { RoomColor } from '../../domain/type'; @@ -11,6 +13,9 @@ export class RoomEntity extends BaseEntityWithTimestamps implements RoomProps { @Property({ nullable: false }) color!: RoomColor; + @Property({ type: ObjectIdType, fieldName: 'school', nullable: false }) + schoolId!: EntityId; + @Property({ nullable: true }) startDate?: Date; diff --git a/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts b/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts index 22f25ad841c..8de67527eed 100644 --- a/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts +++ b/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts @@ -1,3 +1,4 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { Room, RoomProps } from '../domain/do/room.do'; import { RoomColor } from '../domain/type'; import { roomEntityFactory } from '../testing'; @@ -32,6 +33,7 @@ describe('RoomDomainMapper', () => { id: '1', name: 'Existing Room', color: RoomColor.GREEN, + schoolId: new ObjectId().toHexString(), startDate: new Date('2023-01-01'), endDate: new Date('2023-12-31'), createdAt: new Date('2023-01-01'), @@ -42,6 +44,7 @@ describe('RoomDomainMapper', () => { id: '2', name: 'Test Room', color: RoomColor.RED, + schoolId: new ObjectId().toHexString(), startDate: new Date('2023-02-01'), endDate: new Date('2023-11-30'), domainObject: existingRoom, @@ -55,6 +58,7 @@ describe('RoomDomainMapper', () => { id: '1', name: 'Existing Room', color: RoomColor.GREEN, + schoolId: existingRoom.schoolId, startDate: new Date('2023-01-01'), endDate: new Date('2023-12-31'), createdAt: new Date('2023-01-01'), @@ -69,6 +73,7 @@ describe('RoomDomainMapper', () => { id: '1', name: 'Test Room', color: RoomColor.RED, + schoolId: new ObjectId().toHexString(), startDate: new Date('2023-01-01'), endDate: new Date('2023-12-31'), } as RoomEntity; @@ -99,6 +104,7 @@ describe('RoomDomainMapper', () => { id: '66d581c3ef74c548a4efea1d', name: 'Test Room #1', color: RoomColor.RED, + schoolId: new ObjectId().toHexString(), startDate: new Date('2023-01-01'), endDate: new Date('2023-12-31'), createdAt: new Date('2024-10-1'), diff --git a/apps/server/src/modules/room/room-api.module.ts b/apps/server/src/modules/room/room-api.module.ts index 7405a83afe8..7711d7cd842 100644 --- a/apps/server/src/modules/room/room-api.module.ts +++ b/apps/server/src/modules/room/room-api.module.ts @@ -2,13 +2,13 @@ import { AuthorizationModule } from '@modules/authorization'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { BoardModule } from '../board'; -import { RoomMemberModule } from '../room-member/room-member.module'; +import { RoomMembershipModule } from '../room-membership/room-membership.module'; import { UserModule } from '../user'; import { RoomController, RoomUc } from './api'; import { RoomModule } from './room.module'; @Module({ - imports: [RoomModule, AuthorizationModule, LoggerModule, RoomMemberModule, BoardModule, UserModule], + imports: [RoomModule, AuthorizationModule, LoggerModule, RoomMembershipModule, BoardModule, UserModule], controllers: [RoomController], providers: [RoomUc], }) diff --git a/apps/server/src/modules/room/testing/room-entity.factory.ts b/apps/server/src/modules/room/testing/room-entity.factory.ts index fee0b2c80b0..ad2e979750e 100644 --- a/apps/server/src/modules/room/testing/room-entity.factory.ts +++ b/apps/server/src/modules/room/testing/room-entity.factory.ts @@ -9,6 +9,7 @@ export const roomEntityFactory = EntityFactory.define(Roo id: new ObjectId().toHexString(), name: `room #${sequence}`, color: [RoomColor.BLUE, RoomColor.RED, RoomColor.GREEN, RoomColor.MAGENTA][Math.floor(Math.random() * 4)], + schoolId: new ObjectId().toHexString(), startDate: new Date(), endDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), createdAt: new Date(), diff --git a/apps/server/src/modules/room/testing/room.factory.ts b/apps/server/src/modules/room/testing/room.factory.ts index 099c98b831c..7f1ef879488 100644 --- a/apps/server/src/modules/room/testing/room.factory.ts +++ b/apps/server/src/modules/room/testing/room.factory.ts @@ -8,6 +8,7 @@ export const roomFactory = BaseFactory.define(Room, ({ sequence id: new ObjectId().toHexString(), name: `room #${sequence}`, color: [RoomColor.BLUE, RoomColor.RED, RoomColor.GREEN, RoomColor.MAGENTA][Math.floor(Math.random() * 4)], + schoolId: new ObjectId().toHexString(), startDate: new Date(), createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index c33c920148a..046df1f7497 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -29,7 +29,7 @@ import { RocketChatModule } from '@modules/rocketchat'; import { RoomApiModule } from '@modules/room/room-api.module'; import { RosterModule } from '@modules/roster/roster.module'; import { SchoolApiModule } from '@modules/school/school-api.module'; -import { SharingApiModule } from '@modules/sharing/sharing.module'; +import { SharingApiModule } from '@modules/sharing/sharing-api.module'; import { ShdApiModule } from '@modules/shd/shd.api.module'; import { SystemApiModule } from '@modules/system/system-api.module'; import { TaskApiModule } from '@modules/task/task-api.module'; diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts index 68423d68574..d7aa58a65e4 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts @@ -94,13 +94,13 @@ describe(`share token creation (api)`, () => { }; describe('with the feature disabled', () => { - it('should return status 500', async () => { + it('should return status 403', async () => { Configuration.set('FEATURE_COURSE_SHARE', false); const { course } = await setup(); const response = await api.post({ parentId: course.id, parentType: ShareTokenParentType.Course }); - expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(response.status).toEqual(HttpStatus.FORBIDDEN); }); }); diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts new file mode 100644 index 00000000000..799a1bcca32 --- /dev/null +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts @@ -0,0 +1,108 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + cleanupCollections, + groupEntityFactory, + roleFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { BoardExternalReferenceType } from '@src/modules/board'; +import { columnBoardEntityFactory } from '@src/modules/board/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { ServerTestModule } from '@src/modules/server'; +import { ShareTokenParentType } from '../../domainobject/share-token.do'; +import { ShareTokenService } from '../../service'; + +describe('Sharing Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let shareTokenService: ShareTokenService; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, 'sharetoken'); + shareTokenService = module.get(ShareTokenService); + }); + + beforeEach(async () => { + await cleanupCollections(em); + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', true); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /sharetoken/:token/import', () => { + const setup = async () => { + const room = roomEntityFactory.buildWithId(); + const role = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ role, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const board = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role, board]); + em.clear(); + + const shareToken = await shareTokenService.createToken({ + parentId: board.id, + parentType: ShareTokenParentType.ColumnBoard, + }); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, token: shareToken.token, room }; + }; + + describe('when the feature is disabled', () => { + beforeEach(() => { + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', false); + }); + + it('should return a 403 error', async () => { + const { loggedInClient, token } = await setup(); + const response = await loggedInClient.post(`${token}/import`, { newName: 'NewName' }); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when the user has the required permissions', () => { + describe('when the destination is omitted', () => { + it('should return a 401 status', async () => { + const { loggedInClient, token } = await setup(); + const response = await loggedInClient.post(`${token}/import`, { newName: 'NewName' }); + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when the destination is valid', () => { + it('should return a 201 status', async () => { + const { loggedInClient, token, room } = await setup(); + const response = await loggedInClient.post(`${token}/import`, { newName: 'NewName', destinationId: room.id }); + expect(response.status).toBe(HttpStatus.CREATED); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts index 9386b965857..ff24d2ed78b 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts @@ -1,211 +1,172 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; import { EntityManager } from '@mikro-orm/mongodb'; import { CopyApiResponse, CopyElementType, CopyStatusEnum } from '@modules/copy-helper'; import { ServerTestModule } from '@modules/server'; -import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ApiValidationError } from '@shared/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { cleanupCollections, courseFactory, - mapUserToCurrentUser, roleFactory, schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { Request } from 'express'; -import request from 'supertest'; import { ShareTokenContext, ShareTokenContextType, ShareTokenParentType } from '../../domainobject/share-token.do'; import { ShareTokenService } from '../../service'; -import { ShareTokenImportBodyParams, ShareTokenResponse, ShareTokenUrlParams } from '../dto'; -const baseRouteName = '/sharetoken'; - -class API { - app: INestApplication; - - constructor(app: INestApplication) { - this.app = app; - } - - async post(urlParams: ShareTokenUrlParams, body: ShareTokenImportBodyParams) { - const response = await request(this.app.getHttpServer()) - .post(`${baseRouteName}/${urlParams.token}/import`) - .set('Accept', 'application/json') - .set('Authorization', 'jwt') - .send(body); - - return { - result: response.body as ShareTokenResponse, - error: response.body as ApiValidationError, - status: response.status, - }; - } -} - -describe(`share token import (api)`, () => { +describe(`Share Token Import (API)`, () => { let app: INestApplication; let em: EntityManager; - let currentUser: ICurrentUser; + let testApiClient: TestApiClient; let shareTokenService: ShareTokenService; - let api: API; beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ + const module = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = module.createNestApplication(); await app.init(); em = module.get(EntityManager); + testApiClient = new TestApiClient(app, 'sharetoken'); shareTokenService = module.get(ShareTokenService); - - api = new API(app); }); - afterAll(async () => { - await app.close(); - }); - - beforeEach(() => { + beforeEach(async () => { + await cleanupCollections(em); Configuration.set('FEATURE_COURSE_SHARE', true); }); - const setup = async (context?: ShareTokenContext) => { - await cleanupCollections(em); - const school = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.COURSE_CREATE], - }); - const user = userFactory.build({ school, roles }); - const course = courseFactory.build({ teachers: [user] }); - await em.persistAndFlush([user, course]); - - const shareToken = await shareTokenService.createToken( - { - parentType: ShareTokenParentType.Course, - parentId: course.id, - }, - { context } - ); - - em.clear(); - - currentUser = mapUserToCurrentUser(user); - - return { - token: shareToken.token, - elementType: CopyElementType.COURSE, - }; - }; - - describe('with the feature disabled', () => { - it('should return status 500', async () => { - Configuration.set('FEATURE_COURSE_SHARE', false); - const { token } = await setup(); - - const response = await api.post({ token }, { newName: 'NewName' }); - - expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); - }); + afterAll(async () => { + await app.close(); }); - describe('with a valid token', () => { - it('should return status 201', async () => { - const { token } = await setup(); - - const response = await api.post({ token }, { newName: 'NewName' }); - - expect(response.status).toEqual(HttpStatus.CREATED); + describe('POST /sharetoken/:token/import', () => { + describe('when the user is not authenticated', () => { + it('should return a 401 error', async () => { + const token = 'aaLnAEZ0xqIW'; + const response = await testApiClient.post(`${token}/import`, { + newName: 'NewName', + }); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); }); - it('should return a valid result', async () => { - const { token, elementType } = await setup(); - const newName = 'NewName'; - const response = await api.post({ token }, { newName }); - - const expectedResult: CopyApiResponse = { - id: expect.any(String), - type: elementType, - title: newName, - status: CopyStatusEnum.SUCCESS, + describe('when the user is valid', () => { + const setup = async (context?: ShareTokenContext) => { + const school = schoolEntityFactory.build(); + const roles = roleFactory.buildList(1, { + permissions: [Permission.COURSE_CREATE], + }); + const user = userFactory.build({ school, roles }); + const course = courseFactory.build({ teachers: [user] }); + await em.persistAndFlush([user, course]); + + const shareToken = await shareTokenService.createToken( + { + parentType: ShareTokenParentType.Course, + parentId: course.id, + }, + { context } + ); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + token: shareToken.token, + elementType: CopyElementType.COURSE, + }; }; - expect(response.result).toEqual(expect.objectContaining(expectedResult)); - }); - }); - - describe('with invalid token', () => { - it('should return status 404', async () => { - await setup(); - - const response = await api.post({ token: 'invalid_token' }, { newName: 'NewName' }); - - expect(response.status).toEqual(HttpStatus.NOT_FOUND); - }); - }); - - describe('with invalid context', () => { - const setup2 = async () => { - const school = schoolEntityFactory.build(); - const otherSchool = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.COURSE_CREATE], + describe('with the feature disabled', () => { + beforeEach(() => { + Configuration.set('FEATURE_COURSE_SHARE', false); + }); + + it('should return a 403 error', async () => { + const { loggedInClient, token } = await setup(); + const response = await loggedInClient.post(`${token}/import`, { + newName: 'NewName', + }); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); }); - const user = userFactory.build({ school, roles }); - const course = courseFactory.build({ teachers: [user] }); - await em.persistAndFlush([user, course, otherSchool]); - - const context = { - contextType: ShareTokenContextType.School, - contextId: otherSchool.id, - }; - - const shareToken = await shareTokenService.createToken( - { - parentType: ShareTokenParentType.Course, - parentId: course.id, - }, - { context } - ); + describe('with a valid token', () => { + it('should return status 201', async () => { + const { loggedInClient, token } = await setup(); + + const response = await loggedInClient.post(`${token}/import`, { + newName: 'NewName', + }); + + expect(response.status).toEqual(HttpStatus.CREATED); + }); + + it('should return a valid result', async () => { + const { loggedInClient, token, elementType } = await setup(); + const newName = 'NewName'; + const response = await loggedInClient.post(`${token}/import`, { + newName, + }); + const expectedResult: CopyApiResponse = { + id: expect.any(String), + type: elementType, + title: newName, + status: CopyStatusEnum.SUCCESS, + }; + + expect(response.body as CopyApiResponse).toEqual(expect.objectContaining(expectedResult)); + }); + }); - em.clear(); + describe('with invalid token', () => { + it('should return status 404', async () => { + const { loggedInClient } = await setup(); - currentUser = mapUserToCurrentUser(user); + const response = await loggedInClient.post(`invalid_token/import`, { + newName: 'NewName', + }); - return { - shareTokenFromDifferentCourse: shareToken.token, - }; - }; + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); - it('should return status 403', async () => { - const { shareTokenFromDifferentCourse } = await setup2(); + describe('with invalid context', () => { + it('should return status 403', async () => { + const otherSchool = schoolEntityFactory.build(); + await em.persistAndFlush(otherSchool); - const response = await api.post({ token: shareTokenFromDifferentCourse }, { newName: 'NewName' }); + const { loggedInClient, token: tokenFromOtherSchool } = await setup({ + contextId: otherSchool.id, + contextType: ShareTokenContextType.School, + }); - expect(response.status).toEqual(HttpStatus.FORBIDDEN); - }); - }); + const response = await loggedInClient.post(`${tokenFromOtherSchool}/import`, { + newName: 'NewName', + }); - describe('with invalid new name', () => { - it('should return status 501', async () => { - const { token } = await setup(); - // @ts-expect-error invalid new name - const response = await api.post({ token }, { newName: 42 }); + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); - expect(response.status).toEqual(HttpStatus.NOT_IMPLEMENTED); + describe('with invalid new name', () => { + it('should return status 501', async () => { + const { loggedInClient, token } = await setup(); + const response = await loggedInClient.post(`${token}/import`, { + newName: 42, + }); + expect(response.status).toEqual(HttpStatus.NOT_IMPLEMENTED); + }); + }); }); }); }); diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts index 57498162304..adf854d9b3d 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts @@ -59,18 +59,12 @@ describe(`share token lookup (api)`, () => { }; }; - it('should return status 500', async () => { + it('should return status 403', async () => { const { token, loggedInClient } = await setup(); const response = await loggedInClient.get(token); - expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); - expect(response.body).toEqual({ - code: 500, - message: 'Import Course Feature not enabled', - title: 'Internal Server Error', - type: 'INTERNAL_SERVER_ERROR', - }); + expect(response.status).toEqual(HttpStatus.FORBIDDEN); }); }); diff --git a/apps/server/src/modules/sharing/controller/dto/share-token-import.body.params.ts b/apps/server/src/modules/sharing/controller/dto/share-token-import.body.params.ts index 420d00c3758..28453a6a4c7 100644 --- a/apps/server/src/modules/sharing/controller/dto/share-token-import.body.params.ts +++ b/apps/server/src/modules/sharing/controller/dto/share-token-import.body.params.ts @@ -15,9 +15,9 @@ export class ShareTokenImportBodyParams { @IsOptional() @IsString() @ApiProperty({ - description: 'Id of the course to which the lesson/task will be added', + description: 'Id of the parent to which the imported object will be added.', required: false, nullable: true, }) - destinationCourseId?: string; + destinationId?: string; } diff --git a/apps/server/src/modules/sharing/controller/share-token.controller.ts b/apps/server/src/modules/sharing/controller/share-token.controller.ts index 5320c5578f9..d85937cbb62 100644 --- a/apps/server/src/modules/sharing/controller/share-token.controller.ts +++ b/apps/server/src/modules/sharing/controller/share-token.controller.ts @@ -90,7 +90,7 @@ export class ShareTokenController { currentUser.userId, urlParams.token, body.newName, - body.destinationCourseId + body.destinationId ); const response = CopyMapper.mapToResponse(copyStatus); diff --git a/apps/server/src/modules/sharing/sharing-api.module.ts b/apps/server/src/modules/sharing/sharing-api.module.ts new file mode 100644 index 00000000000..3c30c03a883 --- /dev/null +++ b/apps/server/src/modules/sharing/sharing-api.module.ts @@ -0,0 +1,31 @@ +import { AuthorizationModule } from '@modules/authorization'; +import { BoardModule } from '@modules/board'; +import { LearnroomModule } from '@modules/learnroom'; +import { LessonModule } from '@modules/lesson'; +import { RoomModule } from '@modules/room'; +import { RoomMembershipModule } from '@src/modules/room-membership'; +import { SchoolModule } from '@modules/school'; +import { TaskModule } from '@modules/task'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { ShareTokenController } from './controller/share-token.controller'; +import { SharingModule } from './sharing.module'; +import { ShareTokenUC } from './uc'; + +@Module({ + imports: [ + SharingModule, + AuthorizationModule, + LearnroomModule, + LessonModule, + TaskModule, + BoardModule, + RoomMembershipModule, + RoomModule, + SchoolModule, + LoggerModule, + ], + controllers: [ShareTokenController], + providers: [ShareTokenUC], +}) +export class SharingApiModule {} diff --git a/apps/server/src/modules/sharing/sharing.module.ts b/apps/server/src/modules/sharing/sharing.module.ts index 71773a87a6f..df417337f1e 100644 --- a/apps/server/src/modules/sharing/sharing.module.ts +++ b/apps/server/src/modules/sharing/sharing.module.ts @@ -1,46 +1,17 @@ -import { AuthorizationModule } from '@modules/authorization'; -import { AuthorizationReferenceModule } from '@modules/authorization-reference/authorization-reference.module'; import { BoardModule } from '@modules/board'; import { LearnroomModule } from '@modules/learnroom'; import { LessonModule } from '@modules/lesson'; -import { SchoolModule } from '@modules/school'; import { TaskModule } from '@modules/task'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { ShareTokenController } from './controller/share-token.controller'; +import { RoomModule } from '../room'; import { ShareTokenRepo } from './repo/share-token.repo'; import { ShareTokenService, TokenGenerator } from './service'; -import { ShareTokenUC } from './uc'; @Module({ - imports: [ - AuthorizationModule, - AuthorizationReferenceModule, - LoggerModule, - LearnroomModule, - LessonModule, - TaskModule, - BoardModule, - ], + imports: [LoggerModule, LearnroomModule, LessonModule, TaskModule, BoardModule, RoomModule], controllers: [], providers: [ShareTokenService, TokenGenerator, ShareTokenRepo], exports: [ShareTokenService], }) export class SharingModule {} - -@Module({ - imports: [ - SharingModule, - AuthorizationModule, - AuthorizationReferenceModule, - LearnroomModule, - LessonModule, - TaskModule, - LoggerModule, - BoardModule, - SchoolModule, - ], - controllers: [ShareTokenController], - providers: [ShareTokenUC], -}) -export class SharingApiModule {} diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts index 4b33a73d438..193f4606285 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts @@ -3,15 +3,19 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { BoardExternalReferenceType, BoardNodeAuthorizableService, ColumnBoardService } from '@modules/board'; +import { CopyColumnBoardParams } from '@modules/board/service/internal'; import { boardNodeAuthorizableFactory, columnBoardFactory } from '@modules/board/testing'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { StorageLocation } from '@modules/files-storage/interface'; import { CourseCopyService, CourseService } from '@modules/learnroom'; import { LessonCopyService, LessonService } from '@modules/lesson'; +import { RoomService } from '@modules/room'; import { SchoolService } from '@modules/school'; import { schoolFactory } from '@modules/school/testing'; import { TaskCopyService, TaskService } from '@modules/task'; -import { BadRequestException, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; +import { BadRequestException, NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Permission } from '@shared/domain/interface'; import { courseFactory, @@ -23,6 +27,7 @@ import { userFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; +import { RoomMembershipService } from '@src/modules/room-membership'; import { ShareTokenContextType, ShareTokenParentType, ShareTokenPayload } from '../domainobject/share-token.do'; import { ShareTokenService } from '../service'; import { ShareTokenUC } from './share-token.uc'; @@ -82,6 +87,14 @@ describe('ShareTokenUC', () => { provide: TaskService, useValue: createMock(), }, + { + provide: RoomService, + useValue: createMock(), + }, + { + provide: RoomMembershipService, + useValue: createMock(), + }, { provide: ColumnBoardService, useValue: createMock(), @@ -673,6 +686,8 @@ describe('ShareTokenUC', () => { const shareToken = shareTokenFactory.build({ payload }); service.lookupTokenWithParentName.mockResolvedValueOnce({ shareToken, parentName: columnBoard.title }); + columnBoardService.findById.mockResolvedValueOnce(columnBoard); + return { user, shareToken, columnBoard, course }; }; @@ -849,7 +864,7 @@ describe('ShareTokenUC', () => { Configuration.set('FEATURE_COURSE_SHARE', false); await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( - InternalServerErrorException + FeatureDisabledLoggableException ); }); @@ -922,11 +937,11 @@ describe('ShareTokenUC', () => { Configuration.set('FEATURE_LESSON_SHARE', false); await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( - InternalServerErrorException + FeatureDisabledLoggableException ); }); - it('should throw if the destinationCourseId is not passed', async () => { + it('should throw if the destinationId is not passed', async () => { const { user, shareToken } = setup(); await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( @@ -1008,11 +1023,11 @@ describe('ShareTokenUC', () => { Configuration.set('FEATURE_TASK_SHARE', false); await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( - InternalServerErrorException + FeatureDisabledLoggableException ); }); - it('should throw if the destinationCourseId is not passed', async () => { + it('should throw if the destinationId is not passed', async () => { const { user, shareToken } = setupTask(); await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( @@ -1070,9 +1085,10 @@ describe('ShareTokenUC', () => { const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const course = courseFactory.buildWithId(); - courseService.findById.mockResolvedValueOnce(course); + courseService.findById.mockResolvedValue(course); const columnBoard = columnBoardFactory.build(); + columnBoardService.findById.mockResolvedValue(columnBoard); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const payload: ShareTokenPayload = { parentType: ShareTokenParentType.ColumnBoard, parentId: columnBoard.id }; @@ -1090,7 +1106,7 @@ describe('ShareTokenUC', () => { const { user, shareToken } = setup(); Configuration.set('FEATURE_COLUMN_BOARD_SHARE', false); await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( - InternalServerErrorException + FeatureDisabledLoggableException ); }); it('should check the permission to create the columnboard', async () => { @@ -1116,9 +1132,11 @@ describe('ShareTokenUC', () => { const { user, shareToken, course, columnBoard } = setup(); const newName = 'NewName'; await uc.importShareToken(user.id, shareToken.token, newName, course.id); - expect(columnBoardService.copyColumnBoard).toHaveBeenCalledWith({ + expect(columnBoardService.copyColumnBoard).toHaveBeenCalledWith({ originalColumnBoardId: columnBoard.id, - destinationExternalReference: { type: BoardExternalReferenceType.Course, id: course.id }, + targetExternalReference: { type: BoardExternalReferenceType.Course, id: course.id }, + sourceStorageLocationReference: { type: StorageLocation.SCHOOL, id: course.school.id }, + targetStorageLocationReference: { type: StorageLocation.SCHOOL, id: course.school.id }, userId: user.id, copyTitle: newName, }); diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.ts b/apps/server/src/modules/sharing/uc/share-token.uc.ts index e8e0babd7bf..af4a079f10e 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.ts @@ -1,16 +1,26 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { + BoardExternalReference, + BoardExternalReferenceType, + BoardNodeAuthorizableService, + ColumnBoardService, +} from '@modules/board'; +import { StorageLocationReference } from '@modules/board/service/internal'; import { CopyStatus } from '@modules/copy-helper'; +import { StorageLocation } from '@modules/files-storage/interface'; import { CourseCopyService, CourseService } from '@modules/learnroom'; import { LessonCopyService, LessonService } from '@modules/lesson'; +import { RoomService } from '@modules/room'; +import { SchoolService } from '@modules/school'; import { TaskCopyService, TaskService } from '@modules/task'; -import { BadRequestException, Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; -import { BoardNodeAuthorizableService, BoardExternalReferenceType, ColumnBoardService } from '@modules/board'; +import { BadRequestException, Injectable, NotImplementedException } from '@nestjs/common'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Course, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; -import { SchoolService } from '@src/modules/school'; +import { RoomMembershipService } from '@src/modules/room-membership'; import { ShareTokenContext, ShareTokenContextType, @@ -29,10 +39,12 @@ export class ShareTokenUC { private readonly courseCopyService: CourseCopyService, private readonly lessonCopyService: LessonCopyService, private readonly taskCopyService: TaskCopyService, - private readonly columnBoardService: ColumnBoardService, private readonly courseService: CourseService, private readonly lessonService: LessonService, private readonly taskService: TaskService, + private readonly roomService: RoomService, + private readonly roomMembershipService: RoomMembershipService, + private readonly columnBoardService: ColumnBoardService, private readonly schoolService: SchoolService, private readonly boardNodeAuthorizableService: BoardNodeAuthorizableService, private readonly logger: LegacyLogger @@ -75,7 +87,7 @@ export class ShareTokenUC { this.checkFeatureEnabled(shareToken.payload.parentType); - await this.checkLookupPermission(userId, shareToken.payload.parentType); + await this.checkTokenLookupPermission(userId, shareToken.payload); if (shareToken.context) { await this.checkContextReadPermission(userId, shareToken.context); @@ -94,7 +106,7 @@ export class ShareTokenUC { userId: EntityId, token: string, newName: string, - destinationCourseId?: string + destinationId?: EntityId ): Promise { this.logger.debug({ action: 'importShareToken', userId, token, newName }); @@ -115,22 +127,22 @@ export class ShareTokenUC { result = await this.copyCourse(user, shareToken.payload.parentId, newName); break; case ShareTokenParentType.Lesson: - if (destinationCourseId === undefined) { - throw new BadRequestException('Destination course id is required to copy lesson'); + if (destinationId === undefined) { + throw new BadRequestException('Cannot copy lesson without destination course reference'); } - result = await this.copyLesson(user, shareToken.payload.parentId, destinationCourseId, newName); + result = await this.copyLesson(user, shareToken.payload.parentId, destinationId, newName); break; case ShareTokenParentType.Task: - if (destinationCourseId === undefined) { - throw new BadRequestException('Destination course id is required to copy task'); + if (destinationId === undefined) { + throw new BadRequestException('Cannot copy task without destination course reference'); } - result = await this.copyTask(user, shareToken.payload.parentId, destinationCourseId, newName); + result = await this.copyTask(user, shareToken.payload.parentId, destinationId, newName); break; case ShareTokenParentType.ColumnBoard: - if (destinationCourseId === undefined) { - throw new BadRequestException('Destination course id is required to copy task'); + if (destinationId === undefined) { + throw new BadRequestException('Cannot copy board without destination course or room reference'); } - result = await this.copyColumnBoard(user, shareToken.payload.parentId, destinationCourseId, newName); + result = await this.copyColumnBoard(user, shareToken.payload.parentId, destinationId, newName); break; } @@ -180,14 +192,26 @@ export class ShareTokenUC { private async copyColumnBoard( user: User, originalColumnBoardId: string, - courseId: string, + destinationId: EntityId, copyTitle?: string ): Promise { - await this.checkCourseWritePermission(user, courseId, Permission.COURSE_EDIT); + const originalBoard = await this.columnBoardService.findById(originalColumnBoardId, 0); + + const targetExternalReference: BoardExternalReference = { + id: destinationId, + type: originalBoard.context.type, + }; + + await this.checkBoardContextWritePermission(user, targetExternalReference); + + const sourceStorageLocationReference = await this.getStorageLocationReference(originalBoard.context); + const targetStorageLocationReference = await this.getStorageLocationReference(targetExternalReference); const copyStatus = this.columnBoardService.copyColumnBoard({ originalColumnBoardId, - destinationExternalReference: { type: BoardExternalReferenceType.Course, id: courseId }, + targetExternalReference, + sourceStorageLocationReference, + targetStorageLocationReference, userId: user.id, copyTitle, }); @@ -206,7 +230,7 @@ export class ShareTokenUC { await this.checkTaskWritePermission(user, payload.parentId, Permission.HOMEWORK_CREATE); break; case ShareTokenParentType.ColumnBoard: - await this.checkColumnBoardWritePermission(user, payload.parentId, Permission.COURSE_EDIT); + await this.checkColumnBoardSharePermission(user, payload.parentId); break; default: } @@ -225,6 +249,16 @@ export class ShareTokenUC { }; } + private async checkRoomWritePermission(user: User, roomId: EntityId, permissions: Permission[] = []) { + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(roomId); + + this.authorizationService.checkPermission( + user, + roomMembershipAuthorizable, + AuthorizationContextBuilder.write(permissions) + ); + } + private async checkLessonWritePermission(user: User, lessonId: EntityId, permission: Permission) { const lesson = await this.lessonService.findById(lessonId); this.authorizationService.checkPermission(user, lesson, AuthorizationContextBuilder.write([permission])); @@ -235,14 +269,15 @@ export class ShareTokenUC { this.authorizationService.checkPermission(user, task, AuthorizationContextBuilder.write([permission])); } - private async checkColumnBoardWritePermission(user: User, boardNodeId: EntityId, permission: Permission) { - const columBoard = await this.columnBoardService.findById(boardNodeId); - const boardNodeAuthorizableService = await this.boardNodeAuthorizableService.getBoardAuthorizable(columBoard); + private async checkColumnBoardSharePermission(user: User, boardNodeId: EntityId) { + const columBoard = await this.columnBoardService.findById(boardNodeId, 0); + const boardNodeAuthorizable = await this.boardNodeAuthorizableService.getBoardAuthorizable(columBoard); + const permissions = columBoard.context.type === BoardExternalReferenceType.Course ? [Permission.COURSE_EDIT] : []; this.authorizationService.checkPermission( user, - boardNodeAuthorizableService, - AuthorizationContextBuilder.write([permission]) + boardNodeAuthorizable, + AuthorizationContextBuilder.write(permissions) ); } @@ -263,11 +298,11 @@ export class ShareTokenUC { } } - private async checkLookupPermission(userId: EntityId, parentType: ShareTokenParentType) { + private async checkTokenLookupPermission(userId: EntityId, payload: ShareTokenPayload) { const user = await this.authorizationService.getUserWithPermissions(userId); let requiredPermissions: Permission[] = []; // eslint-disable-next-line default-case - switch (parentType) { + switch (payload.parentType) { case ShareTokenParentType.Course: requiredPermissions = [Permission.COURSE_CREATE]; break; @@ -277,9 +312,12 @@ export class ShareTokenUC { case ShareTokenParentType.Task: requiredPermissions = [Permission.HOMEWORK_CREATE]; break; - case ShareTokenParentType.ColumnBoard: - requiredPermissions = [Permission.COURSE_EDIT]; + case ShareTokenParentType.ColumnBoard: { + const columnBoard = await this.columnBoardService.findById(payload.parentId, 0); + requiredPermissions = + columnBoard.context.type === BoardExternalReferenceType.Course ? [Permission.COURSE_EDIT] : []; break; + } } this.authorizationService.checkAllPermissions(user, requiredPermissions); } @@ -295,29 +333,58 @@ export class ShareTokenUC { case ShareTokenParentType.Course: // Configuration.get is the deprecated way to read envirment variables if (!(Configuration.get('FEATURE_COURSE_SHARE') as boolean)) { - throw new InternalServerErrorException('Import Course Feature not enabled'); + throw new FeatureDisabledLoggableException('FEATURE_COURSE_SHARE'); } break; case ShareTokenParentType.Lesson: // Configuration.get is the deprecated way to read envirment variables if (!(Configuration.get('FEATURE_LESSON_SHARE') as boolean)) { - throw new InternalServerErrorException('Import Lesson Feature not enabled'); + throw new FeatureDisabledLoggableException('FEATURE_LESSON_SHARE'); } break; case ShareTokenParentType.Task: // Configuration.get is the deprecated way to read envirment variables if (!(Configuration.get('FEATURE_TASK_SHARE') as boolean)) { - throw new InternalServerErrorException('Import Task Feature not enabled'); + throw new FeatureDisabledLoggableException('FEATURE_TASK_SHARE'); } break; case ShareTokenParentType.ColumnBoard: // Configuration.get is the deprecated way to read envirment variables if (!(Configuration.get('FEATURE_COLUMN_BOARD_SHARE') as boolean)) { - throw new InternalServerErrorException('Import Task Feature not enabled'); + throw new FeatureDisabledLoggableException('FEATURE_COLUMN_BOARD_SHARE'); } break; default: throw new NotImplementedException('Import Feature not implemented'); } } + + // ---- Move to shared service? (see apps/server/src/modules/board/uc/board.uc.ts) + + private async checkBoardContextWritePermission(user: User, boardContext: BoardExternalReference) { + if (boardContext.type === BoardExternalReferenceType.Course) { + await this.checkCourseWritePermission(user, boardContext.id, Permission.COURSE_EDIT); + } else if (boardContext.type === BoardExternalReferenceType.Room) { + await this.checkRoomWritePermission(user, boardContext.id); + } else { + /* istanbul ignore next */ + throw new Error(`Unsupported board reference type ${boardContext.type as string}`); + } + } + + private async getStorageLocationReference(boardContext: BoardExternalReference): Promise { + if (boardContext.type === BoardExternalReferenceType.Course) { + const course = await this.courseService.findById(boardContext.id); + + return { id: course.school.id, type: StorageLocation.SCHOOL }; + } + + if (boardContext.type === BoardExternalReferenceType.Room) { + const room = await this.roomService.getSingleRoom(boardContext.id); + + return { id: room.schoolId, type: StorageLocation.SCHOOL }; + } + /* istanbul ignore next */ + throw new Error(`Unsupported board reference type ${boardContext.type as string}`); + } } diff --git a/apps/server/src/shared/controller/transformer/index.ts b/apps/server/src/shared/controller/transformer/index.ts index 889f33130c7..1a037feef76 100644 --- a/apps/server/src/shared/controller/transformer/index.ts +++ b/apps/server/src/shared/controller/transformer/index.ts @@ -4,3 +4,4 @@ export * from './single-value-to-array.transformer'; export * from './sanitize-html.transformer'; export { PolymorphicArrayTransform } from './polymorphic-array.transformer'; export { StringToObject } from './string-to-object.transformer'; +export { NullToUndefined } from './null-to-undefined.transformer'; diff --git a/apps/server/src/shared/controller/transformer/null-to-undefined.transformer.spec.ts b/apps/server/src/shared/controller/transformer/null-to-undefined.transformer.spec.ts new file mode 100644 index 00000000000..490e8745be6 --- /dev/null +++ b/apps/server/src/shared/controller/transformer/null-to-undefined.transformer.spec.ts @@ -0,0 +1,35 @@ +import { plainToClass } from 'class-transformer'; +import { NullToUndefined } from './null-to-undefined.transformer'; + +describe('NullToUndefined Decorator', () => { + describe('when transforming an optionl value', () => { + class WithOptionalDto { + @NullToUndefined() + optionalDate?: Date; + } + + it('should transform from `null` to `undefined`', () => { + const props = { optionalDate: null }; + const instance = plainToClass(WithOptionalDto, props); + expect(instance.optionalDate).toEqual(undefined); + }); + + it('should transform from `undefined` to `undefined`', () => { + const props = { optionalDate: undefined }; + const instance = plainToClass(WithOptionalDto, props); + expect(instance.optionalDate).toEqual(undefined); + }); + + it('should transform from omitted property to `undefined`', () => { + const props = {}; + const instance = plainToClass(WithOptionalDto, props); + expect(instance.optionalDate).toEqual(undefined); + }); + + it('should transform from value to value', () => { + const props = { optionalDate: new Date() }; + const instance = plainToClass(WithOptionalDto, props); + expect(instance.optionalDate).toEqual(props.optionalDate); + }); + }); +}); diff --git a/apps/server/src/shared/controller/transformer/null-to-undefined.transformer.ts b/apps/server/src/shared/controller/transformer/null-to-undefined.transformer.ts new file mode 100644 index 00000000000..1be36c10825 --- /dev/null +++ b/apps/server/src/shared/controller/transformer/null-to-undefined.transformer.ts @@ -0,0 +1,12 @@ +import { Transform } from 'class-transformer'; + +/** + * Decorator to replace a null value by undefined. + * Can be used to make optinal values consistent. + * Place after IsOptional decorator. + * It will return undefined if the value is null. + * @returns + */ +export function NullToUndefined(): PropertyDecorator { + return Transform(({ value }): unknown => (value === null ? undefined : value)); +} diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 45d69efea23..ff570b7c6ac 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -11,6 +11,7 @@ import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; import { RoomEntity } from '@modules/room/repo/entity'; +import { RoomMembershipEntity } from '@modules/room-membership/repo/entity/room-membership.entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { SystemEntity } from '@modules/system/entity/system.entity'; import { TldrawDrawing } from '@modules/tldraw/entities'; @@ -19,7 +20,6 @@ import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { ImportUser } from '@modules/user-import/entity'; import { MediaSourceEntity, MediaUserLicenseEntity, UserLicenseEntity } from '@modules/user-license/entity'; -import { RoomMemberEntity } from '@src/modules/room-member/repo/entity/room-member.entity'; import { ColumnBoardNode } from './column-board-node.entity'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; @@ -77,7 +77,7 @@ export const ALL_ENTITIES = [ RocketChatUserEntity, Role, RoomEntity, - RoomMemberEntity, + RoomMembershipEntity, SchoolEntity, SchoolExternalToolEntity, SchoolNews, diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index 125e027653f..c5bed37ad11 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -100,8 +100,10 @@ export enum Permission { ROLE_CREATE = 'ROLE_CREATE', ROLE_EDIT = 'ROLE_EDIT', ROLE_VIEW = 'ROLE_VIEW', + ROOM_CREATE = 'ROOM_CREATE', ROOM_EDIT = 'ROOM_EDIT', ROOM_VIEW = 'ROOM_VIEW', + ROOM_DELETE = 'ROOM_DELETE', SCHOOL_CHAT_MANAGE = 'SCHOOL_CHAT_MANAGE', SCHOOL_CREATE = 'SCHOOL_CREATE', SCHOOL_EDIT = 'SCHOOL_EDIT', diff --git a/apps/server/src/shared/domain/interface/rolename.enum.ts b/apps/server/src/shared/domain/interface/rolename.enum.ts index 0fa35e22862..e354109efd3 100644 --- a/apps/server/src/shared/domain/interface/rolename.enum.ts +++ b/apps/server/src/shared/domain/interface/rolename.enum.ts @@ -11,8 +11,8 @@ export enum RoleName { GUESTTEACHER = 'guestTeacher', GUESTSTUDENT = 'guestStudent', HELPDESK = 'helpdesk', - ROOM_VIEWER = 'room_viewer', - ROOM_EDITOR = 'room_editor', + ROOMVIEWER = 'roomviewer', + ROOMEDITOR = 'roomeditor', STUDENT = 'student', SUPERHERO = 'superhero', TEACHER = 'teacher', @@ -32,7 +32,7 @@ export type IUserRoleName = | RoleName.DEMOSTUDENT | RoleName.DEMOTEACHER; -export const RoomRoleArray = [RoleName.ROOM_EDITOR, RoleName.ROOM_VIEWER] as const; +export const RoomRoleArray = [RoleName.ROOMEDITOR, RoleName.ROOMVIEWER] as const; export type RoomRole = typeof RoomRoleArray[number]; export const GuestRoleArray = [RoleName.GUESTSTUDENT, RoleName.GUESTTEACHER] as const; diff --git a/apps/server/src/shared/domain/types/task.types.ts b/apps/server/src/shared/domain/types/task.types.ts index c9ce4ebb698..a5812f5e522 100644 --- a/apps/server/src/shared/domain/types/task.types.ts +++ b/apps/server/src/shared/domain/types/task.types.ts @@ -1,5 +1,5 @@ -import type { Course, LessonEntity, SchoolEntity, Submission, User } from '@shared/domain/entity'; -import type { InputFormat } from '@shared/domain/types'; +import type { Course, LessonEntity, SchoolEntity, Submission, User } from '../entity'; +import type { InputFormat } from './input-format.types'; interface ITask { name: string; diff --git a/apps/server/src/shared/testing/user-role-permissions.ts b/apps/server/src/shared/testing/user-role-permissions.ts index 378ba8859d1..e8e49a85dc2 100644 --- a/apps/server/src/shared/testing/user-role-permissions.ts +++ b/apps/server/src/shared/testing/user-role-permissions.ts @@ -103,6 +103,7 @@ export const teacherPermissions = [ Permission.TOPIC_EDIT, Permission.START_MEETING, Permission.CONTEXT_TOOL_ADMIN, + Permission.ROOM_CREATE, ]; export const adminPermissions = [ diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index f31be452adb..1679b78b973 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -262,11 +262,38 @@ }, { "_id": { - "$oid": "674847ac3c76d17b0c01c155" + "$oid": "673237e9b0955dcff4cde3d9" }, - "name": "Migration20241127134513", + "name": "Migration20241111160412", "created_at": { - "$date": "2024-11-28T10:37:51.515Z" + "$date": "2024-11-11T16:59:21.768Z" + } + }, + { + "_id": { + "$oid": "673387c13aba1e283484119d" + }, + "name": "Migration20241112163538", + "created_at": { + "$date": "2024-11-12T16:52:17.292Z" + } + }, + { + "_id": { + "$oid": "67347bb8b1bcb78aecbab90f" + }, + "name": "Migration20241113100535", + "created_at": { + "$date": "2024-11-13T10:13:12.411Z" + } + }, + { + "_id": { + "$oid": "67361aa7776f2f3e5a519735" + }, + "name": "Migration20241113152001", + "created_at": { + "$date": "2024-11-14T15:43:35.024Z" } }, { @@ -277,5 +304,32 @@ "created_at": { "$date": "2024-11-25T09:32:22.556Z" } + }, + { + "_id": { + "$oid": "674847ac3c76d17b0c01c155" + }, + "name": "Migration20241127134513", + "created_at": { + "$date": "2024-11-28T10:37:51.515Z" + } + }, + { + "_id": { + "$oid": "67477a7455d881b78f7a79fa" + }, + "name": "Migration20241127195120", + "created_at": { + "$date": "2024-11-27T20:00:52.582Z" + } + }, + { + "_id": { + "$oid": "6748b0f451c62e9dc6899983" + }, + "name": "Migration20241128155801", + "created_at": { + "$date": "2024-11-28T18:05:40.839Z" + } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 800ad0bc742..81c1b5bc4af 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -214,7 +214,7 @@ }, "name": "teacher", "updatedAt": { - "$date": "2024-04-19T10:32:51.070Z" + "$date": "2024-11-12T16:35:53.763Z" }, "createdAt": { "$date": "2017-01-01T00:06:37.148Z" @@ -263,7 +263,8 @@ "USERGROUP_CREATE", "USERGROUP_EDIT", "USER_CHANGE_OWN_NAME", - "USER_CREATE" + "USER_CREATE", + "ROOM_CREATE" ], "__v": 2 }, @@ -586,7 +587,7 @@ "_id": { "$oid": "6720b8621b61c9dd7ebd193b" }, - "name": "room_viewer", + "name": "roomviewer", "permissions": [ "ROOM_VIEW" ] @@ -595,10 +596,11 @@ "_id": { "$oid": "6720b8621b61c9dd7ebd193c" }, - "name": "room_editor", + "name": "roomeditor", "permissions": [ "ROOM_VIEW", - "ROOM_EDIT" + "ROOM_EDIT", + "ROOM_DELETE" ] }, { From 1aa919abf55373fd3365684cf5f69173234d31a3 Mon Sep 17 00:00:00 2001 From: mkreuzkam-cap <144103168+mkreuzkam-cap@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:39:36 +0100 Subject: [PATCH 11/17] EW-1081: Make ptscListKlasseId optional in TspJwtPayload. (#5380) --- .../provisioning/service/tsp-provisioning.service.ts | 2 +- .../provisioning/strategy/tsp/tsp.jwt.payload.ts | 4 ++-- .../modules/provisioning/strategy/tsp/tsp.strategy.ts | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts index 99b087ccea6..d4948292985 100644 --- a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts @@ -188,7 +188,7 @@ export class TspProvisioningService { return email.toLowerCase(); } - private createTspConsent(user: UserDO) { + private createTspConsent(user: UserDO): void { const userConsent = new UserConsent({ form: 'digital', privacyConsent: true, diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.jwt.payload.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.jwt.payload.ts index 1b4e75fed35..231b22b4257 100644 --- a/apps/server/src/modules/provisioning/strategy/tsp/tsp.jwt.payload.ts +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.jwt.payload.ts @@ -27,8 +27,8 @@ export class TspJwtPayload implements JwtPayload { public ptscSchuleNummer!: string; @IsString() - @IsNotEmpty() - public ptscListKlasseId!: string; + @IsOptional() + public ptscListKlasseId?: string; constructor(data: Partial) { Object.assign(this, data); diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts index f9f8ca71c43..e9dc4d7b8b7 100644 --- a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts @@ -23,11 +23,11 @@ export class TspProvisioningStrategy extends ProvisioningStrategy { super(); } - getType(): SystemProvisioningStrategy { + public getType(): SystemProvisioningStrategy { return SystemProvisioningStrategy.TSP; } - override async getData(input: OauthDataStrategyInputDto): Promise { + public override async getData(input: OauthDataStrategyInputDto): Promise { const decodedAccessToken: JwtPayload | null = jwt.decode(input.accessToken, { json: true }); if (!decodedAccessToken) { @@ -60,8 +60,8 @@ export class TspProvisioningStrategy extends ProvisioningStrategy { }); const externalClassDtoList = payload.ptscListKlasseId - .split(',') - .map((externalId) => new ExternalClassDto({ externalId })); + ? payload.ptscListKlasseId.split(',').map((externalId) => new ExternalClassDto({ externalId })) + : []; const oauthDataDto = new OauthDataDto({ system: input.system, @@ -73,7 +73,7 @@ export class TspProvisioningStrategy extends ProvisioningStrategy { return oauthDataDto; } - override async apply(data: OauthDataDto): Promise { + public override async apply(data: OauthDataDto): Promise { if (!data.externalSchool) { throw new BadDataLoggableException('External school is missing for user', { externalId: data.externalUser.externalId, From 7b3fee5beac4b3ef4118a2f7db1ca33bbd87f922 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:26:57 +0100 Subject: [PATCH 12/17] BC-8200 - exclude entity-classes from explicit-member-accessibility rule (#5379) Adaption to avoid unneccessary public keywords in front of Entity-class properties. --- .eslintrc.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.eslintrc.js b/.eslintrc.js index 3300d598dc0..aa33631f7f7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -230,6 +230,24 @@ module.exports = { ], }, }, + { + files: ['apps/server/src/**/*.entity.ts'], + rules: { + '@typescript-eslint/explicit-member-accessibility': [ + 'warn', + { + accessibility: 'explicit', + overrides: { + accessors: 'no-public', + constructors: 'no-public', + methods: 'explicit', + properties: 'no-public', + parameterProperties: 'explicit', + }, + }, + ], + }, + }, ], }, ], From 19821f85e9c205d6016175b532137b0b42792c08 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:29:56 +0100 Subject: [PATCH 13/17] BC-8393 - Remove tldraw migration job (#5381) --- .../schulcloud-server-core/tasks/main.yml | 22 ---------- .../templates/tldraw-migration-job.yml.j2 | 43 ------------------- 2 files changed, 65 deletions(-) delete mode 100644 ansible/roles/schulcloud-server-core/templates/tldraw-migration-job.yml.j2 diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 28c8a15460b..5f8a82c76af 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -214,28 +214,6 @@ tags: - ingress - - - name: remove old tldraw migration Job - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - api_version: batch/v1 - kind: Job - name: tldraw-migration-job - state: absent - wait: yes - tags: - - job - - - name: tldraw migration Job - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-migration-job.yml.j2 - when: WITH_TLDRAW2 is defined and WITH_TLDRAW2|bool - tags: - - job - - name: Delete Files CronJob kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-migration-job.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-migration-job.yml.j2 deleted file mode 100644 index b18a4e7e934..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-migration-job.yml.j2 +++ /dev/null @@ -1,43 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: tldraw-migration-job - namespace: {{ NAMESPACE }} - labels: - app: tldraw-migration -spec: - template: - metadata: - labels: - app: tldraw-migration - spec: - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - runAsNonRoot: true - containers: - - name: tldraw-migration-job - image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} - imagePullPolicy: IfNotPresent - # this is just for this job and should not be an example for anyone else - envFrom: - - configMapRef: - name: api-configmap - - secretRef: - name: api-secret - - secretRef: - name: api-files-secret - - secretRef: - name: tldraw-server-secret - command: ['/bin/sh','-c'] - args: ['npm run nest:start:tldraw-console -- migration run'] - resources: - limits: - cpu: {{ TLDRAW_MIGRATION_CPU_REQUESTS|default("2000m", true) }} - memory: {{ TLDRAW_MIGRATION_MEMORY_REQUESTS|default("2Gi", true) }} - requests: - cpu: {{ TLDRAW_MIGRATION_CPU_REQUESTS|default("100m", true) }} - memory: {{ TLDRAW_MIGRATION_MEMORY_REQUESTS|default("150Mi", true) }} - restartPolicy: Never - backoffLimit: 5 From 8a4deae68961aad12b846fb5c602fc41023dd5e1 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Fri, 6 Dec 2024 07:36:00 +0100 Subject: [PATCH 14/17] BC-8510 - Wrong handling of WITH_TLDRAW2 by ConfigModule (#5383) * BC-8510 - Add Tldraw client configuration to board collaboration settings * BC-8510 - Remove WITH_TLDRAW2 configuration and related logic from Tldraw client --- .../board/board-collaboration.config.ts | 2 ++ .../tldraw-client-config.interface.ts | 1 - .../drawing-element-adapter.service.spec.ts | 36 +------------------ .../drawing-element-adapter.service.ts | 3 +- .../tldraw-client.config.spec.ts | 2 -- .../tldraw-client/tldraw-client.config.ts | 1 - config/default.schema.json | 5 --- 7 files changed, 4 insertions(+), 46 deletions(-) diff --git a/apps/server/src/modules/board/board-collaboration.config.ts b/apps/server/src/modules/board/board-collaboration.config.ts index 5a74844d2af..e824b4ca867 100644 --- a/apps/server/src/modules/board/board-collaboration.config.ts +++ b/apps/server/src/modules/board/board-collaboration.config.ts @@ -1,6 +1,7 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { JwtAuthGuardConfig } from '@infra/auth-guard'; import { Algorithm } from 'jsonwebtoken'; +import { getTldrawClientConfig } from '../tldraw-client'; export interface BoardCollaborationConfig extends JwtAuthGuardConfig { NEST_LOG_LEVEL: string; @@ -13,6 +14,7 @@ const boardCollaborationConfig: BoardCollaborationConfig = { JWT_PUBLIC_KEY: (Configuration.get('JWT_PUBLIC_KEY') as string).replace(/\\n/g, '\n'), JWT_SIGNING_ALGORITHM: Configuration.get('JWT_SIGNING_ALGORITHM') as Algorithm, SC_DOMAIN: Configuration.get('SC_DOMAIN') as string, + ...getTldrawClientConfig(), }; export const config = () => boardCollaborationConfig; diff --git a/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts b/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts index 22b6cb2ac8c..40564ab2e81 100644 --- a/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts +++ b/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts @@ -1,5 +1,4 @@ export interface TldrawClientConfig { TLDRAW_ADMIN_API_CLIENT_BASE_URL: string; TLDRAW_ADMIN_API_CLIENT_API_KEY: string; - WITH_TLDRAW2: boolean; } diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts index 4867778f515..634d6485bbb 100644 --- a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts @@ -49,14 +49,12 @@ describe(DrawingElementAdapterService.name, () => { }); describe('deleteDrawingBinData', () => { - describe('when WITH_TLDRAW2 env var is true', () => { + describe('when deleteDrawingBinData is called', () => { const setup = () => { const apiKey = 'a4a20e6a-8036-4603-aba6-378006fedce2'; const baseUrl = 'http://localhost:3349'; - const WITH_TLDRAW2 = true; configService.get.mockReturnValueOnce(baseUrl); - configService.get.mockReturnValueOnce(WITH_TLDRAW2); configService.get.mockReturnValueOnce(apiKey); httpService.delete.mockReturnValue( of( @@ -81,37 +79,5 @@ describe(DrawingElementAdapterService.name, () => { }); }); }); - - describe('when WITH_TLDRAW2 env var is false', () => { - const setup = () => { - const apiKey = 'a4a20e6a-8036-4603-aba6-378006fedce2'; - const baseUrl = 'http://localhost:3349'; - const WITH_TLDRAW2 = false; - configService.get.mockReturnValueOnce(baseUrl); - configService.get.mockReturnValueOnce(WITH_TLDRAW2); - configService.get.mockReturnValueOnce(apiKey); - httpService.delete.mockReturnValue( - of( - axiosResponseFactory.build({ - data: '', - status: HttpStatus.OK, - statusText: 'OK', - }) - ) - ); - - return { apiKey, baseUrl }; - }; - - it('should call axios delete method', async () => { - const { apiKey, baseUrl } = setup(); - - await service.deleteDrawingBinData('test'); - - expect(httpService.delete).toHaveBeenCalledWith(`${baseUrl}/api/v3/tldraw-document/test`, { - headers: { 'X-Api-Key': apiKey, Accept: 'Application/json' }, - }); - }); - }); }); }); diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts index 7b98fdaa144..baa16703a94 100644 --- a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts @@ -17,8 +17,7 @@ export class DrawingElementAdapterService { async deleteDrawingBinData(parentId: string): Promise { const baseUrl = this.configService.get('TLDRAW_ADMIN_API_CLIENT_BASE_URL'); - const isTlDraw2 = this.configService.get('WITH_TLDRAW2'); - const endpointUrl = isTlDraw2 ? '/api/tldraw-document' : '/api/v3/tldraw-document'; + const endpointUrl = '/api/tldraw-document'; const tldrawDocumentEndpoint = new URL(endpointUrl, baseUrl).toString(); await firstValueFrom(this.httpService.delete(`${tldrawDocumentEndpoint}/${parentId}`, this.defaultHeaders())); diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts b/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts index b3f7f4b2051..240a34ae7fc 100644 --- a/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts +++ b/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts @@ -20,12 +20,10 @@ describe(getTldrawClientConfig.name, () => { Configuration.set('TLDRAW_ADMIN_API_CLIENT__BASE_URL', baseUrl); Configuration.set('TLDRAW_ADMIN_API_CLIENT__API_KEY', apiKey); - Configuration.set('WITH_TLDRAW2', true); const expectedConfig = { TLDRAW_ADMIN_API_CLIENT_BASE_URL: baseUrl, TLDRAW_ADMIN_API_CLIENT_API_KEY: apiKey, - WITH_TLDRAW2: true, }; return { expectedConfig }; diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.config.ts b/apps/server/src/modules/tldraw-client/tldraw-client.config.ts index 0f308fad4ec..b778408b0c9 100644 --- a/apps/server/src/modules/tldraw-client/tldraw-client.config.ts +++ b/apps/server/src/modules/tldraw-client/tldraw-client.config.ts @@ -5,6 +5,5 @@ export const getTldrawClientConfig = (): TldrawClientConfig => { return { TLDRAW_ADMIN_API_CLIENT_BASE_URL: Configuration.get('TLDRAW_ADMIN_API_CLIENT__BASE_URL') as string, TLDRAW_ADMIN_API_CLIENT_API_KEY: Configuration.get('TLDRAW_ADMIN_API_CLIENT__API_KEY') as string, - WITH_TLDRAW2: Configuration.get('WITH_TLDRAW2') as boolean, }; }; diff --git a/config/default.schema.json b/config/default.schema.json index 00afd8e6a69..14cb3e99a1c 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1648,11 +1648,6 @@ "default": "http://localhost:3349", "description": "Address for tldraw management app" }, - "WITH_TLDRAW2": { - "type": "boolean", - "default": false, - "description": "Enables tldraw2 feature" - }, "SCHULCONNEX_CLIENT": { "type": "object", "description": "Configuration of the schulcloud's schulconnex client.", From 2104505b252a77d63f9dc4304e03390d609cdb09 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:39:02 +0100 Subject: [PATCH 15/17] BC-8139 - add or remove guestrole according to room-membership updates (#5382) * add group filter for multiple userIds * implement guest role management, when users get added to a room * implement guest role management, when users get removed from a room * guest role management: when user is at least in one room of the school it should have the guest role --- .../group/domain/interface/group-filter.ts | 1 + .../src/modules/group/repo/group.repo.ts | 1 + .../src/modules/group/repo/group.scope.ts | 17 ++- .../repo/room-membership.repo.ts | 12 +- .../room-membership/room-membership.module.ts | 3 +- .../service/room-membership.service.spec.ts | 126 ++++++++++++++++-- .../service/room-membership.service.ts | 24 +++- .../api/test/room-add-members.api.spec.ts | 10 +- .../src/modules/user/service/user.service.ts | 49 +++---- apps/server/src/shared/repo/user/user.repo.ts | 6 +- 10 files changed, 198 insertions(+), 51 deletions(-) diff --git a/apps/server/src/modules/group/domain/interface/group-filter.ts b/apps/server/src/modules/group/domain/interface/group-filter.ts index 7baaffbf511..228cf147e28 100644 --- a/apps/server/src/modules/group/domain/interface/group-filter.ts +++ b/apps/server/src/modules/group/domain/interface/group-filter.ts @@ -3,6 +3,7 @@ import { EntityId } from '@shared/domain/types'; export interface GroupFilter { userId?: EntityId; + userIds?: EntityId[]; schoolId?: EntityId; systemId?: EntityId; groupTypes?: GroupTypes[]; diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index 180bc4fd6ae..af58c1d4f18 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -57,6 +57,7 @@ export class GroupRepo extends BaseDomainObjectRepo { public async findGroups(filter: GroupFilter, options?: IFindOptions): Promise> { const scope: GroupScope = new GroupScope(); scope.byUserId(filter.userId); + scope.byUserIds(filter.userIds); scope.byOrganizationId(filter.schoolId); scope.bySystemId(filter.systemId); diff --git a/apps/server/src/modules/group/repo/group.scope.ts b/apps/server/src/modules/group/repo/group.scope.ts index e227f111408..5b879c92609 100644 --- a/apps/server/src/modules/group/repo/group.scope.ts +++ b/apps/server/src/modules/group/repo/group.scope.ts @@ -5,35 +5,42 @@ import { Scope } from '@shared/repo/scope'; import { GroupEntity, GroupEntityTypes } from '../entity'; export class GroupScope extends Scope { - byTypes(types: GroupEntityTypes[] | undefined): this { + public byTypes(types: GroupEntityTypes[] | undefined): this { if (types) { this.addQuery({ type: { $in: types } }); } return this; } - byOrganizationId(id: EntityId | undefined): this { + public byOrganizationId(id: EntityId | undefined): this { if (id) { this.addQuery({ organization: id }); } return this; } - bySystemId(id: EntityId | undefined): this { + public bySystemId(id: EntityId | undefined): this { if (id) { this.addQuery({ externalSource: { system: new ObjectId(id) } }); } return this; } - byUserId(id: EntityId | undefined): this { + public byUserId(id: EntityId | undefined): this { if (id) { this.addQuery({ users: { user: new ObjectId(id) } }); } return this; } - byNameQuery(nameQuery: string | undefined): this { + public byUserIds(ids: EntityId[] | undefined): this { + if (ids) { + this.addQuery({ users: { user: { $in: ids.map((id) => new ObjectId(id)) } } }); + } + return this; + } + + public byNameQuery(nameQuery: string | undefined): this { if (nameQuery) { const escapedName = nameQuery.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); this.addQuery({ name: new RegExp(escapedName, 'i') }); diff --git a/apps/server/src/modules/room-membership/repo/room-membership.repo.ts b/apps/server/src/modules/room-membership/repo/room-membership.repo.ts index 5db8b3750ce..5477302b252 100644 --- a/apps/server/src/modules/room-membership/repo/room-membership.repo.ts +++ b/apps/server/src/modules/room-membership/repo/room-membership.repo.ts @@ -10,7 +10,7 @@ import { RoomMembershipDomainMapper } from './room-membership-domain.mapper'; export class RoomMembershipRepo { constructor(private readonly em: EntityManager) {} - async findByRoomId(roomId: EntityId): Promise { + public async findByRoomId(roomId: EntityId): Promise { const roomMembershipEntities = await this.em.findOne(RoomMembershipEntity, { roomId }); if (!roomMembershipEntities) return null; @@ -19,28 +19,28 @@ export class RoomMembershipRepo { return roomMemberships; } - async findByRoomIds(roomIds: EntityId[]): Promise { + public async findByRoomIds(roomIds: EntityId[]): Promise { const entities = await this.em.find(RoomMembershipEntity, { roomId: { $in: roomIds } }); const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity)); return roomMemberships; } - async findByGroupId(groupId: EntityId): Promise { + public async findByGroupId(groupId: EntityId): Promise { const entities = await this.em.find(RoomMembershipEntity, { userGroupId: groupId }); const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity)); return roomMemberships; } - async findByGroupIds(groupIds: EntityId[]): Promise { + public async findByGroupIds(groupIds: EntityId[]): Promise { const entities = await this.em.find(RoomMembershipEntity, { userGroupId: { $in: groupIds } }); const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity)); return roomMemberships; } - async save(roomMembership: RoomMembership | RoomMembership[]): Promise { + public async save(roomMembership: RoomMembership | RoomMembership[]): Promise { const roomMemberships = Utils.asArray(roomMembership); roomMemberships.forEach((member) => { @@ -51,7 +51,7 @@ export class RoomMembershipRepo { await this.em.flush(); } - async delete(roomMembership: RoomMembership | RoomMembership[]): Promise { + public async delete(roomMembership: RoomMembership | RoomMembership[]): Promise { const roomMemberships = Utils.asArray(roomMembership); roomMemberships.forEach((member) => { diff --git a/apps/server/src/modules/room-membership/room-membership.module.ts b/apps/server/src/modules/room-membership/room-membership.module.ts index a261d8ea475..1015fd7b9a8 100644 --- a/apps/server/src/modules/room-membership/room-membership.module.ts +++ b/apps/server/src/modules/room-membership/room-membership.module.ts @@ -4,12 +4,13 @@ import { CqrsModule } from '@nestjs/cqrs'; import { AuthorizationModule } from '../authorization'; import { RoleModule } from '../role'; import { RoomModule } from '../room/room.module'; +import { UserModule } from '../user'; import { RoomMembershipRule } from './authorization/room-membership.rule'; import { RoomMembershipRepo } from './repo/room-membership.repo'; import { RoomMembershipService } from './service/room-membership.service'; @Module({ - imports: [AuthorizationModule, CqrsModule, GroupModule, RoleModule, RoomModule], + imports: [AuthorizationModule, CqrsModule, GroupModule, RoleModule, RoomModule, UserModule], providers: [RoomMembershipService, RoomMembershipRepo, RoomMembershipRule], exports: [RoomMembershipService], }) diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts index d9134cb2867..6f6b39139b2 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts @@ -1,13 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { GroupService, GroupTypes } from '@modules/group'; -import { RoleService } from '@modules/role'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { Group, GroupService, GroupTypes } from '@modules/group'; +import { RoleDto, RoleService } from '@modules/role'; +import { RoomService } from '@modules/room/domain'; import { roomFactory } from '@modules/room/testing'; +import { schoolFactory } from '@modules/school/testing'; +import { UserService } from '@modules/user'; import { BadRequestException } from '@nestjs/common/exceptions'; import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain/interface'; -import { groupFactory, roleDtoFactory, userFactory } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@src/infra/database'; -import { RoomService } from '@src/modules/room/domain'; +import { groupFactory, roleDtoFactory, roleFactory, userDoFactory, userFactory } from '@shared/testing'; import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do'; import { RoomMembershipRepo } from '../repo/room-membership.repo'; import { roomMembershipFactory } from '../testing'; @@ -20,6 +22,7 @@ describe('RoomMembershipService', () => { let groupService: DeepMocked; let roleService: DeepMocked; let roomService: DeepMocked; + let userService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -42,6 +45,10 @@ describe('RoomMembershipService', () => { provide: RoomService, useValue: createMock(), }, + { + provide: UserService, + useValue: createMock(), + }, ], }).compile(); @@ -50,6 +57,7 @@ describe('RoomMembershipService', () => { groupService = module.get(GroupService); roleService = module.get(RoleService); roomService = module.get(RoomService); + userService = module.get(UserService); }); afterAll(async () => { @@ -113,9 +121,14 @@ describe('RoomMembershipService', () => { describe('when roomMembership exists', () => { const setup = () => { const user = userFactory.buildWithId(); - const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build(); - const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const school = schoolFactory.build(); + const group = groupFactory.build({ type: GroupTypes.ROOM, organizationId: school.id }); + const room = roomFactory.build({ schoolId: school.id }); + const roomMembership = roomMembershipFactory.build({ + roomId: room.id, + userGroupId: group.id, + schoolId: school.id, + }); roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); @@ -136,6 +149,14 @@ describe('RoomMembershipService', () => { { userId: user.id, roleName: RoleName.ROOMEDITOR }, ]); }); + + it('should add user to school', async () => { + const { user, room } = setup(); + + await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); + + expect(userService.addSecondarySchoolToUsers).toHaveBeenCalledWith([user.id], room.schoolId); + }); }); }); @@ -176,6 +197,7 @@ describe('RoomMembershipService', () => { roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); + groupService.findGroups.mockResolvedValue({ total: 1, data: [group] }); return { user, @@ -193,6 +215,94 @@ describe('RoomMembershipService', () => { expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); }); }); + + const setupUserWithSecondarySchool = () => { + const secondarySchool = schoolFactory.build(); + const otherSchool = schoolFactory.build(); + const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); + const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); + const externalUser = userDoFactory.buildWithId({ + roles: [role], + secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], + }); + + return { secondarySchool, externalUser, otherSchool }; + }; + + const setupGroupAndRoom = (schoolId: string) => { + const group = groupFactory.build({ type: GroupTypes.ROOM }); + const room = roomFactory.build({ schoolId }); + const roomMembership = roomMembershipFactory.build({ + roomId: room.id, + userGroupId: group.id, + schoolId, + }); + + return { group, room, roomMembership }; + }; + + const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { + groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); + }; + + it('should pass the schoolId of the room', async () => { + const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + + const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); + groupService.findById.mockResolvedValue(group); + groupService.removeUsersFromGroup.mockResolvedValue(group); + mockGroupsAtSchoolAfterRemoval([]); + + await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + + expect(groupService.findGroups).toHaveBeenCalledWith(expect.objectContaining({ schoolId: secondarySchool.id })); + }); + + describe('when after removal: user is not in any room of that secondary school', () => { + it('should remove user from secondary school', async () => { + const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + + const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); + groupService.findById.mockResolvedValue(group); + groupService.removeUsersFromGroup.mockResolvedValue(group); + mockGroupsAtSchoolAfterRemoval([]); + + await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + + expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUser.id], secondarySchool.id); + }); + }); + + describe('when after removal: user is still in a room of that secondary school', () => { + it('should not remove user from secondary school', async () => { + const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + + const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + const { group: group2 } = setupGroupAndRoom(secondarySchool.id); + group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); + groupService.findById.mockResolvedValue(group); + groupService.removeUsersFromGroup.mockResolvedValue(group); + mockGroupsAtSchoolAfterRemoval([group2]); + + await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + + expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + }); + }); }); describe('deleteRoomMembership', () => { diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.ts b/apps/server/src/modules/room-membership/service/room-membership.service.ts index 75e6e6922eb..59fa6167c6d 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.ts @@ -1,6 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Group, GroupService, GroupTypes } from '@modules/group'; import { RoleDto, RoleService } from '@modules/role'; +import { UserService } from '@modules/user'; import { BadRequestException, Injectable } from '@nestjs/common'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; @@ -15,14 +16,15 @@ export class RoomMembershipService { private readonly groupService: GroupService, private readonly roomMembershipRepo: RoomMembershipRepo, private readonly roleService: RoleService, - private readonly roomService: RoomService + private readonly roomService: RoomService, + private readonly userService: UserService ) {} private async createNewRoomMembership( roomId: EntityId, userId: EntityId, roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER - ) { + ): Promise { const room = await this.roomService.getSingleRoom(roomId); const group = await this.groupService.createGroup( @@ -65,7 +67,7 @@ export class RoomMembershipService { return roomMembershipAuthorizable; } - public async deleteRoomMembership(roomId: EntityId) { + public async deleteRoomMembership(roomId: EntityId): Promise { const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); if (roomMembership === null) return; @@ -90,6 +92,9 @@ export class RoomMembershipService { await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles); + const userIds = userIdsAndRoles.map((user) => user.userId); + await this.userService.addSecondarySchoolToUsers(userIds, roomMembership.schoolId); + return roomMembership.id; } @@ -101,6 +106,8 @@ export class RoomMembershipService { const group = await this.groupService.findById(roomMembership.userGroupId); await this.groupService.removeUsersFromGroup(group.id, userIds); + + await this.handleGuestRoleRemoval(userIds, roomMembership.schoolId); } public async getRoomMembershipAuthorizablesByUserId(userId: EntityId): Promise { @@ -141,4 +148,15 @@ export class RoomMembershipService { return roomMembershipAuthorizable; } + + private async handleGuestRoleRemoval(userIds: EntityId[], schoolId: EntityId): Promise { + const { data: groups } = await this.groupService.findGroups({ userIds, groupTypes: [GroupTypes.ROOM], schoolId }); + + const userIdsInGroups = groups.flatMap((group) => group.users.map((groupUser) => groupUser.userId)); + const removeUserIds = userIds.filter((userId) => !userIdsInGroups.includes(userId)); + + if (removeUserIds.length > 0) { + await this.userService.removeSecondarySchoolFromUsers(removeUserIds, schoolId); + } + } } diff --git a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts index 5fab1cced7e..a1ed8579853 100644 --- a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts @@ -9,6 +9,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, } from '@shared/testing'; import { GroupEntityTypes } from '@modules/group/entity/group.entity'; import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; @@ -45,10 +46,13 @@ describe('Room Controller (API)', () => { describe('PATCH /rooms/:roomId/members/add', () => { const setupRoomWithMembers = async () => { - const room = roomEntityFactory.buildWithId(); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const school = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); const { teacherAccount: otherTeacherAccount, teacherUser: otherTeacherUser } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const room = roomEntityFactory.buildWithId({ schoolId: teacherUser.school.id }); + const teacherGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); + const studentGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTSTUDENT }); const role = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], @@ -67,6 +71,8 @@ describe('Room Controller (API)', () => { roomMemberships, teacherAccount, teacherUser, + teacherGuestRole, + studentGuestRole, otherTeacherUser, otherTeacherAccount, userGroupEntity, diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 6f0dc3f798a..811cedbfea8 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -56,14 +56,14 @@ export class UserService implements DeletionService, IEventHandler { + public async getUserEntityWithRoles(userId: EntityId): Promise { // only roles required, no need for the other populates const userWithRoles = await this.userRepo.findById(userId, true); return userWithRoles; } - async me(userId: EntityId): Promise<[User, string[]]> { + public async me(userId: EntityId): Promise<[User, string[]]> { const user = await this.userRepo.findById(userId, true); const permissions = user.resolvePermissions(); @@ -73,20 +73,20 @@ export class UserService implements DeletionService, IEventHandler { + public async getUser(id: string): Promise { const userEntity = await this.userRepo.findById(id, true); const userDto = UserMapper.mapFromEntityToDto(userEntity); return userDto; } - async findById(id: string): Promise { + public async findById(id: string): Promise { const userDO = await this.userDORepo.findById(id, true); return userDO; } - async findByIds(ids: string[]): Promise { + public async findByIds(ids: string[]): Promise { const userDOs = await this.userDORepo.findByIds(ids, true); return userDOs; @@ -98,25 +98,25 @@ export class UserService implements DeletionService, IEventHandler { - const savedUser: Promise = this.userDORepo.save(user); + public async save(user: UserDO): Promise { + const savedUser = await this.userDORepo.save(user); return savedUser; } - async saveAll(users: UserDO[]): Promise { - const savedUsers: Promise = this.userDORepo.saveAll(users); + public async saveAll(users: UserDO[]): Promise { + const savedUsers = await this.userDORepo.saveAll(users); return savedUsers; } - async findUsers(query: UserQuery, options?: IFindOptions): Promise> { + public async findUsers(query: UserQuery, options?: IFindOptions): Promise> { const users: Page = await this.userDORepo.find(query, options); return users; } - async findBySchoolRole( + public async findBySchoolRole( schoolId: EntityId, roleName: RoleName, options?: IFindOptions @@ -127,7 +127,7 @@ export class UserService implements DeletionService, IEventHandler): Promise> { + public async findPublicTeachersBySchool(schoolId: EntityId, options?: IFindOptions): Promise> { const discoverabilitySetting = this.configService.get('TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION'); if (discoverabilitySetting === 'disabled') { return new Page([], 0); @@ -147,7 +147,7 @@ export class UserService implements DeletionService, IEventHandler { + public async addSecondarySchoolToUsers(userIds: string[], schoolId: EntityId): Promise { const users = await this.userDORepo.findByIds(userIds, true); const guestStudent = await this.roleService.findByName(RoleName.GUESTSTUDENT); const guestTeacher = await this.roleService.findByName(RoleName.GUESTTEACHER); @@ -174,7 +174,7 @@ export class UserService implements DeletionService, IEventHandler { + public async removeSecondarySchoolFromUsers(userIds: string[], schoolId: EntityId): Promise { const users = await this.userDORepo.findByIds(userIds, true); users.forEach((user) => { @@ -185,19 +185,19 @@ export class UserService implements DeletionService, IEventHandler { - const user: Promise = this.userDORepo.findByExternalId(externalId, systemId); + public async findByExternalId(externalId: string, systemId: EntityId): Promise { + const user = await this.userDORepo.findByExternalId(externalId, systemId); return user; } - async findByEmail(email: string): Promise { - const user: Promise = this.userDORepo.findByEmail(email); + public async findByEmail(email: string): Promise { + const user = await this.userDORepo.findByEmail(email); return user; } - async getDisplayName(user: UserDO): Promise { + public async getDisplayName(user: UserDO): Promise { const protectedRoles: RoleDto[] = await this.roleService.getProtectedRoles(); const isProtectedUser: boolean = user.roles.some( (roleRef: RoleReference): boolean => @@ -209,7 +209,7 @@ export class UserService implements DeletionService, IEventHandler { + public async patchLanguage(userId: EntityId, newLanguage: LanguageType): Promise { this.checkAvailableLanguages(newLanguage); const user = await this.userRepo.findById(userId); user.language = newLanguage; @@ -281,7 +281,7 @@ export class UserService implements DeletionService, IEventHandler { - const parentEmails = this.userRepo.getParentEmailsFromUser(userId); + const parentEmails = await this.userRepo.getParentEmailsFromUser(userId); return parentEmails; } @@ -293,7 +293,9 @@ export class UserService implements DeletionService, IEventHandler { - return this.userRepo.findByExternalIds(externalIds); + const userIds = await this.userRepo.findByExternalIds(externalIds); + + return userIds; } public async updateLastSyncedAt(userIds: string[]): Promise { @@ -326,7 +328,8 @@ export class UserService implements DeletionService, IEventHandler { diff --git a/apps/server/src/shared/repo/user/user.repo.ts b/apps/server/src/shared/repo/user/user.repo.ts index 126dbabd99a..7b6fee3bf4e 100644 --- a/apps/server/src/shared/repo/user/user.repo.ts +++ b/apps/server/src/shared/repo/user/user.repo.ts @@ -121,16 +121,16 @@ export class UserRepo extends BaseRepo { return users; } - public async findByExternalIds(externalIds: string[]): Promise { + public async findByExternalIds(externalIds: EntityId[]): Promise { const foundUsers = await this._em.find( User, { externalId: { $in: externalIds } }, { fields: ['id', 'externalId'] } ); - const users = foundUsers.map(({ id }) => id); + const userIds = foundUsers.map(({ id }) => id); - return users; + return userIds; } public async updateAllUserByLastSyncedAt(userIds: string[]): Promise { From 2b7c697379a36f8b78b8de0823452187c35b4b4e Mon Sep 17 00:00:00 2001 From: Gordon Nicholas <160246213+GordonNicholasCap@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:25:20 +0100 Subject: [PATCH 16/17] N21-1506-sharing-ctl-tools (#5353) - migration script for garbage data cleanup - adapt logic for sharing tools in course for different schools --- .../mikro-orm/Migration20241120100616.ts | 65 ++ .../board/controller/board.controller.ts | 2 +- .../service/column-board.service.spec.ts | 2 + .../internal/board-node-copy-context.spec.ts | 1 + .../internal/board-node-copy-context.ts | 7 +- .../board-node-copy-general.service.spec.ts | 6 + .../board-node-copy-specific.service.spec.ts | 73 +- .../internal/board-node-copy.service.ts | 59 +- .../column-board-copy.service.spec.ts | 4 + .../internal/column-board-copy.service.ts | 2 + .../src/modules/board/uc/board.uc.spec.ts | 14 +- apps/server/src/modules/board/uc/board.uc.ts | 3 +- .../service/board-copy.service.spec.ts | 1 + .../learnroom/service/board-copy.service.ts | 1 + .../service/course-copy.service.spec.ts | 141 ++- .../learnroom/service/course-copy.service.ts | 73 +- .../api-test/sharing-import-token.api.spec.ts | 826 ++++++++++++++++-- .../sharing/testing/share-token.factory.ts | 51 ++ .../modules/sharing/uc/share-token.uc.spec.ts | 1 + .../src/modules/sharing/uc/share-token.uc.ts | 1 + .../copy-context-external-tool-reject-data.ts | 12 + .../context-external-tool/domain/index.ts | 1 + .../context-external-tool.service.spec.ts | 232 ++++- .../service/context-external-tool.service.ts | 23 +- ...ntext-external-tool-reject-data.factory.ts | 10 + .../context-external-tool/testing/index.ts | 1 + backup/setup/migrations.json | 9 + 27 files changed, 1425 insertions(+), 196 deletions(-) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241120100616.ts create mode 100644 apps/server/src/modules/sharing/testing/share-token.factory.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/domain/copy-context-external-tool-reject-data.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/testing/copy-context-external-tool-reject-data.factory.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20241120100616.ts b/apps/server/src/migrations/mikro-orm/Migration20241120100616.ts new file mode 100644 index 00000000000..1080f7eb2f2 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241120100616.ts @@ -0,0 +1,65 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; +import { ObjectId } from '@mikro-orm/mongodb'; + +export class Migration20241120100616 extends Migration { + async up(): Promise { + const cursor = this.getCollection<{ contextType: string; contextId: ObjectId; schoolTool: ObjectId }>( + 'context-external-tools' + ).find({ + $or: [{ contextType: 'course' }, { contextType: 'boardElement' }], + }); + + let numberOfDeletedTools = 0; + let numberOfDeletedElements = 0; + for await (const tool of cursor) { + let courseId: ObjectId | undefined; + if (tool.contextType === 'course') { + courseId = tool.contextId; + } else if (tool.contextType === 'boardElement') { + const element = await this.getCollection<{ path: string }>('boardnodes').findOne({ + _id: tool.contextId, + }); + + if (element) { + const boardId = new ObjectId(element.path.split(',')[1]); + + const board = await this.getCollection<{ context: ObjectId }>('boardnodes').findOne({ + _id: boardId, + }); + + if (board) { + courseId = board.context; + } + } + } + + if (courseId) { + const course = await this.getCollection<{ schoolId: ObjectId }>('courses').findOne({ _id: courseId }); + + const schoolTool = await this.getCollection<{ school: ObjectId }>('school-external-tools').findOne({ + _id: tool.schoolTool, + }); + + if (!course || !schoolTool || course.schoolId.toString() !== schoolTool.school.toString()) { + await this.driver.nativeDelete('context-external-tools', { _id: tool._id }); + console.info(`deleted context external tool: ${tool._id.toString()}`); + numberOfDeletedTools += 1; + if (tool.contextType === 'boardElement') { + await this.driver.nativeDelete('boardnodes', { _id: tool.contextId }); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + console.info(`deleted boardnode: ${tool.contextId}`); + numberOfDeletedElements += 1; + } + } + } + } + console.info( + `Deleted ${numberOfDeletedTools} context external tools and ${numberOfDeletedElements} external tool elements.` + ); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down(): Promise { + console.info('Unfortunately the deleted documents cannot be restored. Use a backup.'); + } +} diff --git a/apps/server/src/modules/board/controller/board.controller.ts b/apps/server/src/modules/board/controller/board.controller.ts index 120ce0863d4..2a6f4e74cfe 100644 --- a/apps/server/src/modules/board/controller/board.controller.ts +++ b/apps/server/src/modules/board/controller/board.controller.ts @@ -138,7 +138,7 @@ export class BoardController { @Param() urlParams: BoardUrlParams, @CurrentUser() currentUser: ICurrentUser ): Promise { - const copyStatus = await this.boardUc.copyBoard(currentUser.userId, urlParams.boardId); + const copyStatus = await this.boardUc.copyBoard(currentUser.userId, urlParams.boardId, currentUser.schoolId); const dto = CopyMapper.mapToResponse(copyStatus); return dto; } diff --git a/apps/server/src/modules/board/service/column-board.service.spec.ts b/apps/server/src/modules/board/service/column-board.service.spec.ts index ac2986ff473..5b0da8b87fe 100644 --- a/apps/server/src/modules/board/service/column-board.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board.service.spec.ts @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain/types'; import { StorageLocation } from '@src/modules/files-storage/interface'; +import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '../../copy-helper'; import { BoardExternalReference, BoardExternalReferenceType, ColumnBoard } from '../domain'; import { BoardNodeRepo } from '../repo'; @@ -114,6 +115,7 @@ describe('ColumnBoardService', () => { sourceStorageLocationReference: { id: '1', type: StorageLocation.SCHOOL }, targetStorageLocationReference: { id: '1', type: StorageLocation.SCHOOL }, userId: '1', + targetSchoolId: new ObjectId().toHexString(), }); expect(result).toEqual(copyStatus); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts index a65ef01e651..c2e917b70cf 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-context.spec.ts @@ -13,6 +13,7 @@ describe(BoardNodeCopyContext.name, () => { targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), + targetSchoolId: new ObjectId().toHexString(), }; const copyContext = new BoardNodeCopyContext(contextProps); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-context.ts b/apps/server/src/modules/board/service/internal/board-node-copy-context.ts index f32fe232534..d442cc35b7f 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-context.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-context.ts @@ -15,10 +15,15 @@ export type BoardNodeCopyContextProps = { targetStorageLocationReference: StorageLocationReference; userId: EntityId; filesStorageClientAdapterService: FilesStorageClientAdapterService; + targetSchoolId: EntityId; }; export class BoardNodeCopyContext implements CopyContext { - constructor(private readonly props: BoardNodeCopyContextProps) {} + readonly targetSchoolId: EntityId; + + constructor(private readonly props: BoardNodeCopyContextProps) { + this.targetSchoolId = props.targetSchoolId; + } copyFilesOfParent(sourceParentId: EntityId, targetParentId: EntityId): Promise { return this.props.filesStorageClientAdapterService.copyFilesOfParent({ diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts index 71ea6f8ab56..2a3b6b4c8f1 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { StorageLocation } from '@modules/files-storage/interface'; +import { SchoolExternalToolService } from '@modules/tool/school-external-tool/service'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { ToolConfig } from '@modules/tool/tool-config'; import { ConfigService } from '@nestjs/config'; @@ -48,6 +49,10 @@ describe(BoardNodeCopyService.name, () => { provide: CopyHelperService, useValue: createMock(), }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, ], }).compile(); @@ -70,6 +75,7 @@ describe(BoardNodeCopyService.name, () => { targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), + targetSchoolId: new ObjectId().toHexString(), }; const copyContext = new BoardNodeCopyContext(contextProps); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index 44de4316a5c..a2400688f7b 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -4,12 +4,15 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from ' import { StorageLocation } from '@modules/files-storage/interface'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { ToolConfig } from '@modules/tool/tool-config'; +import { copyContextExternalToolRejectDataFactory } from '@modules/tool/context-external-tool/testing'; +import { CopyContextExternalToolRejectData } from '@modules/tool/context-external-tool/domain'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; import { CopyFileDto } from '@src/modules/files-storage-client/dto'; import { contextExternalToolFactory } from '@src/modules/tool/context-external-tool/testing'; + import { Card, CollaborativeTextEditorElement, @@ -103,6 +106,7 @@ describe(BoardNodeCopyService.name, () => { targetStorageLocationReference: { id: new ObjectId().toHexString(), type: StorageLocation.SCHOOL }, userId: new ObjectId().toHexString(), filesStorageClientAdapterService: createMock(), + targetSchoolId: new ObjectId().toHexString(), }; const copyContext = new BoardNodeCopyContext(contextProps); @@ -420,23 +424,68 @@ describe(BoardNodeCopyService.name, () => { const setupToolElement = () => { const { copyContext, externalToolElement } = setupCopyEnabled(); - const tool = contextExternalToolFactory.build(); - const toolCopy = contextExternalToolFactory.build(); - contextExternalToolService.findById.mockResolvedValueOnce(tool); - contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(toolCopy); - externalToolElement.contextExternalToolId = tool.id; + const contextTool = contextExternalToolFactory.build(); + contextExternalToolService.findById.mockResolvedValueOnce(contextTool); + externalToolElement.contextExternalToolId = contextTool.id; - return { copyContext, externalToolElement, tool, toolCopy }; + return { copyContext, externalToolElement, contextTool }; }; - it('should copy the external tool', async () => { - const { copyContext, externalToolElement, tool, toolCopy } = setupToolElement(); + describe('when the copying of context external tool is successful', () => { + const setupCopySuccess = () => { + const { copyContext, externalToolElement, contextTool } = setupToolElement(); - const result = await service.copyExternalToolElement(externalToolElement, copyContext); + const copiedTool = contextExternalToolFactory.build(); + contextExternalToolService.copyContextExternalTool.mockResolvedValue(copiedTool); + + return { copyContext, externalToolElement, contextTool, copiedTool }; + }; + + it('should return the copied entity as ExternalTool', async () => { + const { copyContext, externalToolElement, contextTool, copiedTool } = setupCopySuccess(); + + const result = await service.copyExternalToolElement(externalToolElement, copyContext); + + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + contextTool, + result.copyEntity?.id, + copyContext.targetSchoolId + ); + expect(result.copyEntity instanceof ExternalToolElement).toEqual(true); + expect((result.copyEntity as ExternalToolElement).contextExternalToolId).toEqual(copiedTool.id); + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + }); - expect(contextExternalToolService.findById).toHaveBeenCalledWith(tool.id); - expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tool, result.copyEntity?.id); - expect((result.copyEntity as ExternalToolElement).contextExternalToolId).toEqual(toolCopy.id); + describe('when the copying of context external tool is rejected', () => { + const setupCopyRejected = () => { + const { copyContext, externalToolElement, contextTool } = setupToolElement(); + + const copyRejectData = copyContextExternalToolRejectDataFactory.build(); + const mockWithCorrectType = Object.create( + CopyContextExternalToolRejectData.prototype + ) as CopyContextExternalToolRejectData; + Object.assign(mockWithCorrectType, { ...copyRejectData }); + contextExternalToolService.copyContextExternalTool.mockResolvedValue(mockWithCorrectType); + + return { copyContext, externalToolElement, contextTool }; + }; + + it('should return the copied entity as DeletedElement', async () => { + const { externalToolElement, copyContext, contextTool } = setupCopyRejected(); + + const result = await service.copyExternalToolElement(externalToolElement, copyContext); + + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + contextTool, + expect.any(String), + copyContext.targetSchoolId + ); + expect(result.copyEntity instanceof DeletedElement).toEqual(true); + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + expect(result.status).toEqual(CopyStatusEnum.FAIL); + }); }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts index 3870411bac9..1f8fcc1e4a6 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts @@ -2,7 +2,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { CopyFileDto } from '@modules/files-storage-client/dto'; import { ContextExternalToolService } from '@modules/tool/context-external-tool'; -import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { ContextExternalTool, CopyContextExternalToolRejectData } from '@modules/tool/context-external-tool/domain'; import { ToolConfig } from '@modules/tool/tool-config'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -14,6 +14,7 @@ import { CollaborativeTextEditorElement, Column, ColumnBoard, + ContentElementType, DeletedElement, DrawingElement, ExternalToolElement, @@ -30,6 +31,7 @@ import { } from '../../domain'; export interface CopyContext { + targetSchoolId: EntityId; copyFilesOfParent(sourceParentId: EntityId, targetParentId: EntityId): Promise; } @@ -283,35 +285,60 @@ export class BoardNodeCopyService { return Promise.resolve(result); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars async copyExternalToolElement(original: ExternalToolElement, context: CopyContext): Promise { - const copy = new ExternalToolElement({ + let copy: ExternalToolElement | DeletedElement; + copy = new ExternalToolElement({ ...original.getProps(), ...this.buildSpecificProps([]), }); - let status: CopyStatusEnum; - if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED') && original.contextExternalToolId) { - const linkedTool = await this.contextExternalToolService.findById(original.contextExternalToolId); + if (!this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED') || !original.contextExternalToolId) { + const copyStatus: CopyStatus = { + copyEntity: copy, + type: CopyElementType.EXTERNAL_TOOL_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }; - if (linkedTool) { - const contextExternalToolCopy: ContextExternalTool = - await this.contextExternalToolService.copyContextExternalTool(linkedTool, copy.id); + return Promise.resolve(copyStatus); + } - copy.contextExternalToolId = contextExternalToolCopy.id; + const linkedTool = await this.contextExternalToolService.findById(original.contextExternalToolId); + if (!linkedTool) { + const copyStatus: CopyStatus = { + copyEntity: copy, + type: CopyElementType.EXTERNAL_TOOL_ELEMENT, + status: CopyStatusEnum.FAIL, + }; - status = CopyStatusEnum.SUCCESS; - } else { - status = CopyStatusEnum.FAIL; - } + return copyStatus; + } + + const contextToolCopyResult: ContextExternalTool | CopyContextExternalToolRejectData = + await this.contextExternalToolService.copyContextExternalTool(linkedTool, copy.id, context.targetSchoolId); + + let copyStatus: CopyStatusEnum = CopyStatusEnum.SUCCESS; + if (contextToolCopyResult instanceof CopyContextExternalToolRejectData) { + copy = new DeletedElement({ + id: new ObjectId().toHexString(), + path: copy.path, + level: copy.level, + position: copy.position, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + deletedElementType: ContentElementType.EXTERNAL_TOOL, + title: contextToolCopyResult.externalToolName, + }); + + copyStatus = CopyStatusEnum.FAIL; } else { - status = CopyStatusEnum.SUCCESS; + copy.contextExternalToolId = contextToolCopyResult.id; } const result: CopyStatus = { copyEntity: copy, type: CopyElementType.EXTERNAL_TOOL_ELEMENT, - status, + status: copyStatus, }; return Promise.resolve(result); diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts index e78022f73ef..25feffbeb24 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.spec.ts @@ -63,6 +63,7 @@ describe(ColumnBoardCopyService.name, () => { describe('copyColumnBoard', () => { const setup = () => { const userId = new ObjectId().toHexString(); + const targetSchoolId = new ObjectId().toHexString(); const course = courseFactory.buildWithId(); const originalBoard = columnBoardFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, @@ -86,6 +87,7 @@ describe(ColumnBoardCopyService.name, () => { targetStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, userId, copyTitle: 'Board Copy', + targetSchoolId, }; return { originalBoard, userId, copyParams }; @@ -165,6 +167,7 @@ describe(ColumnBoardCopyService.name, () => { describe('when the copy response is not a ColumnBoard', () => { const setup = () => { const userId = new ObjectId().toHexString(); + const targetSchoolId = new ObjectId().toHexString(); const course = courseFactory.buildWithId(); const originalBoard = columnBoardFactory.build({ context: { id: course.id, type: BoardExternalReferenceType.Course }, @@ -178,6 +181,7 @@ describe(ColumnBoardCopyService.name, () => { targetStorageLocationReference: { id: course.school.id, type: StorageLocation.SCHOOL }, userId, copyTitle: 'Board Copy', + targetSchoolId, }; return { originalBoard, userId, copyParams }; diff --git a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts index 531ec8c3510..6c5b4a31b01 100644 --- a/apps/server/src/modules/board/service/internal/column-board-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/column-board-copy.service.ts @@ -15,6 +15,7 @@ export type CopyColumnBoardParams = { targetStorageLocationReference: StorageLocationReference; userId: EntityId; copyTitle?: string; + targetSchoolId: EntityId; }; @Injectable() @@ -36,6 +37,7 @@ export class ColumnBoardCopyService { targetStorageLocationReference: params.targetStorageLocationReference, userId: params.userId, filesStorageClientAdapterService: this.filesStorageClientAdapterService, + targetSchoolId: params.targetSchoolId, }); const copyStatus = await this.boardNodeCopyService.copy(originalBoard, copyContext); diff --git a/apps/server/src/modules/board/uc/board.uc.spec.ts b/apps/server/src/modules/board/uc/board.uc.spec.ts index ff22353895b..276f3275458 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -442,7 +442,7 @@ describe(BoardUc.name, () => { it('should call the service to find the user', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); }); @@ -450,7 +450,7 @@ describe(BoardUc.name, () => { it('should call the service to find the board', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(ColumnBoard, boardId); }); @@ -458,7 +458,7 @@ describe(BoardUc.name, () => { it('[deprecated] should call course repo to find the course', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(courseRepo.findById).toHaveBeenCalled(); }); @@ -466,7 +466,7 @@ describe(BoardUc.name, () => { it('should call Board Permission Service to check permission', async () => { const { user, board } = setup(); - await uc.copyBoard(user.id, board.id); + await uc.copyBoard(user.id, board.id, user.school.id); expect(boardPermissionService.checkPermission).toHaveBeenCalledWith(user.id, board, Action.read); }); @@ -478,7 +478,7 @@ describe(BoardUc.name, () => { // TODO should not use course repo courseRepo.findById.mockResolvedValueOnce(course); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, course, { action: Action.write, @@ -489,7 +489,7 @@ describe(BoardUc.name, () => { it('should call the service to copy the board', async () => { const { user, boardId } = setup(); - await uc.copyBoard(user.id, boardId); + await uc.copyBoard(user.id, boardId, user.school.id); expect(columnBoardService.copyColumnBoard).toHaveBeenCalledWith( expect.objectContaining({ userId: user.id, originalColumnBoardId: boardId }) @@ -505,7 +505,7 @@ describe(BoardUc.name, () => { }; columnBoardService.copyColumnBoard.mockResolvedValueOnce(copyStatus); - const result = await uc.copyBoard(user.id, boardId); + const result = await uc.copyBoard(user.id, boardId, user.school.id); expect(result).toEqual(copyStatus); }); diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index 3dae0f22bb2..83d250541c4 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -116,7 +116,7 @@ export class BoardUc { return column; } - async copyBoard(userId: EntityId, boardId: EntityId): Promise { + async copyBoard(userId: EntityId, boardId: EntityId, schoolId: EntityId): Promise { this.logger.debug({ action: 'copyBoard', userId, boardId }); const board = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); @@ -132,6 +132,7 @@ export class BoardUc { sourceStorageLocationReference: storageLocationReference, targetStorageLocationReference: storageLocationReference, userId, + targetSchoolId: schoolId, }); return copyStatus; diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts index 0f82cd9f790..376f89eec5d 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts @@ -324,6 +324,7 @@ describe('board copy service', () => { sourceStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, targetStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, userId: user.id, + targetSchoolId: user.school.id, }; await copyService.copyBoard({ originalBoard, user, originalCourse: destinationCourse, destinationCourse }); diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.ts b/apps/server/src/modules/learnroom/service/board-copy.service.ts index 2678f13d3e4..c4e465cde90 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.ts @@ -153,6 +153,7 @@ export class BoardCopyService { sourceStorageLocationReference: { id: originalCourse.school.id, type: StorageLocation.SCHOOL }, targetStorageLocationReference: { id: destinationCourse.school.id, type: StorageLocation.SCHOOL }, userId: user.id, + targetSchoolId: user.school.id, }); } diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts index fa89376fba2..41f9cf8e443 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts @@ -1,9 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; +import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { LessonCopyService } from '@modules/lesson/service'; import { ToolContextType } from '@modules/tool/common/enum'; -import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; +import { ContextExternalTool, CopyContextExternalToolRejectData } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { schoolExternalToolFactory } from '@modules/tool/school-external-tool/testing'; import { ToolConfig } from '@modules/tool/tool-config'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; @@ -17,7 +19,10 @@ import { setupEntities, userFactory, } from '@shared/testing'; -import { contextExternalToolFactory } from '../../tool/context-external-tool/testing'; +import { + contextExternalToolFactory, + copyContextExternalToolRejectDataFactory, +} from '../../tool/context-external-tool/testing'; import { BoardCopyService } from './board-copy.service'; import { CourseCopyService } from './course-copy.service'; import { CourseRoomsService } from './course-rooms.service'; @@ -105,13 +110,20 @@ describe('course copy service', () => { describe('copy course', () => { const setup = () => { - const user = userFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ school }); const allCourses = courseFactory.buildList(3, { teachers: [user] }); const course = allCourses[0]; const originalBoard = boardFactory.build({ course }); const courseCopy = courseFactory.buildWithId({ teachers: [user] }); const boardCopy = boardFactory.build({ course: courseCopy }); - const tools: ContextExternalTool[] = contextExternalToolFactory.buildList(2); + const schoolTool: SchoolExternalTool = schoolExternalToolFactory.build({ schoolId: school.id }); + const tools: ContextExternalTool[] = contextExternalToolFactory.buildList(2, { + schoolToolRef: { + schoolToolId: schoolTool.id, + schoolId: school.id, + }, + }); userRepo.findById.mockResolvedValue(user); courseRepo.findById.mockResolvedValue(course); @@ -349,8 +361,112 @@ describe('course copy service', () => { const status = await service.copyCourse({ userId: user.id, courseId: course.id }); const courseCopy = status.copyEntity as Course; - expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tools[0], courseCopy.id); - expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith(tools[1], courseCopy.id); + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + tools[0], + courseCopy.id, + tools[0].schoolToolRef.schoolId + ); + expect(contextExternalToolService.copyContextExternalTool).toHaveBeenCalledWith( + tools[1], + courseCopy.id, + tools[0].schoolToolRef.schoolId + ); + }); + + describe('when the all ctl tools of course are successfully copied', () => { + it('should return in the elements field the copy status for course tools as success', async () => { + const { course, user } = setup(); + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).not.toBeUndefined(); + expect(courseToolCopyStatus?.status).toEqual(CopyStatusEnum.SUCCESS); + }); + }); + + describe('when only some of the ctl tools of course are successfully copied', () => { + const setupPartialCopySuccessTools = () => { + const { course, user, tools } = setup(); + + const copyRejectData = copyContextExternalToolRejectDataFactory.build(); + const mockWithCorrectType = Object.create( + CopyContextExternalToolRejectData.prototype + ) as CopyContextExternalToolRejectData; + Object.assign(mockWithCorrectType, { ...copyRejectData }); + + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(mockWithCorrectType); + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(tools[0]); + + copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.PARTIAL); + + return { course, user }; + }; + + it('should return in the elements field the copy status for course tools as partial', async () => { + const { course, user } = setupPartialCopySuccessTools(); + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).not.toBeUndefined(); + expect(courseToolCopyStatus?.status).toEqual(CopyStatusEnum.PARTIAL); + }); + }); + + describe('when the all ctl tools of course failed to be copied', () => { + const setupAllCopyFailedTools = () => { + const { course, user } = setup(); + + const copyRejectData = copyContextExternalToolRejectDataFactory.build(); + const mockWithCorrectType = Object.create( + CopyContextExternalToolRejectData.prototype + ) as CopyContextExternalToolRejectData; + Object.assign(mockWithCorrectType, { ...copyRejectData }); + + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(mockWithCorrectType); + contextExternalToolService.copyContextExternalTool.mockResolvedValueOnce(mockWithCorrectType); + + copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.FAIL); + + return { course, user }; + }; + + it('should return in the elements field the copy status for course tools as partial', async () => { + const { course, user } = setupAllCopyFailedTools(); + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).not.toBeUndefined(); + expect(courseToolCopyStatus?.status).toEqual(CopyStatusEnum.FAIL); + }); + }); + + describe('when there are no ctl tools to copy', () => { + const setupNoTools = () => { + const { course, user } = setup(); + + contextExternalToolService.findAllByContext.mockResolvedValueOnce([]); + + copyHelperService.deriveStatusFromElements.mockReturnValue(CopyStatusEnum.SUCCESS); + + return { course, user }; + }; + + it('should not return copy status of course tools in the elements field', async () => { + const { course, user } = setupNoTools(); + + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).toBeUndefined(); + }); }); }); @@ -403,6 +519,17 @@ describe('course copy service', () => { expect(contextExternalToolService.copyContextExternalTool).not.toHaveBeenCalled(); }); + + it('should not return copy status of course tools in the elements field', async () => { + const { course, user } = setup(); + + const status = await service.copyCourse({ userId: user.id, courseId: course.id }); + const courseToolCopyStatus: CopyStatus | undefined = status.elements?.find( + (copyStatus: CopyStatus) => copyStatus.type === CopyElementType.EXTERNAL_TOOL + ); + + expect(courseToolCopyStatus).toBeUndefined(); + }); }); describe('when course is empty', () => { diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.ts b/apps/server/src/modules/learnroom/service/course-copy.service.ts index 66da0757f29..d3b82021d00 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.ts @@ -1,6 +1,10 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { ToolContextType } from '@modules/tool/common/enum'; -import { ContextExternalTool, ContextRef } from '@modules/tool/context-external-tool/domain'; +import { + ContextExternalTool, + ContextRef, + CopyContextExternalToolRejectData, +} from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { ToolConfig } from '@modules/tool/tool-config'; import { Injectable } from '@nestjs/common'; @@ -53,21 +57,25 @@ export class CourseCopyService { // copy course and board const courseCopy = await this.copyCourseEntity({ user, originalCourse, copyName }); + + let courseToolsCopyStatus: CopyStatus | null = null; if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED')) { const contextRef: ContextRef = { id: courseId, type: ToolContextType.COURSE }; const contextExternalToolsInContext: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext(contextRef); - await Promise.all( - contextExternalToolsInContext.map(async (tool: ContextExternalTool): Promise => { - const copiedTool: ContextExternalTool = await this.contextExternalToolService.copyContextExternalTool( - tool, - courseCopy.id - ); + const copyCourseToolsResult = await Promise.all( + contextExternalToolsInContext.map( + async (tool: ContextExternalTool): Promise => { + const copiedResult: ContextExternalTool | CopyContextExternalToolRejectData = + await this.contextExternalToolService.copyContextExternalTool(tool, courseCopy.id, user.school.id); - return copiedTool; - }) + return copiedResult; + } + ) ); + + courseToolsCopyStatus = this.deriveCourseToolCopyStatus(copyCourseToolsResult); } const boardStatus = await this.boardCopyService.copyBoard({ @@ -78,7 +86,12 @@ export class CourseCopyService { }); const finishedCourseCopy = await this.finishCourseCopying(courseCopy); - const courseStatus = this.deriveCourseStatus(originalCourse, finishedCourseCopy, boardStatus); + const courseStatus = this.deriveCourseStatus( + originalCourse, + finishedCourseCopy, + boardStatus, + courseToolsCopyStatus + ); return courseStatus; } @@ -105,7 +118,12 @@ export class CourseCopyService { return courseCopy; } - private deriveCourseStatus(originalCourse: Course, courseCopy: Course, boardStatus: CopyStatus): CopyStatus { + private deriveCourseStatus( + originalCourse: Course, + courseCopy: Course, + boardStatus: CopyStatus, + courseToolsCopyStatus: CopyStatus | null + ): CopyStatus { const elements = [ { type: CopyElementType.METADATA, @@ -126,11 +144,8 @@ export class CourseCopyService { boardStatus, ]; - if (this.configService.get('FEATURE_CTL_TOOLS_COPY_ENABLED')) { - elements.push({ - type: CopyElementType.EXTERNAL_TOOL, - status: CopyStatusEnum.SUCCESS, - }); + if (courseToolsCopyStatus) { + elements.push(courseToolsCopyStatus); } const courseGroupsExist = originalCourse.getCourseGroupItems().length > 0; @@ -151,4 +166,30 @@ export class CourseCopyService { }; return status; } + + private deriveCourseToolCopyStatus( + copyCourseToolsResult: (ContextExternalTool | CopyContextExternalToolRejectData)[] + ): CopyStatus | null { + if (!copyCourseToolsResult.length) { + return null; + } + + const rejectedCopies: CopyContextExternalToolRejectData[] = copyCourseToolsResult.filter( + (result) => result instanceof CopyContextExternalToolRejectData + ); + + let status: CopyStatusEnum; + if (rejectedCopies.length === copyCourseToolsResult.length) { + status = CopyStatusEnum.FAIL; + } else if (rejectedCopies.length > 0) { + status = CopyStatusEnum.PARTIAL; + } else { + status = CopyStatusEnum.SUCCESS; + } + + return { + type: CopyElementType.EXTERNAL_TOOL, + status, + }; + } } diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts index ff24d2ed78b..02309bc3241 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts @@ -1,30 +1,47 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { CopyApiResponse, CopyElementType, CopyStatusEnum } from '@modules/copy-helper'; -import { ServerTestModule } from '@modules/server'; +import { serverConfig, type ServerConfig, ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; +import { Course } from '@shared/domain/entity'; +import { BoardExternalReferenceType } from '@modules/board'; +import { BoardNodeType } from '@modules/board/domain'; +import { BoardNodeEntity } from '@modules/board/repo'; +import { + cardEntityFactory, + columnBoardEntityFactory, + columnEntityFactory, + externalToolElementEntityFactory, +} from '@modules/board/testing'; +import { ContextExternalToolEntity, ContextExternalToolType } from '@modules/tool/context-external-tool/entity'; +import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; import { cleanupCollections, courseFactory, - roleFactory, schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, - userFactory, } from '@shared/testing'; -import { ShareTokenContext, ShareTokenContextType, ShareTokenParentType } from '../../domainobject/share-token.do'; -import { ShareTokenService } from '../../service'; +import { shareTokenFactory } from '../../testing/share-token.factory'; +import { ShareTokenContextType } from '../../domainobject/share-token.do'; +import { ShareTokenImportBodyParams } from '../dto'; describe(`Share Token Import (API)`, () => { + const getSubPath = (token: string): string => { + const subPath = `/${token}/import`; + return subPath; + }; + let app: INestApplication; let em: EntityManager; let testApiClient: TestApiClient; - let shareTokenService: ShareTokenService; beforeAll(async () => { - const module = await Test.createTestingModule({ + const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], }).compile(); @@ -32,18 +49,52 @@ describe(`Share Token Import (API)`, () => { await app.init(); em = module.get(EntityManager); testApiClient = new TestApiClient(app, 'sharetoken'); - shareTokenService = module.get(ShareTokenService); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); }); beforeEach(async () => { await cleanupCollections(em); + Configuration.set('FEATURE_COURSE_SHARE', true); - }); + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', true); - afterAll(async () => { - await app.close(); + const config: ServerConfig = serverConfig(); + config.FEATURE_CTL_TOOLS_COPY_ENABLED = true; }); + const setupSchoolExclusiveImport = async () => { + await cleanupCollections(em); + + const school = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const course = courseFactory.buildWithId({ teachers: [teacherUser], school: teacherUser.school }); + + const shareToken = shareTokenFactory.withParentTypeCourse().build({ + parentId: course.id, + contextType: ShareTokenContextType.School, + contextId: school.id, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, school, course, shareToken]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + token: shareToken.token, + elementType: CopyElementType.COURSE, + course, + }; + }; + describe('POST /sharetoken/:token/import', () => { describe('when the user is not authenticated', () => { it('should return a 401 error', async () => { @@ -55,118 +106,719 @@ describe(`Share Token Import (API)`, () => { }); }); - describe('when the user is valid', () => { - const setup = async (context?: ShareTokenContext) => { - const school = schoolEntityFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.COURSE_CREATE], + describe('with the feature disabled', () => { + beforeEach(() => { + Configuration.set('FEATURE_COURSE_SHARE', false); + }); + + it('should return a 403 error', async () => { + const { loggedInClient, token } = await setupSchoolExclusiveImport(); + const response = await loggedInClient.post(`${token}/import`, { + newName: 'NewName', }); - const user = userFactory.build({ school, roles }); - const course = courseFactory.build({ teachers: [user] }); - await em.persistAndFlush([user, course]); - - const shareToken = await shareTokenService.createToken( - { - parentType: ShareTokenParentType.Course, - parentId: course.id, - }, - { context } - ); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - await em.persistAndFlush([teacherAccount, teacherUser]); - em.clear(); + describe('with a valid token', () => { + it('should return status 201', async () => { + const { loggedInClient, token } = await setupSchoolExclusiveImport(); - const loggedInClient = await testApiClient.login(teacherAccount); + const response = await loggedInClient.post(getSubPath(token), { newName: 'NewName' }); - return { - loggedInClient, - token: shareToken.token, - elementType: CopyElementType.COURSE, + expect(response.status).toEqual(HttpStatus.CREATED); + }); + + it('should return a valid result', async () => { + const { loggedInClient, token, elementType } = await setupSchoolExclusiveImport(); + const newName = 'NewName'; + const response = await loggedInClient.post(getSubPath(token), { newName }); + + const expectedResult: CopyApiResponse = { + id: expect.any(String), + type: elementType, + title: newName, + status: CopyStatusEnum.SUCCESS, }; + + expect(response.body as CopyApiResponse).toEqual(expect.objectContaining(expectedResult)); + }); + }); + + describe('when doing a valid course import from another school', () => { + const setupCrossSchoolImport = async () => { + const targetSchool = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school: targetSchool }); + + const sourceSchool = schoolEntityFactory.buildWithId(); + const course = courseFactory.buildWithId({ school: sourceSchool }); + + const shareToken = shareTokenFactory.withParentTypeCourse().build({ + parentId: course.id, + contextType: undefined, + contextId: undefined, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, targetSchool, course, shareToken]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, token: shareToken.token, targetSchool, course }; }; - describe('with the feature disabled', () => { - beforeEach(() => { - Configuration.set('FEATURE_COURSE_SHARE', false); + describe('when the course has course tools', () => { + describe('when the importing school has the proper school external tool', () => { + const setupExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const externalTool = externalToolEntityFactory.buildWithId(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + tool: externalTool, + }); + + const sourceCourseTools = contextExternalToolEntityFactory.buildList(2, { + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.COURSE, + contextId: course.id, + }); + + const targetSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: targetSchool, + tool: externalTool, + }); + + await em.persistAndFlush([externalTool, targetSchoolTool, sourceSchoolTool, ...sourceCourseTools]); + em.clear(); + + return { + loggedInClient, + token, + targetSchool, + targetSchoolTool, + }; + }; + + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should save the course tools with the correct external school id', async () => { + const { loggedInClient, token, targetSchool, targetSchoolTool } = await setupExistingSchoolTool(); + + const newName = 'newName'; + const response = await loggedInClient.post(getSubPath(token), { newName }); + + expect(response.status).toEqual(201); + + const copiedCourse: Course = await em.findOneOrFail(Course, { school: targetSchool }); + const copiedCourseTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.COURSE, + contextId: new ObjectId(copiedCourse.id), + }); + + expect(copiedCourseTools.length).toEqual(2); + copiedCourseTools.forEach((courseTool) => { + expect(courseTool.schoolTool.id).toEqual(targetSchoolTool.id); + }); + }); }); - it('should return a 403 error', async () => { - const { loggedInClient, token } = await setup(); - const response = await loggedInClient.post(`${token}/import`, { - newName: 'NewName', + describe('when the importing school does not have the proper school external tool', () => { + const setupNonExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + }); + + const sourceCourseTools = contextExternalToolEntityFactory.buildListWithId(2, { + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.COURSE, + contextId: course.id, + }); + + await em.persistAndFlush([sourceSchoolTool, ...sourceCourseTools]); + em.clear(); + + return { loggedInClient, token, targetSchool }; + }; + + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should not save the course tools', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedCourse: Course = await em.findOneOrFail(Course, { school: targetSchool }); + const copiedCourseTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.COURSE, + contextId: new ObjectId(copiedCourse.id), + }); + + expect(copiedCourseTools.length).toEqual(0); }); - expect(response.status).toBe(HttpStatus.FORBIDDEN); }); }); - describe('with a valid token', () => { - it('should return status 201', async () => { - const { loggedInClient, token } = await setup(); + describe('when the course has boards with tool elements', () => { + const setupBoardEntitiesWithTools = ( + course: Course, + boardToolOne: ContextExternalToolEntity, + boardToolTwo: ContextExternalToolEntity + ) => { + const columnBoardNode = columnBoardEntityFactory.build({ + context: { + type: BoardExternalReferenceType.Course, + id: course.id, + }, + }); + + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + + const cardNode = cardEntityFactory.withParent(columnNode).build(); + + const boardToolElementOne = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 0, + contextExternalToolId: boardToolOne.id, + }); + + const boardToolElementTwo = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 1, + contextExternalToolId: boardToolTwo.id, + }); + + em.persist([columnBoardNode, columnNode, cardNode, boardToolElementOne, boardToolElementTwo]); + }; + + describe('when the importing school has the proper school external tool', () => { + const setupExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const externalTool = externalToolEntityFactory.buildWithId(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + tool: externalTool, + }); - const response = await loggedInClient.post(`${token}/import`, { - newName: 'NewName', + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const targetSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: targetSchool, + tool: externalTool, + }); + + setupBoardEntitiesWithTools(course, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([ + externalTool, + targetSchoolTool, + sourceSchoolTool, + sourceBoardToolOne, + sourceBoardToolTwo, + ]); + em.clear(); + + return { loggedInClient, token, targetSchool, targetSchoolTool }; + }; + + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should save the copied board', async () => { + const { loggedInClient, token, targetSchool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedCourse = await em.findOneOrFail(Course, { school: targetSchool }); + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === copiedCourse.id + ); + + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should copy the board tool elements', async () => { + const { loggedInClient, token } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const persistedBoardTools: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.EXTERNAL_TOOL, + }); + + expect(persistedBoardTools.length).toBeGreaterThan(2); }); - expect(response.status).toEqual(HttpStatus.CREATED); + it('should copy the board context external tools with the correct school external tool', async () => { + const { loggedInClient, token, targetSchoolTool } = await setupExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedBoardTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + schoolTool: targetSchoolTool, + }); + + expect(copiedBoardTools.length).toEqual(2); + }); }); - it('should return a valid result', async () => { - const { loggedInClient, token, elementType } = await setup(); - const newName = 'NewName'; - const response = await loggedInClient.post(`${token}/import`, { - newName, - }); - const expectedResult: CopyApiResponse = { - id: expect.any(String), - type: elementType, - title: newName, - status: CopyStatusEnum.SUCCESS, + describe('when the importing school does not have the proper school external tool', () => { + const setupNonExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, course } = await setupCrossSchoolImport(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: course.school, + }); + + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + setupBoardEntitiesWithTools(course, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([sourceSchoolTool, sourceBoardToolOne, sourceBoardToolTwo]); + em.clear(); + + return { loggedInClient, token, targetSchool }; }; - expect(response.body as CopyApiResponse).toEqual(expect.objectContaining(expectedResult)); + it('should save the copied course', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + await em.findOneOrFail(Course, { school: targetSchool }); + }); + + it('should save the copied board', async () => { + const { loggedInClient, token, targetSchool } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const copiedCourse = await em.findOneOrFail(Course, { school: targetSchool }); + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === copiedCourse.id + ); + + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should not copy the board tools and replace them with deleted elements', async () => { + const { loggedInClient, token } = await setupNonExistingSchoolTool(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 'newName' }); + + expect(response.status).toEqual(201); + + const persistedBoardTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + }); + const deletedElementNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.DELETED_ELEMENT, + }); + + expect(persistedBoardTools.length).not.toBeGreaterThan(2); + expect(deletedElementNodes.length).toEqual(2); + }); }); }); + }); - describe('with invalid token', () => { - it('should return status 404', async () => { - const { loggedInClient } = await setup(); + describe('when doing a valid board import from another school', () => { + const setupCrossSchoolImport = async () => { + const targetSchool = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school: targetSchool }, [ + Permission.COURSE_EDIT, + ]); - const response = await loggedInClient.post(`invalid_token/import`, { - newName: 'NewName', - }); + const targetCourse = courseFactory.buildWithId({ + school: targetSchool, + teachers: [teacherUser], + }); - expect(response.status).toEqual(HttpStatus.NOT_FOUND); + const sourceSchool = schoolEntityFactory.buildWithId(); + const sourceCourse = courseFactory.buildWithId({ school: sourceSchool }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { + id: sourceCourse.id, + type: BoardExternalReferenceType.Course, + }, }); + + const shareToken = shareTokenFactory.withParentTypeBoard().build({ + parentId: columnBoardNode.id, + contextType: undefined, + contextId: undefined, + }); + + await em.persistAndFlush([ + teacherAccount, + teacherUser, + targetSchool, + targetCourse, + sourceCourse, + shareToken, + columnBoardNode, + ]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + token: shareToken.token, + elementType: CopyElementType.COLUMNBOARD, + targetSchool, + targetCourse, + sourceCourse, + columnBoardNode, + }; + }; + + it('should return status 201', async () => { + const { loggedInClient, token, targetCourse } = await setupCrossSchoolImport(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + }); + + it('should return a valid response body', async () => { + const { loggedInClient, token, elementType, targetCourse } = await setupCrossSchoolImport(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + const body = response.body as CopyApiResponse; + + const expectedResult: CopyApiResponse = { + id: expect.any(String), + type: elementType, + status: CopyStatusEnum.SUCCESS, + }; + + expect(body).toEqual(expect.objectContaining(expectedResult)); }); - describe('with invalid context', () => { - it('should return status 403', async () => { - const otherSchool = schoolEntityFactory.build(); - await em.persistAndFlush(otherSchool); + describe('when the board has tool elements', () => { + const populateColumnBoardWithTools = ( + columnBoardNode: BoardNodeEntity, + boardToolOne: ContextExternalToolEntity, + boardToolTwo: ContextExternalToolEntity + ) => { + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); - const { loggedInClient, token: tokenFromOtherSchool } = await setup({ - contextId: otherSchool.id, - contextType: ShareTokenContextType.School, + const cardNode = cardEntityFactory.withParent(columnNode).build(); + + const boardToolElementOne = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 0, + contextExternalToolId: boardToolOne.id, }); - const response = await loggedInClient.post(`${tokenFromOtherSchool}/import`, { - newName: 'NewName', + const boardToolElementTwo = externalToolElementEntityFactory.withParent(cardNode).build({ + position: 1, + contextExternalToolId: boardToolTwo.id, }); - expect(response.status).toEqual(HttpStatus.FORBIDDEN); + em.persist([columnBoardNode, columnNode, cardNode, boardToolElementOne, boardToolElementTwo]); + }; + + describe('when the importing school has the proper school external tool', () => { + const setupExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, targetCourse, sourceCourse, columnBoardNode } = + await setupCrossSchoolImport(); + + const externalTool = externalToolEntityFactory.buildWithId(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: sourceCourse.school, + tool: externalTool, + }); + + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const targetSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: targetSchool, + tool: externalTool, + }); + + populateColumnBoardWithTools(columnBoardNode, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([ + externalTool, + targetSchoolTool, + sourceSchoolTool, + sourceBoardToolOne, + sourceBoardToolTwo, + ]); + em.clear(); + + return { loggedInClient, token, targetSchool, targetSchoolTool, targetCourse }; + }; + + it('should save the copied board', async () => { + const { loggedInClient, token, targetCourse } = await setupExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === targetCourse.id + ); + + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should copy the course tools and reassign them to the correct school external tool', async () => { + const { loggedInClient, token, targetSchoolTool, targetCourse } = await setupExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const copiedBoardTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + schoolTool: targetSchoolTool, + }); + expect(copiedBoardTools.length).toEqual(2); + }); }); - }); - describe('with invalid new name', () => { - it('should return status 501', async () => { - const { loggedInClient, token } = await setup(); - const response = await loggedInClient.post(`${token}/import`, { - newName: 42, + describe('when the importing school does not have the proper school external tool', () => { + const setupNonExistingSchoolTool = async () => { + const { loggedInClient, token, targetSchool, targetCourse, sourceCourse, columnBoardNode } = + await setupCrossSchoolImport(); + + const sourceSchoolTool = schoolExternalToolEntityFactory.buildWithId({ + school: sourceCourse.school, + }); + + const sourceBoardToolOne = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + const sourceBoardToolTwo = contextExternalToolEntityFactory.buildWithId({ + schoolTool: sourceSchoolTool, + contextType: ContextExternalToolType.BOARD_ELEMENT, + contextId: new ObjectId().toHexString(), + }); + + populateColumnBoardWithTools(columnBoardNode, sourceBoardToolOne, sourceBoardToolTwo); + + await em.persistAndFlush([sourceSchoolTool, sourceBoardToolOne, sourceBoardToolTwo]); + em.clear(); + + return { loggedInClient, token, targetSchool, targetCourse }; + }; + + it('should save the copied board', async () => { + const { loggedInClient, token, targetCourse } = await setupNonExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const columnBoardNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.COLUMN_BOARD, + }); + const copiedColumnBoardNode: BoardNodeEntity | undefined = columnBoardNodes.find( + (node) => node.context?.id === targetCourse.id + ); + expect(copiedColumnBoardNode).not.toBeUndefined(); + }); + + it('should not copy the board tool elements and replace them with deleted elements', async () => { + const { loggedInClient, token, targetCourse } = await setupNonExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const persistedBoardToolElements: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.EXTERNAL_TOOL, + }); + const deletedElementNodes: BoardNodeEntity[] = await em.find(BoardNodeEntity, { + type: BoardNodeType.DELETED_ELEMENT, + }); + + expect(persistedBoardToolElements.length).not.toBeGreaterThan(2); + expect(deletedElementNodes.length).toEqual(2); + }); + + it('should not copy the board context external tools', async () => { + const { loggedInClient, token, targetCourse } = await setupNonExistingSchoolTool(); + + const data: ShareTokenImportBodyParams = { + newName: 'newName', + destinationId: targetCourse.id, + }; + const response = await loggedInClient.post(getSubPath(token), data); + + expect(response.status).toEqual(201); + + const persistedBoardContextTools: ContextExternalToolEntity[] = await em.find(ContextExternalToolEntity, { + contextType: ContextExternalToolType.BOARD_ELEMENT, + }); + expect(persistedBoardContextTools.length).not.toBeGreaterThan(2); }); - expect(response.status).toEqual(HttpStatus.NOT_IMPLEMENTED); }); }); }); + + describe('with invalid token', () => { + it('should return status 404', async () => { + const { loggedInClient } = await setupSchoolExclusiveImport(); + + const response = await loggedInClient.post(getSubPath('invalid_token'), { newName: 'NewName' }); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('with invalid context', () => { + const setupInvalidTokenContext = async () => { + await cleanupCollections(em); + + const school = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const course = courseFactory.buildWithId({ teachers: [teacherUser], school: teacherUser.school }); + + const otherSchool = schoolEntityFactory.buildWithId(); + + const shareToken = shareTokenFactory.withParentTypeCourse().build({ + parentId: course.id, + contextType: ShareTokenContextType.School, + contextId: otherSchool.id, + }); + + await em.persistAndFlush([teacherUser, teacherAccount, school, course, otherSchool, shareToken]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + shareTokenFromOtherSchool: shareToken.token, + }; + }; + + it('should return status 403', async () => { + const { loggedInClient, shareTokenFromOtherSchool } = await setupInvalidTokenContext(); + + const response = await loggedInClient.post(getSubPath(shareTokenFromOtherSchool), { newName: 'NewName' }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('with invalid new name', () => { + it('should return status 501', async () => { + const { loggedInClient, token } = await setupSchoolExclusiveImport(); + + const response = await loggedInClient.post(getSubPath(token), { newName: 42 }); + + expect(response.status).toEqual(HttpStatus.NOT_IMPLEMENTED); + }); + }); }); }); diff --git a/apps/server/src/modules/sharing/testing/share-token.factory.ts b/apps/server/src/modules/sharing/testing/share-token.factory.ts new file mode 100644 index 00000000000..a9faea781f8 --- /dev/null +++ b/apps/server/src/modules/sharing/testing/share-token.factory.ts @@ -0,0 +1,51 @@ +import { ShareToken, ShareTokenProperties } from '@modules/sharing/entity/share-token.entity'; +import { ShareTokenContextType, ShareTokenParentType } from '@modules/sharing/domainobject/share-token.do'; +import { BaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { nanoid } from 'nanoid'; +import { DeepPartial } from 'fishery'; + +class ShareTokenFactory extends BaseFactory { + withParentTypeCourse(): this { + const parentType = ShareTokenParentType.Course; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } + + withParentTypeBoard(): this { + const parentType = ShareTokenParentType.ColumnBoard; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } + + withParentTypeTask(): this { + const parentType = ShareTokenParentType.Task; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } + + withParentTypeLesson(): this { + const parentType = ShareTokenParentType.Lesson; + const parentId = new ObjectId().toHexString(); + const params: DeepPartial = { parentType, parentId }; + + return this.params(params); + } +} + +export const shareTokenFactory = ShareTokenFactory.define(ShareToken, () => { + return { + token: nanoid(12), + parentType: ShareTokenParentType.Course, + parentId: new ObjectId().toHexString(), + contextType: ShareTokenContextType.School, + contextId: new ObjectId().toHexString(), + expiresAt: new Date(Date.now() + 5 * 3600 * 1000), + }; +}); diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts index 193f4606285..5f496c86bfa 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts @@ -1139,6 +1139,7 @@ describe('ShareTokenUC', () => { targetStorageLocationReference: { type: StorageLocation.SCHOOL, id: course.school.id }, userId: user.id, copyTitle: newName, + targetSchoolId: user.school.id, }); }); it('should return the result', async () => { diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.ts b/apps/server/src/modules/sharing/uc/share-token.uc.ts index af4a079f10e..9cd1a9f5c7e 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.ts @@ -214,6 +214,7 @@ export class ShareTokenUC { targetStorageLocationReference, userId: user.id, copyTitle, + targetSchoolId: user.school.id, }); return copyStatus; } diff --git a/apps/server/src/modules/tool/context-external-tool/domain/copy-context-external-tool-reject-data.ts b/apps/server/src/modules/tool/context-external-tool/domain/copy-context-external-tool-reject-data.ts new file mode 100644 index 00000000000..eb3a3fcfe5c --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/domain/copy-context-external-tool-reject-data.ts @@ -0,0 +1,12 @@ +import { EntityId } from '@shared/domain/types'; + +export class CopyContextExternalToolRejectData { + readonly sourceContextExternalToolId: EntityId; + + readonly externalToolName: string; + + constructor(sourceContextExternalToolId: EntityId, externalToolName: string) { + this.sourceContextExternalToolId = sourceContextExternalToolId; + this.externalToolName = externalToolName; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/index.ts index e0b8c357724..c61b83991b2 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/index.ts @@ -5,3 +5,4 @@ export * from './event'; export * from './error'; export { LtiDeepLink } from './lti-deep-link'; export { LtiDeepLinkToken, LtiDeepLinkTokenProps } from './lti-deep-link-token'; +export { CopyContextExternalToolRejectData } from './copy-context-external-tool-reject-data'; diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts index ac626ba0c19..e799941da49 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts @@ -6,6 +6,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { ContextExternalToolRepo } from '@shared/repo'; import { legacySchoolDoFactory } from '@shared/testing'; +import { School } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; import { CustomParameter } from '../../common/domain'; import { ToolContextType } from '../../common/enum'; import { CommonToolDeleteService, CommonToolService } from '../../common/service'; @@ -13,12 +15,13 @@ import { ExternalToolService } from '../../external-tool'; import { ExternalTool } from '../../external-tool/domain'; import { customParameterFactory, externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalTool, SchoolExternalToolRef } from '../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ContextExternalTool, ContextExternalToolProps, ContextRef, + CopyContextExternalToolRejectData, RestrictedContextMismatchLoggableException, } from '../domain'; import { contextExternalToolFactory } from '../testing'; @@ -430,64 +433,201 @@ describe(ContextExternalToolService.name, () => { }; }; - it('should find schoolExternalTool', async () => { - const { contextExternalTool, contextCopyId } = setup(); + describe('when the tool to copy is from the same school', () => { + it('should find schoolExternalTool', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); - await service.copyContextExternalTool(contextExternalTool, contextCopyId); + await service.copyContextExternalTool(contextExternalTool, contextCopyId, schoolExternalTool.schoolId); - expect(schoolExternalToolService.findById).toHaveBeenCalledWith(contextExternalTool.schoolToolRef.schoolToolId); - }); + expect(schoolExternalToolService.findById).toHaveBeenCalledWith(contextExternalTool.schoolToolRef.schoolToolId); + }); - it('should find externalTool', async () => { - const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); + it('should find externalTool', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); - await service.copyContextExternalTool(contextExternalTool, contextCopyId); + await service.copyContextExternalTool(contextExternalTool, contextCopyId, schoolExternalTool.schoolId); - expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); - }); + expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); + }); - it('should remove values from protected parameters', async () => { - const { contextExternalTool, contextCopyId } = setup(); - - const copiedTool: ContextExternalTool = await service.copyContextExternalTool(contextExternalTool, contextCopyId); - - expect(copiedTool).toEqual( - expect.objectContaining({ - id: expect.any(String), - contextRef: { id: contextCopyId, type: ToolContextType.COURSE }, - displayName: contextExternalTool.displayName, - schoolToolRef: contextExternalTool.schoolToolRef, - parameters: [ - { - name: contextExternalTool.parameters[0].name, - value: undefined, - }, - { - name: contextExternalTool.parameters[1].name, - value: contextExternalTool.parameters[1].value, - }, - ], - }) - ); - }); + it('should remove values from protected parameters', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); - it('should not copy unused parameter', async () => { - const { contextExternalTool, contextCopyId, unusedParam } = setup(); + let copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = await service.copyContextExternalTool( + contextExternalTool, + contextCopyId, + schoolExternalTool.schoolId + ); + + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + copiedTool = copiedTool as ContextExternalTool; + + expect(copiedTool).toEqual( + expect.objectContaining({ + id: expect.any(String), + contextRef: { id: contextCopyId, type: ToolContextType.COURSE }, + displayName: contextExternalTool.displayName, + schoolToolRef: contextExternalTool.schoolToolRef, + parameters: [ + { + name: contextExternalTool.parameters[0].name, + value: undefined, + }, + { + name: contextExternalTool.parameters[1].name, + value: contextExternalTool.parameters[1].value, + }, + ], + }) + ); + }); - const copiedTool: ContextExternalTool = await service.copyContextExternalTool(contextExternalTool, contextCopyId); + it('should not copy unused parameter', async () => { + const { contextExternalTool, contextCopyId, unusedParam, schoolExternalTool } = setup(); + + let copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = await service.copyContextExternalTool( + contextExternalTool, + contextCopyId, + schoolExternalTool.schoolId + ); - expect(copiedTool.parameters.length).toEqual(2); - expect(copiedTool.parameters).not.toContain(unusedParam); + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + copiedTool = copiedTool as ContextExternalTool; + + expect(copiedTool.parameters.length).toEqual(2); + expect(copiedTool.parameters).not.toContain(unusedParam); + }); + + it('should save copied tool', async () => { + const { contextExternalTool, contextCopyId, schoolExternalTool } = setup(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, schoolExternalTool.schoolId); + + expect(contextExternalToolRepo.save).toHaveBeenCalledWith( + new ContextExternalTool({ ...contextExternalTool.getProps(), id: expect.any(String) }) + ); + }); }); - it('should save copied tool', async () => { - const { contextExternalTool, contextCopyId } = setup(); + describe('when the tool to copy is from another school', () => { + describe('when the target school has the correct school external tool', () => { + const setupTools = () => { + const { contextCopyId, contextExternalTool, schoolExternalTool } = setup(); + const sourceSchoolTool = schoolExternalTool; + + const targetSchool: School = schoolFactory.build(); + const targetSchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: sourceSchoolTool.toolId, + schoolId: targetSchool.id, + }); + + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([targetSchoolExternalTool]); + + const expectedSchoolToolRef: SchoolExternalToolRef = { + schoolToolId: targetSchoolExternalTool.id, + schoolId: targetSchool.id, + }; + + return { + contextCopyId, + contextExternalTool, + targetSchool, + targetSchoolExternalTool, + expectedSchoolToolRef, + }; + }; + + it('should find the correct SchoolExternalTool for the target school', async () => { + const { contextExternalTool, contextCopyId, targetSchool, targetSchoolExternalTool } = setupTools(); - await service.copyContextExternalTool(contextExternalTool, contextCopyId); + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ + toolId: targetSchoolExternalTool.toolId, + schoolId: targetSchool.id, + }); + }); - expect(contextExternalToolRepo.save).toHaveBeenCalledWith( - new ContextExternalTool({ ...contextExternalTool.getProps(), id: expect.any(String) }) - ); + it('should return the copied tool as type ContextExternalTool', async () => { + const { contextExternalTool, contextCopyId, targetSchool } = setupTools(); + + const copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + }); + + it('should assign the copied tool the correct school tool', async () => { + const { contextExternalTool, contextCopyId, targetSchool, expectedSchoolToolRef } = setupTools(); + + let copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(copiedTool instanceof ContextExternalTool).toEqual(true); + copiedTool = copiedTool as ContextExternalTool; + + expect(copiedTool.schoolToolRef).toMatchObject(expectedSchoolToolRef); + }); + + it('should saved the copied tool with correct school tool', async () => { + const { contextExternalTool, contextCopyId, expectedSchoolToolRef, targetSchool } = setupTools(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(contextExternalToolRepo.save).toBeCalledWith( + new ContextExternalTool({ + ...contextExternalTool.getProps(), + schoolToolRef: expectedSchoolToolRef, + id: expect.any(String), + }) + ); + }); + }); + + describe('when the target school does no have the correct school external tool', () => { + const setupTools = () => { + const { contextCopyId, contextExternalTool, schoolExternalTool } = setup(); + + const targetSchool: School = schoolFactory.build(); + + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([]); + + return { + contextCopyId, + contextExternalTool, + targetSchool, + sourceSchoolTool: schoolExternalTool, + }; + }; + + it('should find the correct SchoolExternalTool for the target school', async () => { + const { contextExternalTool, contextCopyId, targetSchool, sourceSchoolTool } = setupTools(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ + toolId: sourceSchoolTool.toolId, + schoolId: targetSchool.id, + }); + }); + + it('should return an object type CopyContextExternalToolRejectData', async () => { + const { contextExternalTool, contextCopyId, targetSchool } = setupTools(); + + const copiedTool: ContextExternalTool | CopyContextExternalToolRejectData = + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(copiedTool instanceof CopyContextExternalToolRejectData).toEqual(true); + }); + + it('should not save the copied tool to the database', async () => { + const { contextExternalTool, contextCopyId, targetSchool } = setupTools(); + + await service.copyContextExternalTool(contextExternalTool, contextCopyId, targetSchool.id); + + expect(contextExternalToolRepo.save).not.toBeCalled(); + }); + }); }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts index 61d39f19ebb..d4f203c4f94 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts @@ -12,6 +12,7 @@ import { ContextExternalTool, ContextExternalToolLaunchable, ContextRef, + CopyContextExternalToolRejectData, RestrictedContextMismatchLoggableException, } from '../domain'; import { ContextExternalToolQuery } from '../uc/dto/context-external-tool.types'; @@ -81,8 +82,9 @@ export class ContextExternalToolService { public async copyContextExternalTool( contextExternalTool: ContextExternalTool, - contextCopyId: EntityId - ): Promise { + contextCopyId: EntityId, + targetSchoolId: EntityId + ): Promise { const copy = new ContextExternalTool({ ...contextExternalTool.getProps(), id: new ObjectId().toHexString(), @@ -110,6 +112,23 @@ export class ContextExternalToolService { } }); + if (schoolExternalTool.schoolId !== targetSchoolId) { + const correctSchoolExternalTools: SchoolExternalTool[] = + await this.schoolExternalToolService.findSchoolExternalTools({ + toolId: schoolExternalTool.toolId, + schoolId: targetSchoolId, + }); + + if (correctSchoolExternalTools.length) { + copy.schoolToolRef.schoolToolId = correctSchoolExternalTools[0].id; + copy.schoolToolRef.schoolId = correctSchoolExternalTools[0].schoolId; + } else { + const copyRejectData = new CopyContextExternalToolRejectData(contextExternalTool.id, externalTool.name); + + return copyRejectData; + } + } + const copiedTool: ContextExternalTool = await this.contextExternalToolRepo.save(copy); return copiedTool; diff --git a/apps/server/src/modules/tool/context-external-tool/testing/copy-context-external-tool-reject-data.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/copy-context-external-tool-reject-data.factory.ts new file mode 100644 index 00000000000..d46430d4548 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/copy-context-external-tool-reject-data.factory.ts @@ -0,0 +1,10 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Factory } from 'fishery'; +import { CopyContextExternalToolRejectData } from '../domain'; + +export const copyContextExternalToolRejectDataFactory = Factory.define(() => { + return { + sourceContextExternalToolId: new ObjectId().toHexString(), + externalToolName: 'Test Tool', + }; +}); diff --git a/apps/server/src/modules/tool/context-external-tool/testing/index.ts b/apps/server/src/modules/tool/context-external-tool/testing/index.ts index a4ee8fd3198..ffa7c991a5d 100644 --- a/apps/server/src/modules/tool/context-external-tool/testing/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/testing/index.ts @@ -6,3 +6,4 @@ export { ltiDeepLinkTokenFactory } from './lti-deep-link-token.factory'; export { ltiDeepLinkTokenEntityFactory } from './lti-deep-link-token-entity.factory'; export { Lti11DeepLinkParamsFactory } from './lti11-deep-link-params.factory'; export { ltiDeepLinkEmbeddableFactory } from './lti-deep-link-embeddable.factory'; +export { copyContextExternalToolRejectDataFactory } from './copy-context-external-tool-reject-data.factory'; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 1679b78b973..980709a2218 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -331,5 +331,14 @@ "created_at": { "$date": "2024-11-28T18:05:40.839Z" } + }, + { + "_id": { + "$oid": "673fca34cc4a3264457c8ad1" + }, + "name": "Migration20241120100616", + "created_at": { + "$date": "2024-11-20T17:03:31.473Z" + } } ] From a9842f2087301bb5b186fcd7e8323d27c2437a77 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Mon, 9 Dec 2024 14:58:04 +0100 Subject: [PATCH 17/17] BC-8486 - typesafe typeguard for keys in object (#5372) * add typeguard *checkKeysInObject* ( instance, keys ) to guarantee that some properties are set or otherwise throw an exception --- .../shared/common/guards/type.guard.spec.ts | 198 +++++++++++------- .../src/shared/common/guards/type.guard.ts | 63 +++--- 2 files changed, 161 insertions(+), 100 deletions(-) diff --git a/apps/server/src/shared/common/guards/type.guard.spec.ts b/apps/server/src/shared/common/guards/type.guard.spec.ts index 8436c8bc6d2..93fc82e67a5 100644 --- a/apps/server/src/shared/common/guards/type.guard.spec.ts +++ b/apps/server/src/shared/common/guards/type.guard.spec.ts @@ -1,27 +1,33 @@ import { TypeGuard } from './type.guard'; +type ExampleObjectType = { + id?: number; + name?: string; + email?: string; +}; + describe('TypeGuard', () => { describe('isError', () => { describe('when passing type of value is an Error', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isError(new Error())).toBe(true); }); }); describe('when passing type of value is NOT an Error', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError('string')).toBe(false); }); }); @@ -29,29 +35,29 @@ describe('TypeGuard', () => { describe('isNull', () => { describe('when passing type of value is null', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNull(null)).toBe(true); }); }); describe('when passing type of value is NOT null', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNull(undefined)).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNull('string')).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNull('')).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNull({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNull(1)).toBe(false); }); }); @@ -59,29 +65,29 @@ describe('TypeGuard', () => { describe('isUndefined', () => { describe('when passing type of value is undefined', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isUndefined(undefined)).toBe(true); }); }); describe('when passing type of value is NOT undefined', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isUndefined(null)).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isUndefined('string')).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isUndefined('')).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isUndefined({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isUndefined(1)).toBe(false); }); }); @@ -89,33 +95,33 @@ describe('TypeGuard', () => { describe('isNumber', () => { describe('when passing type of value is a number', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNumber(123)).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNumber(-1)).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNumber(NaN)).toBe(true); }); }); describe('when passing type of value is NOT a number', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber('string')).toBe(false); }); }); @@ -137,19 +143,19 @@ describe('TypeGuard', () => { }); describe('when passing type of value is NOT a number', () => { - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber(undefined)).toThrowError('Type is not a number'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber(null)).toThrowError('Type is not a number'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber({})).toThrowError('Type is not a number'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber('string')).toThrowError('Type is not a number'); }); }); @@ -157,29 +163,29 @@ describe('TypeGuard', () => { describe('isString', () => { describe('when passing type of value is a string', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isString('string')).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isString('')).toBe(true); }); }); describe('when passing type of value is NOT a string', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString(1)).toBe(false); }); }); @@ -197,19 +203,19 @@ describe('TypeGuard', () => { }); describe('when passing type of value is NOT a string', () => { - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString(undefined)).toThrowError('Type is not a string'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString(null)).toThrowError('Type is not a string'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString({})).toThrowError('Type is not a string'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString(1)).toThrowError('Type is not a string'); }); }); @@ -229,23 +235,23 @@ describe('TypeGuard', () => { }); describe('when value is NOT in values', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings(undefined, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings(null, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings({}, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings(1, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings('string', [''])).toBe(false); }); }); @@ -306,29 +312,29 @@ describe('TypeGuard', () => { describe('isArray', () => { describe('when passing type of value is an array', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArray([])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArray(['', '', ''])).toBe(true); }); }); describe('when passing type of value is NOT an array', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray(1)).toBe(false); }); }); @@ -366,37 +372,37 @@ describe('TypeGuard', () => { describe('isArrayWithElements', () => { describe('when passing type of value is an array with elements', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArrayWithElements([1, 2, 3])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArrayWithElements(['a', 'b', 'c'])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArrayWithElements([{ a: 1 }, { b: 2 }])).toBe(true); }); }); describe('when passing type of value is NOT an array with elements', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements([])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements(1)).toBe(false); }); }); @@ -434,33 +440,33 @@ describe('TypeGuard', () => { describe('isDefinedObject', () => { describe('when passing type of value is an object', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isDefinedObject({})).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isDefinedObject({ a: 1 })).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isDefinedObject({ a: { b: 1 } })).toBe(true); }); }); describe('when passing type of value is NOT an object', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject([])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject('string')).toBe(false); }); }); @@ -482,19 +488,19 @@ describe('TypeGuard', () => { }); describe('when passing type of value is NOT an object', () => { - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject(undefined)).toThrowError('Type is not an object'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject(null)).toThrowError('Type is not an object'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject([])).toThrowError('Type is not an object'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject('string')).toThrowError('Type is not an object'); }); }); @@ -614,33 +620,33 @@ describe('TypeGuard', () => { describe('isEachKeyInObject', () => { describe('when passing value is an object that has all requested keys', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, ['x1', 'x2'])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, ['x1'])).toBe(true); }); }); describe('when passing params do not match', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, ['x1', 'x2', 'x3'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, 'x1' as unknown as string[])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject('string', ['x1', 'x2'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject(undefined, ['x1', 'x2'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject(null, ['x1', 'x2'])).toBe(false); }); }); @@ -649,7 +655,9 @@ describe('TypeGuard', () => { describe('checkKeyInObject', () => { describe('when passing value is an object that has the requested key', () => { it('should be return the key value', () => { - expect(TypeGuard.checkKeyInObject({ xyz: 'abc' }, 'xyz')).toEqual('abc'); + const result = TypeGuard.checkKeyInObject({ xyz: 'abc' }, 'xyz'); + + expect(result).toEqual('abc'); }); }); @@ -697,6 +705,44 @@ describe('TypeGuard', () => { }); }); + describe('checkKeysInInstance', () => { + describe('when passing value is an object that has the requested keys', () => { + it('should be return the object', () => { + const example: ExampleObjectType = { name: 'abc' }; + + const checkedObject = TypeGuard.checkKeysInInstance(example, ['name']); + + expect(checkedObject).toEqual(example); + }); + + it('should know the property is defined', () => { + const example: ExampleObjectType = { name: 'abc' }; + + const checkedObject = TypeGuard.checkKeysInInstance(example, ['name']); + + expect(checkedObject.name).toEqual('abc'); + }); + }); + + describe('when passing value and keys do not match', () => { + it('should throw an error', () => { + const example: ExampleObjectType = { id: 1, name: 'John Doe' }; + + expect(() => TypeGuard.checkKeysInInstance(example, ['email'])).toThrowError( + 'Object lacks this property: email. ' + ); + }); + + it('should throw an error', () => { + const example: ExampleObjectType = { id: 1, name: 'John Doe' }; + + expect(() => TypeGuard.checkKeysInInstance(example, ['email'])).toThrowError( + 'Object lacks this property: email. ' + ); + }); + }); + }); + describe('checkNotNullOrUndefined', () => { describe('when value is null', () => { it('should throw error if it is passed', () => { diff --git a/apps/server/src/shared/common/guards/type.guard.ts b/apps/server/src/shared/common/guards/type.guard.ts index d9f5b687308..342fc38c534 100644 --- a/apps/server/src/shared/common/guards/type.guard.ts +++ b/apps/server/src/shared/common/guards/type.guard.ts @@ -1,29 +1,31 @@ +type EnsureKeysAreSet = T & { [P in K]-?: T[P] }; + export class TypeGuard { - static isError(value: unknown): value is Error { + public static isError(value: unknown): value is Error { const isError = value instanceof Error; return isError; } - static isNull(value: unknown): value is null { + public static isNull(value: unknown): value is null { const isNull = value === null; return isNull; } - static isUndefined(value: unknown): value is undefined { + public static isUndefined(value: unknown): value is undefined { const isUndefined = value === undefined; return isUndefined; } - static isNumber(value: unknown): value is number { + public static isNumber(value: unknown): value is number { const isNumber = typeof value === 'number'; return isNumber; } - static checkNumber(value: unknown): number { + public static checkNumber(value: unknown): number { if (!TypeGuard.isNumber(value)) { throw new Error('Type is not a number'); } @@ -31,13 +33,13 @@ export class TypeGuard { return value; } - static isString(value: unknown): value is string { + public static isString(value: unknown): value is string { const isString = typeof value === 'string'; return isString; } - static checkString(value: unknown): string { + public static checkString(value: unknown): string { if (!TypeGuard.isString(value)) { throw new Error('Type is not a string'); } @@ -45,13 +47,13 @@ export class TypeGuard { return value; } - static isStringOfStrings(value: unknown, values: T[]): value is T { + public static isStringOfStrings(value: unknown, values: T[]): value is T { const isStringOfValue = TypeGuard.isString(value) && values.includes(value as T); return isStringOfValue; } - static checkStringOfStrings(value: unknown, values: T[]): T { + public static checkStringOfStrings(value: unknown, values: T[]): T { if (!TypeGuard.isStringOfStrings(value, values)) { throw new Error('Value is not in strings'); } @@ -59,13 +61,13 @@ export class TypeGuard { return value; } - static isArray(value: unknown): value is [] { + public static isArray(value: unknown): value is [] { const isArray = Array.isArray(value); return isArray; } - static checkArray(value: unknown): [] { + public static checkArray(value: unknown): [] { if (!TypeGuard.isArray(value)) { throw new Error('Type is not an array.'); } @@ -73,13 +75,13 @@ export class TypeGuard { return value; } - static isArrayWithElements(value: unknown): value is [] { + public static isArrayWithElements(value: unknown): value is [] { const isArrayWithElements = TypeGuard.isArray(value) && value.length > 0; return isArrayWithElements; } - static checkArrayWithElements(value: unknown): [] { + public static checkArrayWithElements(value: unknown): [] { if (!TypeGuard.isArrayWithElements(value)) { throw new Error('Type is not an array with elements.'); } @@ -87,13 +89,13 @@ export class TypeGuard { return value; } - static isDefinedObject(value: unknown): value is object { + public static isDefinedObject(value: unknown): value is object { const isObject = typeof value === 'object' && !TypeGuard.isArray(value) && !TypeGuard.isNull(value); return isObject; } - static checkDefinedObject(value: unknown): object { + public static checkDefinedObject(value: unknown): object { if (!TypeGuard.isDefinedObject(value)) { throw new Error('Type is not an object.'); } @@ -102,7 +104,7 @@ export class TypeGuard { } /** @return undefined if no object or key do not exists, otherwise the value of the key. */ - static getValueFromObjectKey(value: unknown, key: string): unknown { + public static getValueFromObjectKey(value: unknown, key: string): unknown { TypeGuard.checkString(key); const result: unknown = TypeGuard.isDefinedObject(value) ? value[key] : undefined; @@ -110,7 +112,7 @@ export class TypeGuard { return result; } - static getValueFromDeepObjectKey(value: unknown, keyPath: string[]): unknown { + public static getValueFromDeepObjectKey(value: unknown, keyPath: string[]): unknown { TypeGuard.checkArrayWithElements(keyPath); let result: unknown = value; @@ -122,7 +124,7 @@ export class TypeGuard { return result; } - static isEachKeyInObject>(value: unknown, keys: (keyof T)[]): value is T { + public static isEachKeyInObject>(value: unknown, keys: (keyof T)[]): value is T { if (!TypeGuard.isDefinedObject(value) || !TypeGuard.isArray(keys)) { return false; } @@ -139,7 +141,7 @@ export class TypeGuard { } /** @return value of requested key in object. */ - static checkKeyInObject(value: T, key: string, toThrow?: Error): unknown { + public static checkKeyInObject(value: T, key: string, toThrow?: Error): unknown { TypeGuard.checkString(key); const object = TypeGuard.checkDefinedObject(value); @@ -151,7 +153,11 @@ export class TypeGuard { return object[key]; } - static checkKeysInObject>(value: unknown, keys: (keyof T)[], toThrow?: Error): T { + public static checkKeysInObject>( + value: unknown, + keys: (keyof T)[], + toThrow?: Error + ): T { const object = TypeGuard.checkDefinedObject(value); if (!TypeGuard.isEachKeyInObject(object, keys)) { @@ -168,11 +174,20 @@ export class TypeGuard { return object; } - // add additional method checkKeysInObject with key array see use case for example in method mapEtherpadSessionToSession - // return an value that represent as type a interface that include all checked keys. - // Same interface can be usefull for checkKeyInObject + public static checkKeysInInstance( + obj: T, + keys: K[], + contextInfo = '' + ): EnsureKeysAreSet { + for (const key of keys) { + if (!(key in obj) || obj[key] === undefined) { + throw new Error(`Object lacks this property: ${String(key)}. ${contextInfo}`); + } + } + return obj as EnsureKeysAreSet; + } - static checkNotNullOrUndefined(value: T | null | undefined, toThrow?: Error): T { + public static checkNotNullOrUndefined(value: T | null | undefined, toThrow?: Error): T { if (TypeGuard.isNull(value)) { throw toThrow || new Error('Type is null.'); }