From 3199809e259f86ee59d5239d1e02f646e6d96086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:36:18 +0200 Subject: [PATCH 01/29] N21-2133 New SHD Authentication (#5202) --- .../src/modules/oauth-provider/api/dto/index.ts | 2 +- ....response.ts => oauth-provider-login.response.ts} | 4 ++-- .../api/mapper/oauth-provider-response.mapper.ts | 12 +++++++++--- .../oauth-provider/api/oauth-provider.controller.ts | 6 +++--- 4 files changed, 15 insertions(+), 9 deletions(-) rename apps/server/src/modules/oauth-provider/api/dto/response/{login.response.ts => oauth-provider-login.response.ts} (94%) diff --git a/apps/server/src/modules/oauth-provider/api/dto/index.ts b/apps/server/src/modules/oauth-provider/api/dto/index.ts index 0a12964d790..bc5a400ca9d 100644 --- a/apps/server/src/modules/oauth-provider/api/dto/index.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/index.ts @@ -13,5 +13,5 @@ export { ConsentResponse } from './response/consent.response'; export { RedirectResponse } from './response/redirect.response'; export { OidcContextResponse } from './response/oidc-context.response'; export { ConsentSessionResponse } from './response/consent-session.response'; -export { LoginResponse } from './response/login.response'; +export { OauthProviderLoginResponse } from './response/oauth-provider-login.response'; export { OAuthRejectableBody } from './request/oauth-rejectable.body'; diff --git a/apps/server/src/modules/oauth-provider/api/dto/response/login.response.ts b/apps/server/src/modules/oauth-provider/api/dto/response/oauth-provider-login.response.ts similarity index 94% rename from apps/server/src/modules/oauth-provider/api/dto/response/login.response.ts rename to apps/server/src/modules/oauth-provider/api/dto/response/oauth-provider-login.response.ts index c71ac20b83c..13c051026a0 100644 --- a/apps/server/src/modules/oauth-provider/api/dto/response/login.response.ts +++ b/apps/server/src/modules/oauth-provider/api/dto/response/oauth-provider-login.response.ts @@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { OauthClientResponse } from './oauth-client.response'; import { OidcContextResponse } from './oidc-context.response'; -export class LoginResponse { - constructor(props: LoginResponse) { +export class OauthProviderLoginResponse { + constructor(props: OauthProviderLoginResponse) { this.client = props.client; this.client_id = props.client_id; this.challenge = props.challenge; diff --git a/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts b/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts index d732f077e6f..4f6e2c41eac 100644 --- a/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts +++ b/apps/server/src/modules/oauth-provider/api/mapper/oauth-provider-response.mapper.ts @@ -6,7 +6,13 @@ import { ProviderOauthClient, ProviderRedirectResponse, } from '../../domain'; -import { ConsentResponse, ConsentSessionResponse, LoginResponse, OauthClientResponse, RedirectResponse } from '../dto'; +import { + ConsentResponse, + ConsentSessionResponse, + OauthClientResponse, + OauthProviderLoginResponse, + RedirectResponse, +} from '../dto'; @Injectable() export class OauthProviderResponseMapper { @@ -40,8 +46,8 @@ export class OauthProviderResponseMapper { return response; } - public static mapLoginResponse(providerLoginResponse: ProviderLoginResponse): LoginResponse { - const response: LoginResponse = new LoginResponse({ + public static mapLoginResponse(providerLoginResponse: ProviderLoginResponse): OauthProviderLoginResponse { + const response: OauthProviderLoginResponse = new OauthProviderLoginResponse({ client_id: providerLoginResponse.client.client_id, ...providerLoginResponse, }); diff --git a/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts b/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts index 21b51a1593b..7baa8333d52 100644 --- a/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts +++ b/apps/server/src/modules/oauth-provider/api/oauth-provider.controller.ts @@ -17,10 +17,10 @@ import { IdParams, ListOauthClientsParams, LoginRequestBody, - LoginResponse, OauthClientCreateBody, OauthClientResponse, OauthClientUpdateBody, + OauthProviderLoginResponse, RedirectResponse, RevokeConsentParams, } from './dto'; @@ -113,10 +113,10 @@ export class OauthProviderController { } @Get('loginRequest/:challenge') - public async getLoginRequest(@Param() params: ChallengeParams): Promise { + public async getLoginRequest(@Param() params: ChallengeParams): Promise { const loginResponse: ProviderLoginResponse = await this.oauthProviderLoginFlowUc.getLoginRequest(params.challenge); - const mapped: LoginResponse = OauthProviderResponseMapper.mapLoginResponse(loginResponse); + const mapped: OauthProviderLoginResponse = OauthProviderResponseMapper.mapLoginResponse(loginResponse); return mapped; } From cbf55e830a2d86619e394130f5ea235189a1cbfe Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:29:45 +0200 Subject: [PATCH 02/29] BC-7545 - update typescript v5.5.3 => v5.5.4 (#5206) --- package-lock.json | 35 ++++------------------------------- package.json | 2 +- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebb39045245..79952875444 100644 --- a/package-lock.json +++ b/package-lock.json @@ -221,7 +221,7 @@ "ts-loader": "^9.4.2", "ts-node": "^10.9.1", "tsconfig-paths": "^4.1.1", - "typescript": "^5.5.3" + "typescript": "^5.5.4" }, "engines": { "node": "20", @@ -4083,19 +4083,6 @@ } } }, - "node_modules/@feathersjs/authentication/node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@feathersjs/authentication/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -4196,19 +4183,6 @@ } } }, - "node_modules/@feathersjs/configuration/node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@feathersjs/errors": { "version": "5.0.29", "resolved": "https://registry.npmjs.org/@feathersjs/errors/-/errors-5.0.29.tgz", @@ -25530,10 +25504,9 @@ } }, "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "dev": true, + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 3eea0a58e66..4a7b586eb8a 100644 --- a/package.json +++ b/package.json @@ -338,6 +338,6 @@ "ts-loader": "^9.4.2", "ts-node": "^10.9.1", "tsconfig-paths": "^4.1.1", - "typescript": "^5.5.3" + "typescript": "^5.5.4" } } From d385a93d50d2e52335a697ce48f551fbd922319b Mon Sep 17 00:00:00 2001 From: Gordon Nicholas <160246213+GordonNicholasCap@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:25:08 +0200 Subject: [PATCH 03/29] N21-2138 migration wizard clear all auto-matches (#5193) --- .../api-test/import-user.api.spec.ts | 56 +++++++++++++++ .../controller/import-user.controller.ts | 13 ++++ .../user-import/uc/user-import.uc.spec.ts | 72 ++++++++++++++++++- .../modules/user-import/uc/user-import.uc.ts | 39 +++++++--- 4 files changed, 169 insertions(+), 11 deletions(-) diff --git a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts index c0aa9b06e36..e6268492d21 100644 --- a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts +++ b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts @@ -1187,4 +1187,60 @@ describe('ImportUser Controller (API)', () => { }); }); }); + + describe('[PATCH] /user/import/clear-all-auto-matches', () => { + describe('when user is unauthorized', () => { + const setup = () => { + return { + notLoggedInClient: new TestApiClient(app, 'user/import'), + }; + }; + + it('should return unauthorized', async () => { + const { notLoggedInClient } = setup(); + + await notLoggedInClient.patch('clear-all-auto-matches').expect(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user has no permission', () => { + const setup = async () => { + const { account, system } = await authenticatedUser([]); + setConfig(system._id.toString()); + const loggedInClient = await testApiClient.login(account); + + return { + loggedInClient, + }; + }; + + it('should return unauthorized', async () => { + const { loggedInClient } = await setup(); + + await loggedInClient.patch('clear-all-auto-matches').expect(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user has permission and all auto matches were successfully cleared', () => { + const setup = async () => { + const { school, system, account } = await authenticatedUser([Permission.IMPORT_USER_UPDATE], [], true, '00100'); + setConfig(system._id.toString()); + + const importusers = importUserFactory.buildList(10, { school }); + await em.persistAndFlush(importusers); + + const loggedInClient = await testApiClient.login(account); + + return { + loggedInClient, + }; + }; + + it('should return no content', async () => { + const { loggedInClient } = await setup(); + + await loggedInClient.patch('clear-all-auto-matches').expect(HttpStatus.NO_CONTENT); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.ts b/apps/server/src/modules/user-import/controller/import-user.controller.ts index edddcb8ffd4..2b2b13d225c 100644 --- a/apps/server/src/modules/user-import/controller/import-user.controller.ts +++ b/apps/server/src/modules/user-import/controller/import-user.controller.ts @@ -150,4 +150,17 @@ export class ImportUserController { async cancelMigration(@CurrentUser() currentUser: ICurrentUser): Promise { await this.userImportUc.cancelMigration(currentUser.userId); } + + @Patch('clear-all-auto-matches') + @ApiOperation({ + summary: 'Clear all auto matches', + description: 'Clear all auto matches from imported users of a school', + }) + @ApiNoContentResponse() + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + @HttpCode(HttpStatus.NO_CONTENT) + async clearAllAutoMatches(@CurrentUser() currentUser: ICurrentUser): Promise { + await this.userImportUc.clearAllAutoMatches(currentUser.userId); + } } diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index 5b22368f4f3..ae421b3373c 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -16,7 +16,7 @@ import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo } from '@shared/domain/domainobject'; import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { MatchCreatorScope, SchoolFeature } from '@shared/domain/types'; +import { Counted, IImportUserScope, MatchCreatorScope, SchoolFeature } from '@shared/domain/types'; import { ImportUserRepo, UserRepo } from '@shared/repo'; import { federalStateFactory, @@ -1209,5 +1209,75 @@ describe('[ImportUserModule]', () => { }); }); }); + + describe('clearAllAutoMatches', () => { + describe('when user id is given', () => { + const setup = () => { + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: schoolEntity.id, + inMaintenanceSince: new Date(2024, 1, 1), + inUserMigration: true, + }); + const currentUser: User = userFactory.buildWithId({ school: schoolEntity }); + + const importUsers: ImportUser[] = [ + importUserFactory.buildWithId({ + school: schoolEntity, + matchedBy: MatchCreator.AUTO, + user: userFactory.buildWithId({ + school: schoolEntity, + }), + }), + importUserFactory.buildWithId({ + school: schoolEntity, + matchedBy: MatchCreator.AUTO, + user: userFactory.buildWithId({ + school: schoolEntity, + }), + }), + ]; + const countedImportUsers: Counted = [importUsers, importUsers.length]; + + userRepo.findById.mockResolvedValueOnce(currentUser); + schoolService.getSchoolById.mockResolvedValueOnce(school); + importUserRepo.findImportUsers.mockResolvedValueOnce(countedImportUsers); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; + + return { + currentUser, + importUsers, + }; + }; + + it('should check users permissions', async () => { + const { currentUser } = setup(); + + await uc.clearAllAutoMatches(currentUser.id); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(currentUser, [ + Permission.IMPORT_USER_UPDATE, + ]); + }); + + it('should check if feature is enabled', async () => { + const { currentUser } = setup(); + + await uc.clearAllAutoMatches(currentUser.id); + + expect(userImportService.checkFeatureEnabled).toHaveBeenCalled(); + }); + + it('should call the relevant functions for removing auto matches and saving the result', async () => { + const { currentUser, importUsers } = setup(); + + await uc.clearAllAutoMatches(currentUser.id); + + const autoMatchFilter: IImportUserScope = { matches: [MatchCreatorScope.AUTO] }; + expect(importUserRepo.findImportUsers).toBeCalledWith(currentUser.school, autoMatchFilter); + expect(userImportService.saveImportUsers).toBeCalledWith(importUsers); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index 6a8547fc4a3..02f9997a651 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -122,11 +122,8 @@ export class UserImportUc { this.userImportService.checkFeatureEnabled(school); const importUser = await this.importUserRepo.findById(importUserId); - // check same school - if (school.id !== importUser.school.id) { - this.logger.warning(new SchoolIdDoesNotMatchWithUserSchoolId('', importUser.school.id, school.id)); - throw new ForbiddenException('not same school'); - } + + this.checkImportUserSameSchool(school, importUser); importUser.revokeMatch(); await this.importUserRepo.save(importUser); @@ -142,11 +139,7 @@ export class UserImportUc { const importUser = await this.importUserRepo.findById(importUserId); - // check same school - if (school.id !== importUser.school.id) { - this.logger.warning(new SchoolIdDoesNotMatchWithUserSchoolId('', importUser.school.id, school.id)); - throw new ForbiddenException('not same school'); - } + this.checkImportUserSameSchool(school, importUser); importUser.flagged = flagged === true; await this.importUserRepo.save(importUser); @@ -340,6 +333,25 @@ export class UserImportUc { await this.userImportService.resetMigrationForUsersSchool(currentUser, school); } + public async clearAllAutoMatches(currentUserId: EntityId): Promise { + const currentUser: User = await this.getCurrentUser(currentUserId, Permission.IMPORT_USER_UPDATE); + + const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); + this.userImportService.checkFeatureEnabled(school); + + const filters: IImportUserScope = { matches: [MatchCreatorScope.AUTO] }; + const [autoMatchedUsers]: Counted = await this.importUserRepo.findImportUsers( + currentUser.school, + filters + ); + + for (const autoMatchedUser of autoMatchedUsers) { + autoMatchedUser.revokeMatch(); + } + + await this.userImportService.saveImportUsers(autoMatchedUsers); + } + private async getCurrentUser(currentUserId: EntityId, permission: UserImportPermissions): Promise { const currentUser = await this.userRepo.findById(currentUserId, true); this.authorizationService.checkAllPermissions(currentUser, [permission]); @@ -435,4 +447,11 @@ export class UserImportUc { throw new MigrationAlreadyActivatedException(); } } + + private checkImportUserSameSchool(school: LegacySchoolDo, importUser: ImportUser) { + if (school.id !== importUser.school.id) { + this.logger.warning(new SchoolIdDoesNotMatchWithUserSchoolId('', importUser.school.id, school.id)); + throw new ForbiddenException('not same school'); + } + } } From c62f0342a41bfc9095ebf96e2df53dc105ab8503 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Tue, 27 Aug 2024 09:26:57 +0200 Subject: [PATCH 04/29] BC-7834 - add conditions for remove old tldraw resourecs (#5198) --- ansible/roles/schulcloud-server-core/tasks/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 25b0880786c..17687a9cceb 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -188,7 +188,7 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: tldraw-delete-files-cronjob.yml.j2 - when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + state: "{{ 'present' if WITH_TLDRAW else 'absent'}}" tags: - cronjob @@ -315,7 +315,7 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: tldraw-deployment.yml.j2 - when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + state: "{{ 'present' if WITH_TLDRAW else 'absent'}}" tags: - deployment @@ -343,7 +343,7 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: tldraw-svc-monitor.yml.j2 - when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + state: "{{ 'present' if WITH_TLDRAW else 'absent'}}" tags: - prometheus From f27a31e220bd5f9c677fc87e5e435abab5f823e8 Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Tue, 27 Aug 2024 10:53:04 +0200 Subject: [PATCH 05/29] BC-7904 - rename rooms endpoints (#5203) --- ... => course-rooms-copy-timeout.api.spec.ts} | 6 ++-- ...s.api.spec.ts => course-rooms.api.spec.ts} | 26 +++++++++-------- ...pec.ts => course-rooms.controller.spec.ts} | 18 ++++++------ ...ntroller.ts => course-rooms.controller.ts} | 25 +++++++++-------- ...s.ts => course-room-element.url.params.ts} | 2 +- ...rl.params.ts => course-room.url.params.ts} | 2 +- .../modules/learnroom/controller/dto/index.ts | 4 +-- apps/server/src/modules/learnroom/index.ts | 2 +- .../modules/learnroom/learnroom-api.module.ts | 16 +++++++---- .../src/modules/learnroom/learnroom.module.ts | 10 +++++-- .../service/course-copy.service.spec.ts | 10 +++---- .../learnroom/service/course-copy.service.ts | 4 +-- ...e.spec.ts => course-rooms.service.spec.ts} | 8 +++--- ...oms.service.ts => course-rooms.service.ts} | 5 +++- .../src/modules/learnroom/service/index.ts | 2 +- ... => course-rooms.authorisation.service.ts} | 2 +- ...oms.uc.spec.ts => course-rooms.uc.spec.ts} | 28 +++++++++---------- .../uc/{rooms.uc.ts => course-rooms.uc.ts} | 10 +++---- apps/server/src/modules/learnroom/uc/index.ts | 4 +-- .../uc/room-board-dto.factory.spec.ts | 8 +++--- .../learnroom/uc/room-board-dto.factory.ts | 8 +++--- .../uc/rooms.authorisation.service.spec.ts | 8 +++--- .../url-handler/board-url-handler.spec.ts | 4 +-- .../service/url-handler/board-url-handler.ts | 2 +- .../url-handler/course-url-handler.spec.ts | 4 +-- .../service/url-handler/course-url-handler.ts | 2 +- 26 files changed, 119 insertions(+), 101 deletions(-) rename apps/server/src/modules/learnroom/controller/api-test/{rooms-copy-timeout.api.spec.ts => course-rooms-copy-timeout.api.spec.ts} (95%) rename apps/server/src/modules/learnroom/controller/api-test/{rooms.api.spec.ts => course-rooms.api.spec.ts} (92%) rename apps/server/src/modules/learnroom/controller/{rooms.controller.spec.ts => course-rooms.controller.spec.ts} (94%) rename apps/server/src/modules/learnroom/controller/{rooms.controller.ts => course-rooms.controller.ts} (83%) rename apps/server/src/modules/learnroom/controller/dto/{room-element.url.params.ts => course-room-element.url.params.ts} (89%) rename apps/server/src/modules/learnroom/controller/dto/{room.url.params.ts => course-room.url.params.ts} (86%) rename apps/server/src/modules/learnroom/service/{rooms.service.spec.ts => course-rooms.service.spec.ts} (96%) rename apps/server/src/modules/learnroom/service/{rooms.service.ts => course-rooms.service.ts} (90%) rename apps/server/src/modules/learnroom/uc/{rooms.authorisation.service.ts => course-rooms.authorisation.service.ts} (96%) rename apps/server/src/modules/learnroom/uc/{rooms.uc.spec.ts => course-rooms.uc.spec.ts} (93%) rename apps/server/src/modules/learnroom/uc/{rooms.uc.ts => course-rooms.uc.ts} (90%) diff --git a/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course-rooms-copy-timeout.api.spec.ts similarity index 95% rename from apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts rename to apps/server/src/modules/learnroom/controller/api-test/course-rooms-copy-timeout.api.spec.ts index 98960bb4a43..89439e9ba78 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course-rooms-copy-timeout.api.spec.ts @@ -26,7 +26,7 @@ import { ServerTestModule } from '@modules/server'; // This needs to be in a separate test file because of the above configuration. // When we find a way to mock the config, it should be moved alongside the other API tests. -describe('Rooms copy (API)', () => { +describe('Course Rooms copy (API)', () => { let app: INestApplication; let em: EntityManager; let currentUser: ICurrentUser; @@ -74,7 +74,7 @@ describe('Rooms copy (API)', () => { currentUser = mapUserToCurrentUser(teacher); const response = await request(app.getHttpServer()) - .post(`/rooms/${course.id}/copy`) + .post(`/course-rooms/${course.id}/copy`) .set('Authorization', 'jwt') .send(); @@ -91,7 +91,7 @@ describe('Rooms copy (API)', () => { currentUser = mapUserToCurrentUser(teacher); const response = await request(app.getHttpServer()) - .post(`/rooms/lessons/${lesson.id}/copy`) + .post(`/course-rooms/lessons/${lesson.id}/copy`) .set('Authorization', 'jwt') .send(); diff --git a/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course-rooms.api.spec.ts similarity index 92% rename from apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts rename to apps/server/src/modules/learnroom/controller/api-test/course-rooms.api.spec.ts index 79508014c1f..d736023d32f 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course-rooms.api.spec.ts @@ -23,7 +23,7 @@ import { import { Request } from 'express'; import request from 'supertest'; -describe('Rooms Controller (API)', () => { +describe('Course Rooms Controller (API)', () => { let app: INestApplication; let em: EntityManager; let currentUser: ICurrentUser; @@ -72,7 +72,7 @@ describe('Rooms Controller (API)', () => { currentUser = mapUserToCurrentUser(student); - const response = await request(app.getHttpServer()).get(`/rooms/${course.id}/board`); + const response = await request(app.getHttpServer()).get(`/course-rooms/${course.id}/board`); expect(response.status).toEqual(200); const body = response.body as SingleColumnBoardResponse; @@ -95,7 +95,7 @@ describe('Rooms Controller (API)', () => { const params = { visibility: true }; const response = await request(app.getHttpServer()) - .patch(`/rooms/${course.id}/elements/${task.id}/visibility`) + .patch(`/course-rooms/${course.id}/elements/${task.id}/visibility`) .send(params); expect(response.status).toEqual(200); @@ -115,7 +115,9 @@ describe('Rooms Controller (API)', () => { currentUser = mapUserToCurrentUser(teacher); const params = { visibility: true }; - await request(app.getHttpServer()).patch(`/rooms/${course.id}/elements/${task.id}/visibility`).send(params); + await request(app.getHttpServer()) + .patch(`/course-rooms/${course.id}/elements/${task.id}/visibility`) + .send(params); const updatedTask = await em.findOneOrFail(Task, task.id); expect(updatedTask.isDraft()).toEqual(false); @@ -135,7 +137,9 @@ describe('Rooms Controller (API)', () => { currentUser = mapUserToCurrentUser(teacher); const params = { visibility: false }; - await request(app.getHttpServer()).patch(`/rooms/${course.id}/elements/${task.id}/visibility`).send(params); + await request(app.getHttpServer()) + .patch(`/course-rooms/${course.id}/elements/${task.id}/visibility`) + .send(params); const updatedTask = await em.findOneOrFail(Task, task.id); expect(updatedTask.isDraft()).toEqual(true); @@ -161,7 +165,7 @@ describe('Rooms Controller (API)', () => { }; const response = await request(app.getHttpServer()) - .patch(`/rooms/${course.id}/board/order`) + .patch(`/course-rooms/${course.id}/board/order`) .set('Authorization', 'jwt') .send(params); @@ -181,7 +185,7 @@ describe('Rooms Controller (API)', () => { currentUser = mapUserToCurrentUser(teacher); const response = await request(app.getHttpServer()) - .post(`/rooms/${course.id}/copy`) + .post(`/course-rooms/${course.id}/copy`) .set('Authorization', 'jwt') .send(); @@ -199,7 +203,7 @@ describe('Rooms Controller (API)', () => { currentUser = mapUserToCurrentUser(teacher); const response = await request(app.getHttpServer()) - .post(`/rooms/${course.id}/copy`) + .post(`/course-rooms/${course.id}/copy`) .set('Authorization', 'jwt') .send(); const body = response.body as CopyApiResponse; @@ -220,7 +224,7 @@ describe('Rooms Controller (API)', () => { currentUser = mapUserToCurrentUser(teacher); const response = await request(app.getHttpServer()) - .post(`/rooms/${course.id}/copy`) + .post(`/course-rooms/${course.id}/copy`) .set('Authorization', 'jwt') .send(); const body = response.body as CopyApiResponse; @@ -247,7 +251,7 @@ describe('Rooms Controller (API)', () => { filesStorageClientAdapterService.copyFilesOfParent.mockResolvedValue([]); const response = await request(app.getHttpServer()) - .post(`/rooms/${course.id}/copy`) + .post(`/course-rooms/${course.id}/copy`) .set('Authorization', 'jwt') .send(); @@ -268,7 +272,7 @@ describe('Rooms Controller (API)', () => { currentUser = mapUserToCurrentUser(teacher); const response = await request(app.getHttpServer()) - .post(`/rooms/lessons/${lesson.id}/copy`) + .post(`/course-rooms/lessons/${lesson.id}/copy`) .set('Authorization', 'jwt') .send({ courseId: course.id }); diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts b/apps/server/src/modules/learnroom/controller/course-rooms.controller.spec.ts similarity index 94% rename from apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts rename to apps/server/src/modules/learnroom/controller/course-rooms.controller.spec.ts index 87cddcd4540..5a7a7df2667 100644 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts +++ b/apps/server/src/modules/learnroom/controller/course-rooms.controller.spec.ts @@ -7,14 +7,14 @@ import { RoomBoardResponseMapper } from '../mapper/room-board-response.mapper'; import { RoomBoardDTO } from '../types'; import { CourseCopyUC } from '../uc/course-copy.uc'; import { LessonCopyUC } from '../uc/lesson-copy.uc'; -import { RoomsUc } from '../uc/rooms.uc'; +import { CourseRoomsUc } from '../uc/course-rooms.uc'; import { SingleColumnBoardResponse } from './dto'; -import { RoomsController } from './rooms.controller'; +import { CourseRoomsController } from './course-rooms.controller'; -describe('rooms controller', () => { - let controller: RoomsController; +describe('course-rooms controller', () => { + let controller: CourseRoomsController; let mapper: RoomBoardResponseMapper; - let uc: RoomsUc; + let uc: CourseRoomsUc; let courseCopyUc: CourseCopyUC; let lessonCopyUc: LessonCopyUC; @@ -22,9 +22,9 @@ describe('rooms controller', () => { const module: TestingModule = await Test.createTestingModule({ imports: [], providers: [ - RoomsController, + CourseRoomsController, { - provide: RoomsUc, + provide: CourseRoomsUc, useValue: { // eslint-disable-next-line @typescript-eslint/no-unused-vars getBoard(roomId: EntityId, userId: EntityId): Promise { @@ -63,9 +63,9 @@ describe('rooms controller', () => { }, ], }).compile(); - controller = module.get(RoomsController); + controller = module.get(CourseRoomsController); mapper = module.get(RoomBoardResponseMapper); - uc = module.get(RoomsUc); + uc = module.get(CourseRoomsUc); courseCopyUc = module.get(CourseCopyUC); lessonCopyUc = module.get(LessonCopyUC); }); diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.ts b/apps/server/src/modules/learnroom/controller/course-rooms.controller.ts similarity index 83% rename from apps/server/src/modules/learnroom/controller/rooms.controller.ts rename to apps/server/src/modules/learnroom/controller/course-rooms.controller.ts index b26e346edc1..4054a13f43e 100644 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course-rooms.controller.ts @@ -6,23 +6,26 @@ import { RequestTimeout } from '@shared/common'; import { RoomBoardResponseMapper } from '../mapper/room-board-response.mapper'; import { CourseCopyUC } from '../uc/course-copy.uc'; import { LessonCopyUC } from '../uc/lesson-copy.uc'; -import { RoomsUc } from '../uc/rooms.uc'; +import { CourseRoomsUc } from '../uc/course-rooms.uc'; import { LessonCopyApiParams, LessonUrlParams, PatchOrderParams, PatchVisibilityParams, - RoomElementUrlParams, - RoomUrlParams, + CourseRoomElementUrlParams, + CourseRoomUrlParams, SingleColumnBoardResponse, } from './dto'; -@ApiTags('Rooms') +/** + * @deprecated - the learnroom module is deprecated and will be removed in the future + */ +@ApiTags('Course-Rooms') @JwtAuthentication() -@Controller('rooms') -export class RoomsController { +@Controller('course-rooms') +export class CourseRoomsController { constructor( - private readonly roomsUc: RoomsUc, + private readonly roomsUc: CourseRoomsUc, private readonly mapper: RoomBoardResponseMapper, private readonly courseCopyUc: CourseCopyUC, private readonly lessonCopyUc: LessonCopyUC @@ -30,7 +33,7 @@ export class RoomsController { @Get(':roomId/board') async getRoomBoard( - @Param() urlParams: RoomUrlParams, + @Param() urlParams: CourseRoomUrlParams, @CurrentUser() currentUser: ICurrentUser ): Promise { const board = await this.roomsUc.getBoard(urlParams.roomId, currentUser.userId); @@ -40,7 +43,7 @@ export class RoomsController { @Patch(':roomId/elements/:elementId/visibility') async patchElementVisibility( - @Param() urlParams: RoomElementUrlParams, + @Param() urlParams: CourseRoomElementUrlParams, @Body() params: PatchVisibilityParams, @CurrentUser() currentUser: ICurrentUser ): Promise { @@ -54,7 +57,7 @@ export class RoomsController { @Patch(':roomId/board/order') async patchOrderingOfElements( - @Param() urlParams: RoomUrlParams, + @Param() urlParams: CourseRoomUrlParams, @Body() params: PatchOrderParams, @CurrentUser() currentUser: ICurrentUser ): Promise { @@ -65,7 +68,7 @@ export class RoomsController { @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') async copyCourse( @CurrentUser() currentUser: ICurrentUser, - @Param() urlParams: RoomUrlParams + @Param() urlParams: CourseRoomUrlParams ): Promise { const copyStatus = await this.courseCopyUc.copyCourse(currentUser.userId, urlParams.roomId); const dto = CopyMapper.mapToResponse(copyStatus); diff --git a/apps/server/src/modules/learnroom/controller/dto/room-element.url.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-room-element.url.params.ts similarity index 89% rename from apps/server/src/modules/learnroom/controller/dto/room-element.url.params.ts rename to apps/server/src/modules/learnroom/controller/dto/course-room-element.url.params.ts index 428e64072af..cc5a6e9eba8 100644 --- a/apps/server/src/modules/learnroom/controller/dto/room-element.url.params.ts +++ b/apps/server/src/modules/learnroom/controller/dto/course-room-element.url.params.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsMongoId } from 'class-validator'; -export class RoomElementUrlParams { +export class CourseRoomElementUrlParams { @IsMongoId() @ApiProperty({ description: 'The id of the room.', diff --git a/apps/server/src/modules/learnroom/controller/dto/room.url.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-room.url.params.ts similarity index 86% rename from apps/server/src/modules/learnroom/controller/dto/room.url.params.ts rename to apps/server/src/modules/learnroom/controller/dto/course-room.url.params.ts index 30b8f990598..c713c1d89b4 100644 --- a/apps/server/src/modules/learnroom/controller/dto/room.url.params.ts +++ b/apps/server/src/modules/learnroom/controller/dto/course-room.url.params.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsMongoId } from 'class-validator'; -export class RoomUrlParams { +export class CourseRoomUrlParams { @IsMongoId() @ApiProperty({ description: 'The id of the room.', diff --git a/apps/server/src/modules/learnroom/controller/dto/index.ts b/apps/server/src/modules/learnroom/controller/dto/index.ts index 3be2cba46f4..c459afc49d1 100644 --- a/apps/server/src/modules/learnroom/controller/dto/index.ts +++ b/apps/server/src/modules/learnroom/controller/dto/index.ts @@ -9,6 +9,6 @@ export * from './move-element.body.params'; export * from './patch-group.params'; export * from './patch-order.params'; export * from './patch-visibility.params'; -export * from './room-element.url.params'; -export * from './room.url.params'; +export * from './course-room-element.url.params'; +export * from './course-room.url.params'; export * from './single-column-board'; diff --git a/apps/server/src/modules/learnroom/index.ts b/apps/server/src/modules/learnroom/index.ts index 16d9f25c7c7..20d6d0f7508 100644 --- a/apps/server/src/modules/learnroom/index.ts +++ b/apps/server/src/modules/learnroom/index.ts @@ -7,5 +7,5 @@ export { CourseService, CourseDoService, DashboardService, - RoomsService, + CourseRoomsService, } from './service'; diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index f0ecaa1855b..e451643a3b6 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -7,7 +7,7 @@ import { Module } from '@nestjs/common'; import { CourseRepo, DashboardModelMapper, DashboardRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; import { CourseController } from './controller/course.controller'; import { DashboardController } from './controller/dashboard.controller'; -import { RoomsController } from './controller/rooms.controller'; +import { CourseRoomsController } from './controller/course-rooms.controller'; import { LearnroomModule } from './learnroom.module'; import { RoomBoardResponseMapper } from './mapper/room-board-response.mapper'; import { @@ -19,10 +19,14 @@ import { DashboardUc, LessonCopyUC, RoomBoardDTOFactory, - RoomsAuthorisationService, - RoomsUc, + CourseRoomsAuthorisationService, + CourseRoomsUc, } from './uc'; +/** + * @deprecated - the learnroom module is deprecated and will be removed in the future + * it will be replaced by the new rooms module + */ @Module({ imports: [ AuthorizationModule, @@ -32,16 +36,16 @@ import { AuthorizationReferenceModule, RoleModule, ], - controllers: [DashboardController, CourseController, RoomsController], + controllers: [DashboardController, CourseController, CourseRoomsController], providers: [ DashboardUc, CourseUc, - RoomsUc, + CourseRoomsUc, RoomBoardResponseMapper, RoomBoardDTOFactory, LessonCopyUC, CourseCopyUC, - RoomsAuthorisationService, + CourseRoomsAuthorisationService, CourseExportUc, CourseImportUc, CourseSyncUc, diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index 4ed7eae9fef..f405aa4d51d 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -31,10 +31,14 @@ import { CourseService, DashboardService, GroupDeletedHandlerService, - RoomsService, + CourseRoomsService, } from './service'; import { CommonCartridgeFileValidatorPipe } from './utils'; +/** + * @deprecated - the learnroom module is deprecated and will be removed in the future + * it will be replaced by the new rooms module + */ @Module({ imports: [ forwardRef(() => BoardModule), @@ -71,7 +75,7 @@ import { CommonCartridgeFileValidatorPipe } from './utils'; DashboardModelMapper, DashboardService, LegacyBoardRepo, - RoomsService, + CourseRoomsService, UserRepo, GroupDeletedHandlerService, ColumnBoardNodeRepo, @@ -80,7 +84,7 @@ import { CommonCartridgeFileValidatorPipe } from './utils'; CourseCopyService, CourseService, CourseDoService, - RoomsService, + CourseRoomsService, CommonCartridgeExportService, CommonCartridgeImportService, CourseGroupService, 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 8d5242de5e2..fa89376fba2 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 @@ -20,14 +20,14 @@ import { import { contextExternalToolFactory } from '../../tool/context-external-tool/testing'; import { BoardCopyService } from './board-copy.service'; import { CourseCopyService } from './course-copy.service'; -import { RoomsService } from './rooms.service'; +import { CourseRoomsService } from './course-rooms.service'; describe('course copy service', () => { let module: TestingModule; let service: CourseCopyService; let courseRepo: DeepMocked; let boardRepo: DeepMocked; - let roomsService: DeepMocked; + let roomsService: DeepMocked; let boardCopyService: DeepMocked; let lessonCopyService: DeepMocked; let copyHelperService: DeepMocked; @@ -57,8 +57,8 @@ describe('course copy service', () => { useValue: createMock(), }, { - provide: RoomsService, - useValue: createMock(), + provide: CourseRoomsService, + useValue: createMock(), }, { provide: BoardCopyService, @@ -90,7 +90,7 @@ describe('course copy service', () => { service = module.get(CourseCopyService); courseRepo = module.get(CourseRepo); boardRepo = module.get(LegacyBoardRepo); - roomsService = module.get(RoomsService); + roomsService = module.get(CourseRoomsService); boardCopyService = module.get(BoardCopyService); lessonCopyService = module.get(LessonCopyService); copyHelperService = module.get(CopyHelperService); 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 bf425d0c376..5edb0937621 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.ts @@ -9,7 +9,7 @@ import { Course, User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { CourseRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; import { BoardCopyService } from './board-copy.service'; -import { RoomsService } from './rooms.service'; +import { CourseRoomsService } from './course-rooms.service'; type CourseCopyParams = { originalCourse: Course; @@ -23,7 +23,7 @@ export class CourseCopyService { private readonly configService: ConfigService, private readonly courseRepo: CourseRepo, private readonly legacyBoardRepo: LegacyBoardRepo, - private readonly roomsService: RoomsService, + private readonly roomsService: CourseRoomsService, private readonly boardCopyService: BoardCopyService, private readonly copyHelperService: CopyHelperService, private readonly userRepo: UserRepo, diff --git a/apps/server/src/modules/learnroom/service/rooms.service.spec.ts b/apps/server/src/modules/learnroom/service/course-rooms.service.spec.ts similarity index 96% rename from apps/server/src/modules/learnroom/service/rooms.service.spec.ts rename to apps/server/src/modules/learnroom/service/course-rooms.service.spec.ts index 4b51edb946d..17a70d49615 100644 --- a/apps/server/src/modules/learnroom/service/rooms.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-rooms.service.spec.ts @@ -15,11 +15,11 @@ import { userFactory, } from '@shared/testing'; import { ColumnBoardNodeRepo } from '../repo'; -import { RoomsService } from './rooms.service'; +import { CourseRoomsService } from './course-rooms.service'; describe('rooms service', () => { let module: TestingModule; - let roomsService: RoomsService; + let roomsService: CourseRoomsService; let lessonService: DeepMocked; let taskService: DeepMocked; let legacyBoardRepo: DeepMocked; @@ -35,7 +35,7 @@ describe('rooms service', () => { await setupEntities(); module = await Test.createTestingModule({ providers: [ - RoomsService, + CourseRoomsService, { provide: LessonService, useValue: createMock(), @@ -54,7 +54,7 @@ describe('rooms service', () => { }, ], }).compile(); - roomsService = module.get(RoomsService); + roomsService = module.get(CourseRoomsService); lessonService = module.get(LessonService); taskService = module.get(TaskService); legacyBoardRepo = module.get(LegacyBoardRepo); diff --git a/apps/server/src/modules/learnroom/service/rooms.service.ts b/apps/server/src/modules/learnroom/service/course-rooms.service.ts similarity index 90% rename from apps/server/src/modules/learnroom/service/rooms.service.ts rename to apps/server/src/modules/learnroom/service/course-rooms.service.ts index e7714ae8192..90cd4368698 100644 --- a/apps/server/src/modules/learnroom/service/rooms.service.ts +++ b/apps/server/src/modules/learnroom/service/course-rooms.service.ts @@ -7,8 +7,11 @@ import { EntityId } from '@shared/domain/types'; import { LegacyBoardRepo } from '@shared/repo'; import { ColumnBoardNodeRepo } from '../repo'; +/** + * @deprecated - the learnroom module is deprecated and will be removed in the future + */ @Injectable() -export class RoomsService { +export class CourseRoomsService { constructor( private readonly taskService: TaskService, private readonly lessonService: LessonService, diff --git a/apps/server/src/modules/learnroom/service/index.ts b/apps/server/src/modules/learnroom/service/index.ts index 49b39023c4c..61cc6b2d068 100644 --- a/apps/server/src/modules/learnroom/service/index.ts +++ b/apps/server/src/modules/learnroom/service/index.ts @@ -6,5 +6,5 @@ export * from './course.service'; export { CourseDoService } from './course-do.service'; export * from './coursegroup.service'; export * from './dashboard.service'; -export * from './rooms.service'; +export * from './course-rooms.service'; export { GroupDeletedHandlerService } from './group-deleted-handler.service'; diff --git a/apps/server/src/modules/learnroom/uc/rooms.authorisation.service.ts b/apps/server/src/modules/learnroom/uc/course-rooms.authorisation.service.ts similarity index 96% rename from apps/server/src/modules/learnroom/uc/rooms.authorisation.service.ts rename to apps/server/src/modules/learnroom/uc/course-rooms.authorisation.service.ts index 8be1e72c1d8..7e6d875074f 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.authorisation.service.ts +++ b/apps/server/src/modules/learnroom/uc/course-rooms.authorisation.service.ts @@ -7,7 +7,7 @@ export enum TaskParentPermission { } @Injectable() -export class RoomsAuthorisationService { +export class CourseRoomsAuthorisationService { hasCourseWritePermission(user: User, course: Course): boolean { const hasPermission = course.substitutionTeachers.contains(user) || course.teachers.contains(user); diff --git a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-rooms.uc.spec.ts similarity index 93% rename from apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts rename to apps/server/src/modules/learnroom/uc/course-rooms.uc.spec.ts index 0d3fee07c6b..272144a9497 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course-rooms.uc.spec.ts @@ -3,21 +3,21 @@ import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { CourseRepo, LegacyBoardRepo, TaskRepo, UserRepo } from '@shared/repo'; import { boardFactory, courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; -import { RoomsService } from '../service/rooms.service'; +import { CourseRoomsService } from '../service/course-rooms.service'; import { RoomBoardDTO } from '../types'; import { RoomBoardDTOFactory } from './room-board-dto.factory'; -import { RoomsAuthorisationService } from './rooms.authorisation.service'; -import { RoomsUc } from './rooms.uc'; +import { CourseRoomsAuthorisationService } from './course-rooms.authorisation.service'; +import { CourseRoomsUc } from './course-rooms.uc'; describe('rooms usecase', () => { - let uc: RoomsUc; + let uc: CourseRoomsUc; let courseRepo: DeepMocked; let taskRepo: DeepMocked; let userRepo: DeepMocked; let legacyBoardRepo: DeepMocked; let factory: DeepMocked; - let authorisation: DeepMocked; - let roomsService: DeepMocked; + let authorisation: DeepMocked; + let roomsService: DeepMocked; let module: TestingModule; afterAll(async () => { @@ -28,7 +28,7 @@ describe('rooms usecase', () => { module = await Test.createTestingModule({ imports: [], providers: [ - RoomsUc, + CourseRoomsUc, { provide: CourseRepo, useValue: createMock(), @@ -50,24 +50,24 @@ describe('rooms usecase', () => { useValue: createMock(), }, { - provide: RoomsAuthorisationService, - useValue: createMock(), + provide: CourseRoomsAuthorisationService, + useValue: createMock(), }, { - provide: RoomsService, - useValue: createMock(), + provide: CourseRoomsService, + useValue: createMock(), }, ], }).compile(); - uc = module.get(RoomsUc); + uc = module.get(CourseRoomsUc); courseRepo = module.get(CourseRepo); taskRepo = module.get(TaskRepo); userRepo = module.get(UserRepo); legacyBoardRepo = module.get(LegacyBoardRepo); factory = module.get(RoomBoardDTOFactory); - authorisation = module.get(RoomsAuthorisationService); - roomsService = module.get(RoomsService); + authorisation = module.get(CourseRoomsAuthorisationService); + roomsService = module.get(CourseRoomsService); await setupEntities(); }); diff --git a/apps/server/src/modules/learnroom/uc/rooms.uc.ts b/apps/server/src/modules/learnroom/uc/course-rooms.uc.ts similarity index 90% rename from apps/server/src/modules/learnroom/uc/rooms.uc.ts rename to apps/server/src/modules/learnroom/uc/course-rooms.uc.ts index a7422bf835a..22df4d015f5 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-rooms.uc.ts @@ -1,20 +1,20 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { CourseRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; -import { RoomsService } from '../service/rooms.service'; +import { CourseRoomsService } from '../service/course-rooms.service'; import { RoomBoardDTO } from '../types'; import { RoomBoardDTOFactory } from './room-board-dto.factory'; -import { RoomsAuthorisationService } from './rooms.authorisation.service'; +import { CourseRoomsAuthorisationService } from './course-rooms.authorisation.service'; @Injectable() -export class RoomsUc { +export class CourseRoomsUc { constructor( private readonly courseRepo: CourseRepo, private readonly userRepo: UserRepo, private readonly legacyBoardRepo: LegacyBoardRepo, private readonly factory: RoomBoardDTOFactory, - private readonly authorisationService: RoomsAuthorisationService, - private readonly roomsService: RoomsService + private readonly authorisationService: CourseRoomsAuthorisationService, + private readonly roomsService: CourseRoomsService ) {} async getBoard(roomId: EntityId, userId: EntityId): Promise { diff --git a/apps/server/src/modules/learnroom/uc/index.ts b/apps/server/src/modules/learnroom/uc/index.ts index 2ef52519ad0..c50d28e74d7 100644 --- a/apps/server/src/modules/learnroom/uc/index.ts +++ b/apps/server/src/modules/learnroom/uc/index.ts @@ -6,5 +6,5 @@ export * from './course.uc'; export * from './dashboard.uc'; export * from './lesson-copy.uc'; export * from './room-board-dto.factory'; -export * from './rooms.authorisation.service'; -export * from './rooms.uc'; +export * from './course-rooms.authorisation.service'; +export * from './course-rooms.uc'; diff --git a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts index b0a98727efa..cb3bcbe26c5 100644 --- a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts +++ b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts @@ -16,12 +16,12 @@ import { } from '@shared/testing'; import { LessonMetaData } from '../types'; import { RoomBoardDTOFactory } from './room-board-dto.factory'; -import { RoomsAuthorisationService } from './rooms.authorisation.service'; +import { CourseRoomsAuthorisationService } from './course-rooms.authorisation.service'; describe(RoomBoardDTOFactory.name, () => { let module: TestingModule; let mapper: RoomBoardDTOFactory; - let roomsAuthorisationService: RoomsAuthorisationService; + let roomsAuthorisationService: CourseRoomsAuthorisationService; let authorisationService: DeepMocked; let configBefore: IConfig; @@ -37,7 +37,7 @@ describe(RoomBoardDTOFactory.name, () => { providers: [ RoomBoardDTOFactory, { - provide: RoomsAuthorisationService, + provide: CourseRoomsAuthorisationService, useValue: { // eslint-disable-next-line @typescript-eslint/no-unused-vars hasTaskReadPermission(user: User, task: Task): boolean { @@ -53,7 +53,7 @@ describe(RoomBoardDTOFactory.name, () => { ], }).compile(); - roomsAuthorisationService = module.get(RoomsAuthorisationService); + roomsAuthorisationService = module.get(CourseRoomsAuthorisationService); authorisationService = module.get(AuthorizationService); mapper = module.get(RoomBoardDTOFactory); await setupEntities(); diff --git a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts index 5ea348cb088..c286033fe28 100644 --- a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts +++ b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts @@ -22,7 +22,7 @@ import { RoomBoardElementDTO, RoomBoardElementTypes, } from '../types/room-board.types'; -import { RoomsAuthorisationService } from './rooms.authorisation.service'; +import { CourseRoomsAuthorisationService } from './course-rooms.authorisation.service'; class DtoCreator { room: Course; @@ -33,7 +33,7 @@ class DtoCreator { authorisationService: AuthorizationService; - roomsAuthorisationService: RoomsAuthorisationService; + roomsAuthorisationService: CourseRoomsAuthorisationService; constructor({ room, @@ -46,7 +46,7 @@ class DtoCreator { board: LegacyBoard; user: User; authorisationService: AuthorizationService; - roomsAuthorisationService: RoomsAuthorisationService; + roomsAuthorisationService: CourseRoomsAuthorisationService; }) { this.room = room; this.board = board; @@ -188,7 +188,7 @@ class DtoCreator { export class RoomBoardDTOFactory { constructor( private readonly authorisationService: AuthorizationService, - private readonly roomsAuthorisationService: RoomsAuthorisationService + private readonly roomsAuthorisationService: CourseRoomsAuthorisationService ) {} createDTO({ room, board, user }: { room: Course; board: LegacyBoard; user: User }): RoomBoardDTO { diff --git a/apps/server/src/modules/learnroom/uc/rooms.authorisation.service.spec.ts b/apps/server/src/modules/learnroom/uc/rooms.authorisation.service.spec.ts index b4c3145cb36..763a54d13cf 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.authorisation.service.spec.ts +++ b/apps/server/src/modules/learnroom/uc/rooms.authorisation.service.spec.ts @@ -1,11 +1,11 @@ import { NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; -import { RoomsAuthorisationService } from './rooms.authorisation.service'; +import { CourseRoomsAuthorisationService } from './course-rooms.authorisation.service'; describe('rooms authorisation service', () => { let module: TestingModule; - let service: RoomsAuthorisationService; + let service: CourseRoomsAuthorisationService; afterAll(async () => { await module.close(); @@ -13,10 +13,10 @@ describe('rooms authorisation service', () => { beforeAll(async () => { module = await Test.createTestingModule({ - providers: [RoomsAuthorisationService], + providers: [CourseRoomsAuthorisationService], }).compile(); - service = module.get(RoomsAuthorisationService); + service = module.get(CourseRoomsAuthorisationService); await setupEntities(); }); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts index 57243e7dfe7..bcee746a106 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.spec.ts @@ -35,7 +35,7 @@ describe(BoardUrlHandler.name, () => { describe('when url fits', () => { it('should call courseService with the correct id', async () => { const id = 'af322312feae'; - const url = `https://localhost/rooms/${id}/board`; + const url = `https://localhost/course-rooms/${id}/board`; await boardUrlHandler.getMetaData(url); @@ -44,7 +44,7 @@ describe(BoardUrlHandler.name, () => { it('should take the title from the board name', async () => { const id = 'af322312feae'; - const url = `https://localhost/rooms/${id}/board`; + const url = `https://localhost/course-rooms/${id}/board`; const boardName = 'My Board'; columnBoardService.findById.mockResolvedValue({ title: boardName, diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts index 447536d6850..738064689f2 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/board-url-handler.ts @@ -7,7 +7,7 @@ import { AbstractUrlHandler } from './abstract-url-handler'; @Injectable() export class BoardUrlHandler extends AbstractUrlHandler implements UrlHandler { - patterns: RegExp[] = [/\/rooms\/(.*?)\/board\/?$/i]; + patterns: RegExp[] = [/\/course-rooms\/(.*?)\/board\/?$/i]; constructor(private readonly columnBoardService: ColumnBoardService, private readonly courseService: CourseService) { super(); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts index bce18e11e64..5d58f822191 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.spec.ts @@ -30,7 +30,7 @@ describe(CourseUrlHandler.name, () => { describe('when url fits', () => { it('should call courseService with the correct id', async () => { const id = 'af322312feae'; - const url = `https://localhost/rooms/${id}`; + const url = `https://localhost/course-rooms/${id}`; await courseUrlHandler.getMetaData(url); @@ -39,7 +39,7 @@ describe(CourseUrlHandler.name, () => { it('should take the title from the course name', async () => { const id = 'af322312feae'; - const url = `https://localhost/rooms/${id}`; + const url = `https://localhost/course-rooms/${id}`; const courseName = 'My Course'; courseService.findById.mockResolvedValue({ name: courseName } as Course); diff --git a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts index def041886f1..3dd6373ada1 100644 --- a/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts +++ b/apps/server/src/modules/meta-tag-extractor/service/url-handler/course-url-handler.ts @@ -6,7 +6,7 @@ import { AbstractUrlHandler } from './abstract-url-handler'; @Injectable() export class CourseUrlHandler extends AbstractUrlHandler implements UrlHandler { - patterns: RegExp[] = [/\/rooms\/([0-9a-z]+)$/i]; + patterns: RegExp[] = [/\/course-rooms\/([0-9a-z]+)$/i]; constructor(private readonly courseService: CourseService) { super(); From 766b068e2addf0a1aef84044245a00e60bd97c77 Mon Sep 17 00:00:00 2001 From: Phillip Date: Tue, 27 Aug 2024 13:42:11 +0200 Subject: [PATCH 06/29] BC-6985/BC-7901 migration aids (#5209) --- scripts/copy-legacy-tool-to-ctl.js | 24 +++++++++++++++--------- scripts/migrate-legacy-bbb.js | 2 -- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/scripts/copy-legacy-tool-to-ctl.js b/scripts/copy-legacy-tool-to-ctl.js index 5f41bc3b6f9..4f2455bd955 100644 --- a/scripts/copy-legacy-tool-to-ctl.js +++ b/scripts/copy-legacy-tool-to-ctl.js @@ -16,8 +16,6 @@ const close = async () => mongoose.connection.close(); const connect = async () => { const mongooseOptions = { useNewUrlParser: true, - useFindAndModify: false, - useCreateIndex: true, useUnifiedTopology: true, }; @@ -120,6 +118,7 @@ const ExternalTool = mongoose.model( enum: ['string', 'number', 'boolean', 'auto_contextid', 'auto_contextname', 'auto_schoolid'], }, isOptional: Boolean, + isProtected: Boolean, }, }, ], @@ -127,12 +126,14 @@ const ExternalTool = mongoose.model( isHidden: Boolean, openNewTab: Boolean, version: Number, + isDeactivated: Boolean, + restrictToContexts: [], }, { timestamps: true, } ), - 'external_tools' + 'external-tools' ); const SchoolExternalTool = mongoose.model( @@ -143,12 +144,13 @@ const SchoolExternalTool = mongoose.model( school: { type: Schema.Types.ObjectId }, schoolParameters: [customParameterEntrySchema], toolVersion: Number, + isDeactivated: Boolean, }, { timestamps: true, } ), - 'school_external_tools' + 'school-external-tools' ); const ContextExternalTool = mongoose.model( @@ -166,7 +168,7 @@ const ContextExternalTool = mongoose.model( timestamps: true, } ), - 'context_external_tools' + 'context-external-tools' ); const Course = mongoose.model( @@ -259,6 +261,8 @@ function mapToExternalTool(ltiToolTemplate) { isHidden: ltiToolTemplate.isHidden, openNewTab: ltiToolTemplate.openNewTab, version: 1, + restrictToContexts: [], + isDeactivated: false, ...toolConfigMapper(ltiToolTemplate), }; } @@ -269,16 +273,18 @@ function mapToSchoolExternalTool(externalTool, course) { school: course.schoolId, schoolParameters: [], toolVersion: externalTool.version, + isDeactivated: false, }; } -function mapToContextExternalTool(schoolExternalTool, course) { +function mapToContextExternalTool(schoolExternalTool, course, externalToolName) { return { schoolTool: schoolExternalTool._id, contextId: course._id, contextType: 'course', parameters: [], toolVersion: schoolExternalTool.toolVersion, + displayName: externalToolName, }; } @@ -350,7 +356,7 @@ async function createSchoolExternalTool(externalTool, course) { return schoolExternalTool; } -async function createContextExternalTool(schoolExternalTool, course) { +async function createContextExternalTool(schoolExternalTool, course, externalToolName) { const contextExternalTools = await ContextExternalTool.find({ schoolTool: schoolExternalTool._id, contextId: course._id, @@ -361,7 +367,7 @@ async function createContextExternalTool(schoolExternalTool, course) { // CHECK IF CONTEXTEXTERNALTOOL EXISTS if ((contextExternalTools || []).length === 0) { - const contextExternalTool = mapToContextExternalTool(schoolExternalTool, course); + const contextExternalTool = mapToContextExternalTool(schoolExternalTool, course, externalToolName); await ContextExternalTool.insertMany(contextExternalTool); } } @@ -428,7 +434,7 @@ const up = async () => { const schoolExternalTool = await createSchoolExternalTool(externalTool, course); // CREATE CONTEXTEXTERNALTOOL - await createContextExternalTool(schoolExternalTool, course); + await createContextExternalTool(schoolExternalTool, course, externalTool.name); } await close(); diff --git a/scripts/migrate-legacy-bbb.js b/scripts/migrate-legacy-bbb.js index 3bd31210a1f..32d5a0c888e 100644 --- a/scripts/migrate-legacy-bbb.js +++ b/scripts/migrate-legacy-bbb.js @@ -16,8 +16,6 @@ const close = async () => mongoose.connection.close(); const connect = async () => { const mongooseOptions = { useNewUrlParser: true, - useFindAndModify: false, - useCreateIndex: true, useUnifiedTopology: true, }; From e7fd8358629ee6435959ce287a464aba40d2ae5d Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Wed, 28 Aug 2024 10:37:16 +0200 Subject: [PATCH 07/29] BC-7830 - additional changes for board load tests (#5207) * improve establishing of connections * improve error handling if token and/or target-url are not defined or not working * add retries if establishing of connections fails * change fileextension of loadtest report json files and add to .gitignore --- .gitignore | 1 + .../loadtest/board-collaboration.load.spec.ts | 5 ++ .../board/loadtest/board-load-test.spec.ts | 57 +++++++++++---- .../modules/board/loadtest/board-load-test.ts | 21 +++--- .../board/loadtest/connection.load.spec.ts | 2 +- .../loadtest/helper/create-board.spec.ts | 72 +++++++++++++++---- .../board/loadtest/helper/create-board.ts | 32 ++++++++- .../modules/board/loadtest/loadtest-client.ts | 2 + .../board/loadtest/loadtest-runner.spec.ts | 2 +- .../modules/board/loadtest/loadtest-runner.ts | 24 +++++-- .../socket-connection-manager.spec.ts | 6 +- .../loadtest/socket-connection-manager.ts | 8 +-- .../board/loadtest/socket-connection.ts | 6 +- 13 files changed, 186 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index a045507c02b..c5a9ad2183f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* +*.loadtest.json # Runtime data pids diff --git a/apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts b/apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts index cc82db40d3e..62a3dac802c 100644 --- a/apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts +++ b/apps/server/src/modules/board/loadtest/board-collaboration.load.spec.ts @@ -19,6 +19,9 @@ describe('Board Collaboration Load Test', () => { }; const socketConnectionManager = new SocketConnectionManager(socketConfiguration); + let connectionIssues = 0; + // eslint-disable-next-line no-plusplus + socketConnectionManager.setOnErrorHandler(() => connectionIssues++); const createBoardLoadTest: CreateBoardLoadTest = (...args) => new BoardLoadTest(...args); const runner = new LoadtestRunner(socketConnectionManager, createBoardLoadTest); @@ -30,6 +33,8 @@ describe('Board Collaboration Load Test', () => { { classDefinition: collaborativeClass, amount: collabClassesAmount }, ], }); + + await socketConnectionManager.destroySocketConnections(); } else { expect('this should only be ran manually').toBeTruthy(); } diff --git a/apps/server/src/modules/board/loadtest/board-load-test.spec.ts b/apps/server/src/modules/board/loadtest/board-load-test.spec.ts index e4ced34c294..656afa7a8b0 100644 --- a/apps/server/src/modules/board/loadtest/board-load-test.spec.ts +++ b/apps/server/src/modules/board/loadtest/board-load-test.spec.ts @@ -1,3 +1,4 @@ +import { createMock } from '@golevelup/ts-jest'; import { BoardLoadTest } from './board-load-test'; import { fastEditor } from './helper/class-definitions'; import { SocketConnectionManager } from './socket-connection-manager'; @@ -11,18 +12,21 @@ jest.mock('./helper/sleep', () => { jest.mock('./loadtest-client', () => { return { - createColumn: jest.fn().mockResolvedValue({ id: 'some-id' }), - createCard: jest.fn().mockResolvedValue({ id: 'some-id' }), - createElement: jest.fn().mockResolvedValue({ id: 'some-id' }), - createAndUpdateLinkElement: jest.fn().mockResolvedValue({ id: 'some-id' }), - createAndUpdateTextElement: jest.fn().mockResolvedValue({ id: 'some-id' }), - updateCardTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), - updateColumnTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + createLoadtestClient: () => { + return { + createColumn: jest.fn().mockResolvedValue({ id: 'some-id' }), + createCard: jest.fn().mockResolvedValue({ id: 'some-id' }), + createElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateLinkElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateTextElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + fetchBoard: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateCardTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateColumnTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + }; + }, }; }); -jest.mock('./socket-connection-manager'); - const testClass: ClassDefinition = { name: 'viewersClass', users: [{ ...fastEditor, amount: 5 }], @@ -41,7 +45,10 @@ afterEach(() => { describe('BoardLoadTest', () => { const setup = () => { const socketConfiguration = { baseUrl: '', path: '', token: '' }; - const socketConnectionManager = new SocketConnectionManager(socketConfiguration); + const socketConnectionManager = createMock(); + socketConnectionManager.createConnections = jest + .fn() + .mockResolvedValue([new SocketConnection(socketConfiguration, console.log)]); const socketConnection = new SocketConnection(socketConfiguration, console.log); const boarLoadTest = new BoardLoadTest(socketConnectionManager, console.log); @@ -53,7 +60,11 @@ describe('BoardLoadTest', () => { it('should do nothing', async () => { const { boarLoadTest } = setup(); const boardId = 'board-id'; - const configuration = { name: 'my-configuration', users: [], simulateUsersTimeMs: 2000 }; + const configuration = { + name: 'my-configuration', + users: [{ name: 'tempuserprofile', isActive: true, sleepMs: 100, amount: 1 }], + simulateUsersTimeMs: 2000, + }; const response = await boarLoadTest.runBoardTest(boardId, configuration); @@ -68,11 +79,33 @@ describe('BoardLoadTest', () => { await boarLoadTest.runBoardTest(boardId, testClass); - expect(socketConnectionManager.createConnection).toHaveBeenCalledTimes(5); + expect(socketConnectionManager.createConnections).toHaveBeenCalledTimes(1); }); }); }); + describe('simulateUsersActions', () => { + it('should simulate actions for all users', async () => { + const { boarLoadTest } = setup(); + const loadtestClient = { + createColumn: jest.fn().mockResolvedValue({ id: 'some-id' }), + createCard: jest.fn().mockResolvedValue({ id: 'some-id' }), + createElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateLinkElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + createAndUpdateTextElement: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateCardTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + updateColumnTitle: jest.fn().mockResolvedValue({ id: 'some-id' }), + fetchBoard: jest.fn().mockResolvedValue({ id: 'some-id' }), + } as unknown as LoadtestClient; + const userProfile = fastEditor; + + await boarLoadTest.simulateUsersActions([loadtestClient], [userProfile]); + + expect(loadtestClient.createColumn).toHaveBeenCalled(); + expect(loadtestClient.createCard).toHaveBeenCalled(); + }); + }); + describe('simulateUserActions', () => { it('should create columns and cards', async () => { const { boarLoadTest } = setup(); diff --git a/apps/server/src/modules/board/loadtest/board-load-test.ts b/apps/server/src/modules/board/loadtest/board-load-test.ts index 94c4287d118..5870e5eba75 100644 --- a/apps/server/src/modules/board/loadtest/board-load-test.ts +++ b/apps/server/src/modules/board/loadtest/board-load-test.ts @@ -2,11 +2,12 @@ import { duplicateUserProfiles } from './helper/class-definitions'; import { getRandomCardTitle, getRandomLink, getRandomRichContentBody } from './helper/randomData'; import { sleep } from './helper/sleep'; -import { LoadtestClient } from './loadtest-client'; +import { createLoadtestClient, LoadtestClient } from './loadtest-client'; +import { SocketConnection } from './socket-connection'; import { SocketConnectionManager } from './socket-connection-manager'; import { Callback, ClassDefinition, UserProfile } from './types'; -const SIMULATE_USER_TIME_MS = 60000; +const SIMULATE_USER_TIME_MS = 120000; export class BoardLoadTest { private columns: { id: string; cards: { id: string }[] }[] = []; @@ -24,19 +25,19 @@ export class BoardLoadTest { } async initializeLoadtestClients(amount: number, boardId: string): Promise { - const promises = Array(amount) - .fill(1) - .map(() => this.initializeLoadtestClient(boardId)); + const connections = await this.socketConnectionManager.createConnections(amount); + const promises = connections.map((socketConnection: SocketConnection) => + this.initializeLoadtestClient(socketConnection, boardId) + ); const results = await Promise.all(promises); return results; } - async initializeLoadtestClient(boardId: string): Promise { - const socketConnection = await this.socketConnectionManager.createConnection(); - const loadtestClient = new LoadtestClient(socketConnection, boardId); - + async initializeLoadtestClient(socketConnection: SocketConnection, boardId: string): Promise { /* istanbul ignore next */ - await sleep(Math.ceil(Math.random() * 3000)); + const loadtestClient = createLoadtestClient(socketConnection, boardId); + + await sleep(Math.ceil(Math.random() * 20000)); /* istanbul ignore next */ await loadtestClient.fetchBoard(); /* istanbul ignore next */ diff --git a/apps/server/src/modules/board/loadtest/connection.load.spec.ts b/apps/server/src/modules/board/loadtest/connection.load.spec.ts index 9118c8370d7..70f6714eb4a 100644 --- a/apps/server/src/modules/board/loadtest/connection.load.spec.ts +++ b/apps/server/src/modules/board/loadtest/connection.load.spec.ts @@ -24,7 +24,7 @@ describe('Board Collaboration - Connection Load Test', () => { const sockets = await manager.createConnections(CONNECTION_AMOUNT); await sleep(3000); expect(sockets).toHaveLength(CONNECTION_AMOUNT); - await manager.destroySocketConnections(sockets); + await manager.destroySocketConnections(); } else { expect('this should only be ran manually').toBeTruthy(); } diff --git a/apps/server/src/modules/board/loadtest/helper/create-board.spec.ts b/apps/server/src/modules/board/loadtest/helper/create-board.spec.ts index e6839a7f146..1a5876b2eb1 100644 --- a/apps/server/src/modules/board/loadtest/helper/create-board.spec.ts +++ b/apps/server/src/modules/board/loadtest/helper/create-board.spec.ts @@ -1,18 +1,19 @@ -import { createBoard } from './create-board'; +import { createBoard, createBoardsResilient } from './create-board'; describe('createBoards', () => { + const mockFetch = jest.fn(); + + beforeEach(() => { + global.fetch = mockFetch; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + describe('createBoard', () => { const apiBaseUrl = 'http://example.com'; const courseId = 'course123'; - const mockFetch = jest.fn(); - - beforeEach(() => { - global.fetch = mockFetch; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); it('should create a board and return its id', async () => { const boardId = 'board123'; @@ -48,9 +49,54 @@ describe('createBoards', () => { }, }); - await expect(createBoard(apiBaseUrl, token, courseId)).rejects.toThrow( - 'Failed to create board: 400 - check token and target in env-variables' - ); + await expect(createBoard(apiBaseUrl, token, courseId)).rejects.toThrow(); + }); + }); + + describe('createBoardsResilient', () => { + it('should create the correct amount of boards', async () => { + mockFetch.mockResolvedValue({ + status: 201, + json: () => { + return { id: `board${Math.ceil(Math.random() * 1000)}` }; + }, + }); + + await createBoardsResilient('http://example.com', 'test-token', 'course123', 5); + + expect(mockFetch).toHaveBeenCalledTimes(5); + }); + + it('should retry on error and in the end return the possible amount of boardIds', async () => { + mockFetch.mockResolvedValueOnce({ + status: 201, + json: () => { + return { id: `board${Math.ceil(Math.random() * 1000)}` }; + }, + }); + + mockFetch.mockResolvedValue({ + status: 404, + json: () => { + return {}; + }, + }); + + const boardIds = await createBoardsResilient('http://example.com', 'test-token', 'course123', 5); + + expect(mockFetch).toHaveBeenCalledTimes(11); + expect(boardIds).toHaveLength(1); + }); + + it('should throw an error if the token is unauthorized', async () => { + mockFetch.mockResolvedValue({ + status: 401, + json: () => { + return {}; + }, + }); + + await expect(createBoardsResilient('http://example.com', 'test-token', 'course123', 5)).rejects.toThrow(); }); }); }); diff --git a/apps/server/src/modules/board/loadtest/helper/create-board.ts b/apps/server/src/modules/board/loadtest/helper/create-board.ts index 820ee038140..16a08b7735d 100644 --- a/apps/server/src/modules/board/loadtest/helper/create-board.ts +++ b/apps/server/src/modules/board/loadtest/helper/create-board.ts @@ -1,4 +1,11 @@ import { BoardExternalReferenceType, BoardLayout } from '../../domain'; +import { sleep } from './sleep'; + +export class HttpError extends Error { + constructor(public readonly status: number, public readonly statusText: string) { + super(`HTTP Error ${status}: ${statusText}`); + } +} export const createBoard = async (apiBaseUrl: string, token: string, courseId: string) => { const boardTitle = `${new Date().toISOString().substring(0, 10)} ${new Date().toLocaleTimeString( @@ -22,8 +29,31 @@ export const createBoard = async (apiBaseUrl: string, token: string, courseId: s }); if (response.status !== 201) { - throw new Error(`Failed to create board: ${response.status} - check token and target in env-variables`); + throw new HttpError(response.status, response.statusText); } const body = (await response.json()) as unknown as { id: string }; return body.id; }; + +export const createBoardsResilient = async (apiBaseUrl: string, token: string, courseId: string, amount: number) => { + const boardIds: string[] = []; + let retries = 0; + while (boardIds.length < amount && retries < 10) { + try { + // eslint-disable-next-line no-await-in-loop + const boardId = await createBoard(apiBaseUrl, token, courseId); + boardIds.push(boardId); + } catch (err) { + if ('status' in err) { + const { status } = err as unknown as HttpError; + if (status === 401) { + throw new Error('Unauthorized REST-Api access - check token, url and courseId in environment variables.'); + } + } + retries += 1; + // eslint-disable-next-line no-await-in-loop + await sleep(100); + } + } + return boardIds; +}; diff --git a/apps/server/src/modules/board/loadtest/loadtest-client.ts b/apps/server/src/modules/board/loadtest/loadtest-client.ts index 650619155b0..74345224757 100644 --- a/apps/server/src/modules/board/loadtest/loadtest-client.ts +++ b/apps/server/src/modules/board/loadtest/loadtest-client.ts @@ -136,3 +136,5 @@ export class LoadtestClient { return result as UpdateContentElementMessageParams; } } + +export const createLoadtestClient = (socket: SocketConnection, boardId: string) => new LoadtestClient(socket, boardId); diff --git a/apps/server/src/modules/board/loadtest/loadtest-runner.spec.ts b/apps/server/src/modules/board/loadtest/loadtest-runner.spec.ts index d0be412ccba..f171cfa7558 100644 --- a/apps/server/src/modules/board/loadtest/loadtest-runner.spec.ts +++ b/apps/server/src/modules/board/loadtest/loadtest-runner.spec.ts @@ -7,7 +7,7 @@ jest.mock('./socket-connection-manager'); jest.mock('./helper/create-board', () => { return { - createBoard: jest.fn().mockResolvedValue({ id: 'board123' }), + createBoardsResilient: jest.fn().mockResolvedValue([{ id: 'board123' }]), }; }); diff --git a/apps/server/src/modules/board/loadtest/loadtest-runner.ts b/apps/server/src/modules/board/loadtest/loadtest-runner.ts index db88ee97656..2f56853aa14 100644 --- a/apps/server/src/modules/board/loadtest/loadtest-runner.ts +++ b/apps/server/src/modules/board/loadtest/loadtest-runner.ts @@ -2,7 +2,7 @@ import { writeFileSync } from 'fs'; import { Injectable } from '@nestjs/common'; import { createSeveralClasses } from './helper/class-definitions'; -import { createBoard } from './helper/create-board'; +import { createBoardsResilient } from './helper/create-board'; import { formatDate } from './helper/format-date'; import { getUrlConfiguration } from './helper/get-url-configuration'; import { useResponseTimes } from './helper/responseTimes.composable'; @@ -56,7 +56,7 @@ export class LoadtestRunner { } startRegularStats = () => { - this.intervalHandle = setInterval(() => this.showStats(), 10000); + this.intervalHandle = setInterval(() => this.showStats(), 2000); }; stopRegularStats = () => { @@ -75,7 +75,7 @@ export class LoadtestRunner { socketConfiguration: SocketConfiguration, configurations: ClassDefinitionWithAmount[] ) { - const protocolFilename = `${formatDate(this.startDate)}_${Math.ceil(Math.random() * 1000)}.json`; + const protocolFilename = `${formatDate(this.startDate)}_${Math.ceil(Math.random() * 1000)}.loadtest.json`; const protocol = { protocolFilename, startDateTime: formatDate(this.startDate), @@ -111,9 +111,23 @@ export class LoadtestRunner { this.startRegularStats(); - const promises: Promise[] = classes.flatMap(async (classDefinition) => { + const boardIds = await createBoardsResilient(urls.api, socketConfiguration.token, courseId, classes.length).catch( + (err) => { + /* istanbul ignore next */ + this.stopRegularStats(); + /* istanbul ignore next */ + throw err; + } + ); + + if (boardIds.length !== classes.length) { + /* istanbul ignore next */ + throw new Error('Failed to create all boards'); + } + + const promises: Promise[] = classes.flatMap(async (classDefinition, index) => { const boardLoadTest = this.createBoardLoadTest(this.socketConnectionManager, this.onError); - const boardId = await createBoard(urls.api, socketConfiguration.token, courseId); + const boardId = boardIds[index]; return boardLoadTest.runBoardTest(boardId, classDefinition); }); diff --git a/apps/server/src/modules/board/loadtest/socket-connection-manager.spec.ts b/apps/server/src/modules/board/loadtest/socket-connection-manager.spec.ts index bbf25c09b77..677321eec1f 100644 --- a/apps/server/src/modules/board/loadtest/socket-connection-manager.spec.ts +++ b/apps/server/src/modules/board/loadtest/socket-connection-manager.spec.ts @@ -22,9 +22,11 @@ describe('SocketConnectionManager', () => { describe('destroySocketConnections', () => { it('should destroy the connections', async () => { const { socketConnectionManager } = setup(); - const connections = await socketConnectionManager.createConnections(5); + await socketConnectionManager.createConnections(5); + + expect(socketConnectionManager.getClientCount()).toBe(5); - await socketConnectionManager.destroySocketConnections(connections); + await socketConnectionManager.destroySocketConnections(); expect(socketConnectionManager.getClientCount()).toBe(0); }); diff --git a/apps/server/src/modules/board/loadtest/socket-connection-manager.ts b/apps/server/src/modules/board/loadtest/socket-connection-manager.ts index dd8eb05221d..c1d36121bb8 100644 --- a/apps/server/src/modules/board/loadtest/socket-connection-manager.ts +++ b/apps/server/src/modules/board/loadtest/socket-connection-manager.ts @@ -31,7 +31,7 @@ export class SocketConnectionManager { const connections: SocketConnection[] = []; while (connections.length < amount) { - const batchAmount = Math.min(100, amount - connections.length); + const batchAmount = Math.min(10, amount - connections.length); const promises = Array(batchAmount) .fill(1) .map(() => this.createConnection()); @@ -59,9 +59,9 @@ export class SocketConnectionManager { this.onErrorHandler = onErrorHandler; } - async destroySocketConnections(sockets: SocketConnection[]) { - const promises = sockets.map((socket) => socket.close()); + async destroySocketConnections() { + const promises = this.connections.map((connection) => connection.close()); + this.connections = []; await Promise.all(promises); - this.connections = this.connections.filter((connection) => !sockets.includes(connection)); } } diff --git a/apps/server/src/modules/board/loadtest/socket-connection.ts b/apps/server/src/modules/board/loadtest/socket-connection.ts index 146dd24545d..c9d09e78bd1 100644 --- a/apps/server/src/modules/board/loadtest/socket-connection.ts +++ b/apps/server/src/modules/board/loadtest/socket-connection.ts @@ -79,7 +79,7 @@ export class SocketConnection { if (!this.connected) { reject(new Error('Timeout: could not connect to socket server')); } - }, this.socketConfiguration.connectTimeout ?? 5000); + }, this.socketConfiguration.connectTimeout ?? 10000); }); } @@ -88,7 +88,7 @@ export class SocketConnection { }; // eslint-disable-next-line arrow-body-style - emitAndWait = async (actionPrefix: string, payload: unknown, timeoutMs = 5000) => { + emitAndWait = async (actionPrefix: string, payload: unknown, timeoutMs = 10000) => { /* istanbul ignore next */ return new Promise((resolve, reject) => { this.socket.emit(`${actionPrefix}-request`, payload); @@ -133,7 +133,7 @@ export class SocketConnection { } } - registerPromise(successEvent: string, resolve: Callback, reject: Callback, timeoutMs = 5000) { + registerPromise(successEvent: string, resolve: Callback, reject: Callback, timeoutMs = 10000) { const startTime = performance.now(); const handle = uuid(); const failureEvent = successEvent.replace('-success', '-failure'); From ea440a3856b1643b1267c2d09d42dc00584f71da Mon Sep 17 00:00:00 2001 From: ezzato Date: Fri, 30 Aug 2024 17:23:11 +0200 Subject: [PATCH 08/29] BC-7968 - Remove GH-Pages (Docs) from server code (#5214) * remove pages workflow file and npm commands for docs * remove other traces of the old doc page --- .github/workflows/publish_pages.yml | 27 --------------------------- README.md | 29 +++++++++++++---------------- apps/assets/README.md | 1 - apps/server/README.md | 9 ++------- package.json | 2 -- 5 files changed, 15 insertions(+), 53 deletions(-) delete mode 100644 .github/workflows/publish_pages.yml delete mode 100644 apps/assets/README.md diff --git a/.github/workflows/publish_pages.yml b/.github/workflows/publish_pages.yml deleted file mode 100644 index 1e8fc1065a9..00000000000 --- a/.github/workflows/publish_pages.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Publish to GitHub Pages - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -permissions: - contents: write - -jobs: - publish-nest-documentation: - runs-on: ubuntu-latest - steps: - - name: Checkout 🛠 - uses: actions/checkout@v4 - - - name: Generate documentation 🤖 - run: npm run nest:docs:build - - - name: Deploy 🚀 - if: ${{ github.ref == 'refs/heads/main' }} - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: gh-pages # The branch the action should deploy to. - folder: docs # The folder the action should deploy. diff --git a/README.md b/README.md index bd20e5bf810..606aa230d48 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,20 @@ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=hpi-schul-cloud_schulcloud-server&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=hpi-schul-cloud_schulcloud-server) ## NestJS application -> Find the [NestJS applications documentation](https://hpi-schul-cloud.github.io/schulcloud-server/additional-documentation/nestjs-application.html) of this repository at GitHub pages. It contains information about +Based on [NestJS](https://docs.nestjs.com/) + +> Find the [documentation](https://documentation.dbildungscloud.dev/docs/schulcloud-server/architecture) of this repository at GitHub pages. It contains information about -- setup & preconditions -- starting the application -- testing -- tools setup (VSCode, Git) +- api design +- code guidelines +- development +- migrations - architecture -Based on [NestJS](https://docs.nestjs.com/) +There is also some old documentation in the server code /apps/server/doc/*.md. + +Note that not all the .md file here are up to date. + ## Feathers application @@ -44,18 +49,10 @@ It is possible (not very likely) that the server api is called with URLs that us When these paths are accessed an error with context `[DEPRECATED-PATH]` is logged. -## Setup - -The whole application setup with all dependencies can be found in [System Architecture](https://docs.dbildungscloud.de/display/DBH/System+Architecture). It contains information about how different application components are connected to each other. - -## Debugger Configuration in Visual Studio Code - -For more details how to set up Visual Studio Code, read [this document](https://docs.dbildungscloud.de/display/DBH/Visual+Studio+Code+-+Beginners+Guide). - ## How to name your branch and create a pull request (PR) 1. Take the Ticket Number from JIRA (ticketsystem.dbildungscloud.de), e.g. SC-999 -2. Name the feature branch beginning with Ticket Number, all words separated by dash "-", e.g. `feature/SC-999-fantasy-problem` +2. Name the feature branch beginning with Ticket Number, all words separated by dash "-", e.g. `SC-999-fantasy-problem` 3. Create a PR on branch develop containing the Ticket Number in PR title 4. Keep the `WIP` label as long as this PR is in development, complete PR checklist (is automatically added), keep or increase code test coverage, and pass all tests before you remove the `WIP` label. Reviewers will be added automatically. @@ -66,7 +63,7 @@ Default branch: `main` 1. Go into project folder 2. Checkout to develop branch (or clone for the first time) 3. Run `git pull` -4. Create a branch for your new feature named feature/BC-*Ticket-ID*-*Description* +4. Create a branch for your new feature named BC-*Ticket-ID*-*Description* 5. Run the tests (see above) 6. Commit with a meaningful commit message(!) even at 4 a.m. and not stuff like "dfsdfsf" 7. Start a pull request (see above) to branch develop to merge your changes diff --git a/apps/assets/README.md b/apps/assets/README.md deleted file mode 100644 index 39818e43fbc..00000000000 --- a/apps/assets/README.md +++ /dev/null @@ -1 +0,0 @@ -This folder contains assets referenced by `/apps/server/doc` markdown files. Files are placed here to are correctly referenced directly from within of the markdown files and when running compodoc using `npm run nest:doc:serve`. diff --git a/apps/server/README.md b/apps/server/README.md index 5d99d350456..9b121735987 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -73,11 +73,6 @@ Beside existing [scripts](/), for the nestJS application the following scripts h - `nest:start:files-storage:debug` run **file storage** in dev-mode with hot-reload and debug port opened on port :9229 - `nest:start:files-storage:prod` start **file storage** in production mode, requires `nest:build` to be executed beforehand -#### How to build and serve the documentation - -- `nest:docs:build` builds code documentation and module relations into /documentation folder -- `nest:docs:serve` builds code documentation and module relations into /documentation folder and serves it on port :8080 with hot reload on changes - #### How to start the server console The console offers management capabilities of the application. @@ -132,9 +127,9 @@ Legacy/feathers Swagger UI documentation when running the server locally, it is ### How to get full documentation -The documentation is provided by compodoc, run `npm run nest:docs:serve`, check generated compodoc features, custom information can be found in additional information section. Your console will tell you, where it is served. +We have an extra repository for our documentation. -The updated documentation is published as [GitHub Page](https://hpi-schul-cloud.github.io/schulcloud-server/additional-documentation/nestjs-application.html) +[Doc Page](https://documentation.dbildungscloud.dev/) ### Content diff --git a/package.json b/package.json index 4a7b586eb8a..626af7e0be0 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,6 @@ "nest:prebuild": "rimraf dist", "nest:build": "nest build", "nest:build:all": "npm run nest:build", - "nest:docs:serve": "npx --yes @compodoc/compodoc -p tsconfig.json -s -w -a apps/assets --includes apps/server/doc -d docs", - "nest:docs:build": "npx --yes @compodoc/compodoc -p tsconfig.json -w -a apps/assets --includes apps/server/doc -d docs", "nest:start": "nest start server", "nest:start:dev": "nest start server --watch", "nest:start:debug": "nest start server --debug --watch", From ebd0fa47c15e4443c7ff711f0637dbca63014944 Mon Sep 17 00:00:00 2001 From: Fshmit <122355627+Fshmit@users.noreply.github.com> Date: Mon, 2 Sep 2024 09:20:40 +0200 Subject: [PATCH 09/29] EW-835 provided courses API client and its adapter (#5200) * EW-835 provided courses API client and its adapter --- .../common-cartridge-client/README.md | 34 + .../course-api-client/.gitignore | 4 + .../course-api-client/.npmignore | 1 + .../.openapi-generator-ignore | 23 + .../.openapi-generator/FILES | 15 + .../course-client/course-api-client/api.ts | 18 + .../course-api-client/api/courses-api.ts | 611 ++++++++++++++++++ .../course-client/course-api-client/base.ts | 86 +++ .../course-client/course-api-client/common.ts | 150 +++++ .../course-api-client/configuration.ts | 110 ++++ .../course-api-client/git_push.sh | 57 ++ .../course-client/course-api-client/index.ts | 18 + ...urse-common-cartridge-metadata-response.ts | 48 ++ .../models/course-export-body-params.ts | 42 ++ .../models/course-metadata-list-response.ts | 51 ++ .../models/course-metadata-response.ts | 66 ++ .../course-api-client/models/index.ts | 4 + .../courses-client.adapter.spec.ts | 103 +++ .../course-client/courses-client.adapter.ts | 42 ++ .../course-client/courses-client.config.ts | 5 + .../courses-client.module.spec.ts | 29 + .../course-client/courses-client.module.ts | 26 + .../course-common-cartridge-metadata.dto.ts | 16 + .../course-client/index.ts | 4 + .../common-cartridge.module.ts | 5 + .../common-cartridge.controller.spec.ts | 10 +- .../controller/common-cartridge.controller.ts | 5 +- .../dto/course-export-body.response.ts | 13 + .../common-cartridge-export.service.spec.ts | 30 + .../common-cartridge-export.service.ts | 12 +- .../uc/common-cartridge.uc.spec.ts | 17 +- .../uc/common-cartridge.uc.ts | 13 +- scripts/filter_spec-apis.js | 108 ++++ sonar-project.properties | 4 +- 34 files changed, 1770 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/README.md create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.gitignore create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.npmignore create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator-ignore create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator/FILES create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api/courses-api.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/base.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/common.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/configuration.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/git_push.sh create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/index.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-common-cartridge-metadata-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-export-body-params.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-list-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/index.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.config.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.spec.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/index.ts create mode 100644 apps/server/src/modules/common-cartridge/controller/dto/course-export-body.response.ts create mode 100644 scripts/filter_spec-apis.js diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/README.md b/apps/server/src/modules/common-cartridge/common-cartridge-client/README.md new file mode 100644 index 00000000000..136b6686d6c --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/README.md @@ -0,0 +1,34 @@ +# How to filter the swagger specification of the schulcloud using filter-spec.js script? + +You can run the script with a parameter, which is the path to the controller you want to filter. + +For example: +```bash +node ./scripts/filter-spec.js /courses +``` +The execution of the script will generate a new file in the script folder called **filtered-spec.json** with the filtered specification to the controller, you have passed as a parameter. This file should be used to generate the client code for the controller you want to use and should be **deleted** after that. +This script is also able to just select used models and operations from the swagger specification. Unused models will be ignored. + +# How to generate the Api client code using openapi-generator-cli? + +Use this command to generate the client and delete the filtered spec file + +For example: +```bash +npx openapi-generator-cli generate -i './scripts/filtered-spec.json' -g typescript-axios -o "apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-api-client" --skip-validate-spec -c 'openapitools-config.json' && rm .\scripts\filtered-spec.json +``` +The input file in this command is the file generated by the filter-spec.js script. The output folder is the folder where the client code will be generated. The openapitools-config.json file is the configuration file for the openapi-generator-cli. After generating the client code, the filtered-spec.json file will be deleted with the command **rm .\scripts\filtered-spec.json**. + +***Make sure*** to delete the filtered-spec.json file after generating the client code, before committing the changes. + +# Add the generated client code to the sonar-project.properties file +To avoid sonarqube errors, you need to add the generated client code to the sonar-project.properties file. +For example: +```properties +sonar.exclusions=**/courses-api-client/**/*.ts +sonar.exclusions.coverage=**/courses-api-client/**/*.ts +``` +You can add several exclusions and separate them with a comma. + + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.gitignore b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.gitignore new file mode 100644 index 00000000000..149b5765472 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.gitignore @@ -0,0 +1,4 @@ +wwwroot/*.js +node_modules +typings +dist diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.npmignore b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.npmignore new file mode 100644 index 00000000000..999d88df693 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.npmignore @@ -0,0 +1 @@ +# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm \ No newline at end of file diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator-ignore b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator-ignore new file mode 100644 index 00000000000..7484ee590a3 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator/FILES b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator/FILES new file mode 100644 index 00000000000..ca8e5c1e70f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/.openapi-generator/FILES @@ -0,0 +1,15 @@ +.gitignore +.npmignore +.openapi-generator-ignore +api.ts +api/courses-api.ts +base.ts +common.ts +configuration.ts +git_push.sh +index.ts +models/course-common-cartridge-metadata-response.ts +models/course-export-body-params.ts +models/course-metadata-list-response.ts +models/course-metadata-response.ts +models/index.ts diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api.ts new file mode 100644 index 00000000000..2a01572ebdf --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 * from './api/courses-api'; + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api/courses-api.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api/courses-api.ts new file mode 100644 index 00000000000..5cb342b5acc --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/api/courses-api.ts @@ -0,0 +1,611 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { CourseCommonCartridgeMetadataResponse } from '../models'; +// @ts-ignore +import type { CourseExportBodyParams } from '../models'; +// @ts-ignore +import type { CourseMetadataListResponse } from '../models'; +/** + * CoursesApi - axios parameter creator + * @export + */ +export const CoursesApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} courseId The id of the course + * @param {CourseControllerExportCourseVersion} version The version of CC export + * @param {CourseExportBodyParams} courseExportBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerExportCourse: async (courseId: string, version: CourseControllerExportCourseVersion, courseExportBodyParams: CourseExportBodyParams, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'courseId' is not null or undefined + assertParamExists('courseControllerExportCourse', 'courseId', courseId) + // verify required parameter 'version' is not null or undefined + assertParamExists('courseControllerExportCourse', 'version', version) + // verify required parameter 'courseExportBodyParams' is not null or undefined + assertParamExists('courseControllerExportCourse', 'courseExportBodyParams', courseExportBodyParams) + const localVarPath = `/courses/{courseId}/export` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (version !== undefined) { + localVarQueryParameter['version'] = version; + } + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(courseExportBodyParams, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerFindForUser: async (skip?: number, limit?: number, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/courses`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (skip !== undefined) { + localVarQueryParameter['skip'] = skip; + } + + if (limit !== undefined) { + localVarQueryParameter['limit'] = limit; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerGetCourseCcMetadataById: async (courseId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'courseId' is not null or undefined + assertParamExists('courseControllerGetCourseCcMetadataById', 'courseId', courseId) + const localVarPath = `/courses/{courseId}/cc-metadata` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get permissions for a user in a course. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerGetUserPermissions: async (courseId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'courseId' is not null or undefined + assertParamExists('courseControllerGetUserPermissions', 'courseId', courseId) + const localVarPath = `/courses/{courseId}/user-permissions` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Imports a course from a Common Cartridge file. + * @param {File} file The Common Cartridge file to import. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerImportCourse: async (file: File, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'file' is not null or undefined + assertParamExists('courseControllerImportCourse', 'file', file) + const localVarPath = `/courses/import`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + const localVarFormParams = new ((configuration && configuration.formDataCtor) || FormData)(); + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + if (file !== undefined) { + localVarFormParams.append('file', file as any); + } + + + localVarHeaderParameter['Content-Type'] = 'multipart/form-data'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = localVarFormParams; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Stop the synchronization of a course with a group. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerStopSynchronization: async (courseId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'courseId' is not null or undefined + assertParamExists('courseControllerStopSynchronization', 'courseId', courseId) + const localVarPath = `/courses/{courseId}/stop-sync` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * CoursesApi - functional programming interface + * @export + */ +export const CoursesApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = CoursesApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} courseId The id of the course + * @param {CourseControllerExportCourseVersion} version The version of CC export + * @param {CourseExportBodyParams} courseExportBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async courseControllerExportCourse(courseId: string, version: CourseControllerExportCourseVersion, courseExportBodyParams: CourseExportBodyParams, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerExportCourse(courseId, version, courseExportBodyParams, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['CoursesApi.courseControllerExportCourse']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async courseControllerFindForUser(skip?: number, limit?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerFindForUser(skip, limit, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['CoursesApi.courseControllerFindForUser']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async courseControllerGetCourseCcMetadataById(courseId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerGetCourseCcMetadataById(courseId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['CoursesApi.courseControllerGetCourseCcMetadataById']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get permissions for a user in a course. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async courseControllerGetUserPermissions(courseId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerGetUserPermissions(courseId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['CoursesApi.courseControllerGetUserPermissions']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Imports a course from a Common Cartridge file. + * @param {File} file The Common Cartridge file to import. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async courseControllerImportCourse(file: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerImportCourse(file, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['CoursesApi.courseControllerImportCourse']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Stop the synchronization of a course with a group. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async courseControllerStopSynchronization(courseId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerStopSynchronization(courseId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['CoursesApi.courseControllerStopSynchronization']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * CoursesApi - factory interface + * @export + */ +export const CoursesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = CoursesApiFp(configuration) + return { + /** + * + * @param {string} courseId The id of the course + * @param {CourseControllerExportCourseVersion} version The version of CC export + * @param {CourseExportBodyParams} courseExportBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerExportCourse(courseId: string, version: CourseControllerExportCourseVersion, courseExportBodyParams: CourseExportBodyParams, options?: any): AxiosPromise { + return localVarFp.courseControllerExportCourse(courseId, version, courseExportBodyParams, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerFindForUser(skip?: number, limit?: number, options?: any): AxiosPromise { + return localVarFp.courseControllerFindForUser(skip, limit, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerGetCourseCcMetadataById(courseId: string, options?: any): AxiosPromise { + return localVarFp.courseControllerGetCourseCcMetadataById(courseId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get permissions for a user in a course. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerGetUserPermissions(courseId: string, options?: any): AxiosPromise { + return localVarFp.courseControllerGetUserPermissions(courseId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Imports a course from a Common Cartridge file. + * @param {File} file The Common Cartridge file to import. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerImportCourse(file: File, options?: any): AxiosPromise { + return localVarFp.courseControllerImportCourse(file, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Stop the synchronization of a course with a group. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerStopSynchronization(courseId: string, options?: any): AxiosPromise { + return localVarFp.courseControllerStopSynchronization(courseId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * CoursesApi - interface + * @export + * @interface CoursesApi + */ +export interface CoursesApiInterface { + /** + * + * @param {string} courseId The id of the course + * @param {CourseControllerExportCourseVersion} version The version of CC export + * @param {CourseExportBodyParams} courseExportBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApiInterface + */ + courseControllerExportCourse(courseId: string, version: CourseControllerExportCourseVersion, courseExportBodyParams: CourseExportBodyParams, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApiInterface + */ + courseControllerFindForUser(skip?: number, limit?: number, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApiInterface + */ + courseControllerGetCourseCcMetadataById(courseId: string, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Get permissions for a user in a course. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApiInterface + */ + courseControllerGetUserPermissions(courseId: string, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Imports a course from a Common Cartridge file. + * @param {File} file The Common Cartridge file to import. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApiInterface + */ + courseControllerImportCourse(file: File, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Stop the synchronization of a course with a group. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApiInterface + */ + courseControllerStopSynchronization(courseId: string, options?: RawAxiosRequestConfig): AxiosPromise; + +} + +/** + * CoursesApi - object-oriented interface + * @export + * @class CoursesApi + * @extends {BaseAPI} + */ +export class CoursesApi extends BaseAPI implements CoursesApiInterface { + /** + * + * @param {string} courseId The id of the course + * @param {CourseControllerExportCourseVersion} version The version of CC export + * @param {CourseExportBodyParams} courseExportBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApi + */ + public courseControllerExportCourse(courseId: string, version: CourseControllerExportCourseVersion, courseExportBodyParams: CourseExportBodyParams, options?: RawAxiosRequestConfig) { + return CoursesApiFp(this.configuration).courseControllerExportCourse(courseId, version, courseExportBodyParams, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {number} [skip] Number of elements (not pages) to be skipped + * @param {number} [limit] Page limit, defaults to 10. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApi + */ + public courseControllerFindForUser(skip?: number, limit?: number, options?: RawAxiosRequestConfig) { + return CoursesApiFp(this.configuration).courseControllerFindForUser(skip, limit, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApi + */ + public courseControllerGetCourseCcMetadataById(courseId: string, options?: RawAxiosRequestConfig) { + return CoursesApiFp(this.configuration).courseControllerGetCourseCcMetadataById(courseId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get permissions for a user in a course. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApi + */ + public courseControllerGetUserPermissions(courseId: string, options?: RawAxiosRequestConfig) { + return CoursesApiFp(this.configuration).courseControllerGetUserPermissions(courseId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Imports a course from a Common Cartridge file. + * @param {File} file The Common Cartridge file to import. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApi + */ + public courseControllerImportCourse(file: File, options?: RawAxiosRequestConfig) { + return CoursesApiFp(this.configuration).courseControllerImportCourse(file, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Stop the synchronization of a course with a group. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApi + */ + public courseControllerStopSynchronization(courseId: string, options?: RawAxiosRequestConfig) { + return CoursesApiFp(this.configuration).courseControllerStopSynchronization(courseId, options).then((request) => request(this.axios, this.basePath)); + } +} + +/** + * @export + */ +export const CourseControllerExportCourseVersion = { + _0_0: '1.0.0', + _1_0: '1.1.0', + _2_0: '1.2.0', + _3_0: '1.3.0', + _4_0: '1.4.0' +} as const; +export type CourseControllerExportCourseVersion = typeof CourseControllerExportCourseVersion[keyof typeof CourseControllerExportCourseVersion]; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/base.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/base.ts new file mode 100644 index 00000000000..82686c7b81b --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/base.ts @@ -0,0 +1,86 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +import type { Configuration } from './configuration'; +// Some imports not used depending on template conditions +// @ts-ignore +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; + +export const BASE_PATH = "http://localhost/api/v3".replace(/\/+$/, ""); + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string; + options: RawAxiosRequestConfig; +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined; + + constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { + if (configuration) { + this.configuration = configuration; + this.basePath = configuration.basePath ?? basePath; + } + } +}; + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + constructor(public field: string, msg?: string) { + super(msg); + this.name = "RequiredError" + } +} + +interface ServerMap { + [key: string]: { + url: string, + description: string, + }[]; +} + +/** + * + * @export + */ +export const operationServerMap: ServerMap = { +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/common.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/common.ts new file mode 100644 index 00000000000..6c119efb60d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/common.ts @@ -0,0 +1,150 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +import type { Configuration } from "./configuration"; +import type { RequestArgs } from "./base"; +import type { AxiosInstance, AxiosResponse } from 'axios'; +import { RequiredError } from "./base"; + +/** + * + * @export + */ +export const DUMMY_BASE_URL = 'https://example.com' + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey; + object[keyParamName] = localVarApiKeyValue; + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { username: configuration.username, password: configuration.password }; + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const accessToken = typeof configuration.accessToken === 'function' + ? await configuration.accessToken() + : await configuration.accessToken; + object["Authorization"] = "Bearer " + accessToken; + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = typeof configuration.accessToken === 'function' + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken; + object["Authorization"] = "Bearer " + localVarAccessTokenValue; + } +} + +function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void { + if (parameter == null) return; + if (typeof parameter === "object") { + if (Array.isArray(parameter)) { + (parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key)); + } + else { + Object.keys(parameter).forEach(currentKey => + setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`) + ); + } + } + else { + if (urlSearchParams.has(key)) { + urlSearchParams.append(key, parameter); + } + else { + urlSearchParams.set(key, parameter); + } + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search); + setFlattenedQueryParams(searchParams, objects); + url.search = searchParams.toString(); +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { + const nonString = typeof value !== 'string'; + const needsSerialization = nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers['Content-Type']) + : nonString; + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : (value || ""); +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { + return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { + const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url}; + return axios.request(axiosRequestArgs); + }; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/configuration.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/configuration.ts new file mode 100644 index 00000000000..8c97d307cf4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/configuration.ts @@ -0,0 +1,110 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 ConfigurationParameters { + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + username?: string; + password?: string; + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + basePath?: string; + serverIndex?: number; + baseOptions?: any; + formDataCtor?: new () => any; +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string; + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string; + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string; + /** + * override server index + * + * @type {number} + * @memberof Configuration + */ + serverIndex?: number; + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any; + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any; + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey; + this.username = param.username; + this.password = param.password; + this.accessToken = param.accessToken; + this.basePath = param.basePath; + this.serverIndex = param.serverIndex; + this.baseOptions = param.baseOptions; + this.formDataCtor = param.formDataCtor; + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); + return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/git_push.sh b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/git_push.sh new file mode 100644 index 00000000000..f53a75d4fab --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/git_push.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ +# +# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" + +git_user_id=$1 +git_repo_id=$2 +release_note=$3 +git_host=$4 + +if [ "$git_host" = "" ]; then + git_host="github.com" + echo "[INFO] No command line input provided. Set \$git_host to $git_host" +fi + +if [ "$git_user_id" = "" ]; then + git_user_id="GIT_USER_ID" + echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" +fi + +if [ "$git_repo_id" = "" ]; then + git_repo_id="GIT_REPO_ID" + echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" +fi + +if [ "$release_note" = "" ]; then + release_note="Minor update" + echo "[INFO] No command line input provided. Set \$release_note to $release_note" +fi + +# Initialize the local directory as a Git repository +git init + +# Adds the files in the local repository and stages them for commit. +git add . + +# Commits the tracked changes and prepares them to be pushed to a remote repository. +git commit -m "$release_note" + +# Sets the new remote +git_remote=$(git remote) +if [ "$git_remote" = "" ]; then # git remote not defined + + if [ "$GIT_TOKEN" = "" ]; then + echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." + git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git + else + git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git + fi + +fi + +git pull origin master + +# Pushes (Forces) the changes in the local repository up to the remote repository +echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" +git push origin master 2>&1 | grep -v 'To https' diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/index.ts new file mode 100644 index 00000000000..8b762df664e --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/index.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 * from "./api"; +export * from "./configuration"; +export * from "./models"; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-common-cartridge-metadata-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-common-cartridge-metadata-response.ts new file mode 100644 index 00000000000..85d01bb7a85 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-common-cartridge-metadata-response.ts @@ -0,0 +1,48 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 CourseCommonCartridgeMetadataResponse + */ +export interface CourseCommonCartridgeMetadataResponse { + /** + * The id of the course + * @type {string} + * @memberof CourseCommonCartridgeMetadataResponse + */ + 'id': string; + /** + * Title of the course + * @type {string} + * @memberof CourseCommonCartridgeMetadataResponse + */ + 'title': string; + /** + * Creation date of the course + * @type {string} + * @memberof CourseCommonCartridgeMetadataResponse + */ + 'creationDate': string; + /** + * Copy right owners of the course + * @type {Array} + * @memberof CourseCommonCartridgeMetadataResponse + */ + 'copyRightOwners': Array; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-export-body-params.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-export-body-params.ts new file mode 100644 index 00000000000..7fe921f621c --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-export-body-params.ts @@ -0,0 +1,42 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 CourseExportBodyParams + */ +export interface CourseExportBodyParams { + /** + * The list of ids of topics which should be exported. If empty no topics are exported. + * @type {Array} + * @memberof CourseExportBodyParams + */ + 'topics': Array; + /** + * The list of ids of tasks which should be exported. If empty no tasks are exported. + * @type {Array} + * @memberof CourseExportBodyParams + */ + 'tasks': Array; + /** + * The list of ids of column boards which should be exported. If empty no column boards are exported. + * @type {Array} + * @memberof CourseExportBodyParams + */ + 'columnBoards': Array; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-list-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-list-response.ts new file mode 100644 index 00000000000..489f5be83ea --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-list-response.ts @@ -0,0 +1,51 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { CourseMetadataResponse } from './course-metadata-response'; + +/** + * + * @export + * @interface CourseMetadataListResponse + */ +export interface CourseMetadataListResponse { + /** + * The items for the current page. + * @type {Array} + * @memberof CourseMetadataListResponse + */ + 'data': Array; + /** + * The total amount of items. + * @type {number} + * @memberof CourseMetadataListResponse + */ + 'total': number; + /** + * The amount of items skipped from the start. + * @type {number} + * @memberof CourseMetadataListResponse + */ + 'skip': number; + /** + * The page size of the response. + * @type {number} + * @memberof CourseMetadataListResponse + */ + 'limit': number; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-response.ts new file mode 100644 index 00000000000..0ba6eacd5a7 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/course-metadata-response.ts @@ -0,0 +1,66 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 CourseMetadataResponse + */ +export interface CourseMetadataResponse { + /** + * The id of the Grid element + * @type {string} + * @memberof CourseMetadataResponse + */ + 'id': string; + /** + * Title of the Grid element + * @type {string} + * @memberof CourseMetadataResponse + */ + 'title': string; + /** + * Short title of the Grid element + * @type {string} + * @memberof CourseMetadataResponse + */ + 'shortTitle': string; + /** + * Color of the Grid element + * @type {string} + * @memberof CourseMetadataResponse + */ + 'displayColor': string; + /** + * Start date of the course + * @type {string} + * @memberof CourseMetadataResponse + */ + 'startDate'?: string; + /** + * End date of the course. After this the course counts as archived + * @type {string} + * @memberof CourseMetadataResponse + */ + 'untilDate'?: string; + /** + * Start of the copying process if it is still ongoing - otherwise property is not set. + * @type {string} + * @memberof CourseMetadataResponse + */ + 'copyingSince'?: string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/index.ts new file mode 100644 index 00000000000..2e4b620054f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/course-api-client/models/index.ts @@ -0,0 +1,4 @@ +export * from './course-common-cartridge-metadata-response'; +export * from './course-export-body-params'; +export * from './course-metadata-list-response'; +export * from './course-metadata-response'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts new file mode 100644 index 00000000000..058a7517f98 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; +import { faker } from '@faker-js/faker'; +import { AxiosResponse } from 'axios'; +import { CoursesClientAdapter } from './courses-client.adapter'; +import { CourseCommonCartridgeMetadataResponse, CoursesApi } from './course-api-client'; + +const jwtToken = 'dummyJwtToken'; + +describe(CoursesClientAdapter.name, () => { + let module: TestingModule; + let service: CoursesClientAdapter; + let coursesApi: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CoursesClientAdapter, + { + provide: CoursesApi, + useValue: createMock(), + }, + { + provide: REQUEST, + useValue: createMock({ + headers: { + authorization: `Bearer ${jwtToken}`, + }, + }), + }, + ], + }).compile(); + + service = module.get(CoursesClientAdapter); + coursesApi = module.get(CoursesApi); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getCourseCommonCartridgeMetadata', () => { + describe('when getCourseCommonCartridgeMetadata is called', () => { + const setup = () => { + const courseId = faker.string.uuid(); + const response = createMock>({ + data: { + id: faker.string.uuid(), + title: faker.lorem.word(), + creationDate: faker.date.recent().toString(), + copyRightOwners: [faker.lorem.word(), faker.lorem.word()], + }, + }); + + coursesApi.courseControllerGetCourseCcMetadataById.mockResolvedValueOnce(response); + return { courseId }; + }; + it('should return course common cartridge metadata', async () => { + const { courseId } = setup(); + + const expectedOptions = { headers: { authorization: `Bearer ${jwtToken}` } }; + + const result = await service.getCourseCommonCartridgeMetadata(courseId); + + expect(coursesApi.courseControllerGetCourseCcMetadataById).toHaveBeenCalledWith(courseId, expectedOptions); + expect(result.id).toBeDefined(); + expect(result.title).toBeDefined(); + expect(result.creationDate).toBeDefined(); + expect(result.copyRightOwners).toBeDefined(); + }); + }); + }); + + describe('when no JWT token is found', () => { + const setup = () => { + const courseId = faker.string.uuid(); + const error = new Error('Authentication is required.'); + const request = createMock({ + headers: {}, + }); + + const adapter: CoursesClientAdapter = new CoursesClientAdapter(coursesApi, request); + + return { error, courseId, adapter }; + }; + + it('should throw an Error', async () => { + const { error, courseId, adapter } = setup(); + + await expect(adapter.getCourseCommonCartridgeMetadata(courseId)).rejects.toThrowError(error); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts new file mode 100644 index 00000000000..324f907f06f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { extractJwtFromHeader } from '@shared/common'; +import { RawAxiosRequestConfig } from 'axios'; +import { Request } from 'express'; +import { CourseCommonCartridgeMetadataDto } from './dto/course-common-cartridge-metadata.dto'; +import { CoursesApi } from './course-api-client'; + +@Injectable() +export class CoursesClientAdapter { + constructor(private readonly coursesApi: CoursesApi, @Inject(REQUEST) private request: Request) {} + + public async getCourseCommonCartridgeMetadata(courseId: string): Promise { + const options = this.createOptionParams(); + const response = await this.coursesApi.courseControllerGetCourseCcMetadataById(courseId, options); + const courseCommonCartridgeMetadata: CourseCommonCartridgeMetadataDto = new CourseCommonCartridgeMetadataDto({ + id: response.data.id, + title: response.data.title, + creationDate: response.data.creationDate, + copyRightOwners: response.data.copyRightOwners, + }); + + return courseCommonCartridgeMetadata; + } + + private createOptionParams(): RawAxiosRequestConfig { + const jwt = this.getJwt(); + const options: RawAxiosRequestConfig = { headers: { authorization: `Bearer ${jwt}` } }; + + return options; + } + + private getJwt(): string { + const jwt = extractJwtFromHeader(this.request) || this.request.headers.authorization; + + if (!jwt) { + throw new UnauthorizedException('Authentication is required.'); + } + + return jwt; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.config.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.config.ts new file mode 100644 index 00000000000..94f86ce2560 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.config.ts @@ -0,0 +1,5 @@ +import { ConfigurationParameters } from './course-api-client'; + +export interface CoursesClientConfig extends ConfigurationParameters { + basePath?: string; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.spec.ts new file mode 100644 index 00000000000..515b77ac509 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.spec.ts @@ -0,0 +1,29 @@ +import { TestingModule, Test } from '@nestjs/testing'; +import { CoursesClientModule } from './courses-client.module'; +import { CoursesClientAdapter } from './courses-client.adapter'; + +describe('CommonCartridgeClientModule', () => { + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + CoursesClientModule.register({ + basePath: 'http://localhost:3000', + }), + ], + }).compile(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('when module is initialized', () => { + it('should be defined', () => { + const coursesClientAdapter = module.get(CoursesClientAdapter); + + expect(coursesClientAdapter).toBeDefined(); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.ts new file mode 100644 index 00000000000..039d58d11ca --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.module.ts @@ -0,0 +1,26 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { CoursesClientAdapter } from './courses-client.adapter'; +import { Configuration, CoursesApi } from './course-api-client'; +import { CoursesClientConfig } from './courses-client.config'; + +@Module({}) +export class CoursesClientModule { + static register(config: CoursesClientConfig): DynamicModule { + const providers = [ + CoursesClientAdapter, + { + provide: CoursesApi, + useFactory: () => { + const configuration = new Configuration(config); + return new CoursesApi(configuration); + }, + }, + ]; + + return { + module: CoursesClientModule, + providers, + exports: [CoursesClientAdapter], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts new file mode 100644 index 00000000000..117963823ca --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts @@ -0,0 +1,16 @@ +export class CourseCommonCartridgeMetadataDto { + id: string; + + title: string; + + creationDate?: string; + + copyRightOwners: Array; + + constructor(props: CourseCommonCartridgeMetadataDto) { + this.id = props.id; + this.title = props.title; + this.creationDate = props.creationDate; + this.copyRightOwners = props.copyRightOwners; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/index.ts new file mode 100644 index 00000000000..501ca215cc8 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/index.ts @@ -0,0 +1,4 @@ +export { CoursesClientModule } from './courses-client.module'; +export { CoursesClientAdapter } from './courses-client.adapter'; +export { CoursesClientConfig } from './courses-client.config'; +export { CourseCommonCartridgeMetadataDto } from './dto/course-common-cartridge-metadata.dto'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge.module.ts b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts index 0e0e929c7d7..2148d04d9ea 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge.module.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts @@ -4,13 +4,18 @@ import { Module } from '@nestjs/common'; import { ALL_ENTITIES } from '@shared/domain/entity'; import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { RabbitMQWrapperModule } from '@src/infra/rabbitmq'; +import { Configuration } from '@hpi-schul-cloud/commons'; import { defaultMikroOrmOptions } from '../server'; import { CommonCartridgeExportService } from './service/common-cartridge-export.service'; import { CommonCartridgeUc } from './uc/common-cartridge.uc'; +import { CoursesClientModule } from './common-cartridge-client/course-client'; @Module({ imports: [ RabbitMQWrapperModule, + CoursesClientModule.register({ + basePath: `${Configuration.get('API_HOST') as string}/v3/`, + }), FilesStorageClientModule, MikroOrmModule.forRoot({ ...defaultMikroOrmOptions, diff --git a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts index d6712b12b37..a274eff5f7c 100644 --- a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts +++ b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CommonCartridgeUc } from '../uc/common-cartridge.uc'; import { CommonCartridgeController } from './common-cartridge.controller'; import { CourseFileIdsResponse, ExportCourseParams } from './dto'; +import { CourseExportBodyResponse } from './dto/course-export-body.response'; describe('CommonCartridgeController', () => { let module: TestingModule; @@ -37,7 +38,14 @@ describe('CommonCartridgeController', () => { const setup = () => { const courseId = faker.string.uuid(); const request = new ExportCourseParams(); - const expected = new CourseFileIdsResponse([]); + const expected = new CourseExportBodyResponse({ + courseFileIds: new CourseFileIdsResponse([]), + courseCommonCartridgeMetadata: { + id: courseId, + title: faker.lorem.sentence(), + copyRightOwners: [faker.lorem.words()], + }, + }); Reflect.set(request, 'parentId', courseId); commonCartridgeUcMock.exportCourse.mockResolvedValue(expected); diff --git a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts index a91abf6f2b3..bd609c4ff88 100644 --- a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts +++ b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts @@ -1,7 +1,8 @@ import { Controller, Get, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { CommonCartridgeUc } from '../uc/common-cartridge.uc'; -import { CourseFileIdsResponse, ExportCourseParams } from './dto'; +import { ExportCourseParams } from './dto'; +import { CourseExportBodyResponse } from './dto/course-export-body.response'; @ApiTags('common-cartridge') @Controller('common-cartridge') @@ -9,7 +10,7 @@ export class CommonCartridgeController { constructor(private readonly commonCartridgeUC: CommonCartridgeUc) {} @Get('export/:parentId') - public async exportCourse(@Param() exportCourseParams: ExportCourseParams): Promise { + public async exportCourse(@Param() exportCourseParams: ExportCourseParams): Promise { return this.commonCartridgeUC.exportCourse(exportCourseParams.parentId); } } diff --git a/apps/server/src/modules/common-cartridge/controller/dto/course-export-body.response.ts b/apps/server/src/modules/common-cartridge/controller/dto/course-export-body.response.ts new file mode 100644 index 00000000000..9709cbff7d1 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/dto/course-export-body.response.ts @@ -0,0 +1,13 @@ +import { CourseCommonCartridgeMetadataDto } from '../../common-cartridge-client/course-client'; +import { CourseFileIdsResponse } from './common-cartridge.response'; + +export class CourseExportBodyResponse { + courseFileIds?: CourseFileIdsResponse; + + courseCommonCartridgeMetadata: CourseCommonCartridgeMetadataDto; + + constructor(courseExportBodyResponse: CourseExportBodyResponse) { + this.courseFileIds = courseExportBodyResponse.courseFileIds; + this.courseCommonCartridgeMetadata = courseExportBodyResponse.courseCommonCartridgeMetadata; + } +} diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts index 571a73b0d6a..6ac42f83918 100644 --- a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts @@ -3,11 +3,13 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Test, TestingModule } from '@nestjs/testing'; import { CommonCartridgeExportService } from './common-cartridge-export.service'; +import { CoursesClientAdapter } from '../common-cartridge-client/course-client'; describe('CommonCartridgeExportService', () => { let module: TestingModule; let sut: CommonCartridgeExportService; let filesStorageServiceMock: DeepMocked; + let coursesClientAdapterMock: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -17,11 +19,16 @@ describe('CommonCartridgeExportService', () => { provide: FilesStorageClientAdapterService, useValue: createMock(), }, + { + provide: CoursesClientAdapter, + useValue: createMock(), + }, ], }).compile(); sut = module.get(CommonCartridgeExportService); filesStorageServiceMock = module.get(FilesStorageClientAdapterService); + coursesClientAdapterMock = module.get(CoursesClientAdapter); }); afterAll(async () => { @@ -50,4 +57,27 @@ describe('CommonCartridgeExportService', () => { expect(result).toEqual(expected); }); }); + + describe('findCourseCcMetadata', () => { + const setup = () => { + const courseId = faker.string.uuid(); + const expected = { + id: courseId, + title: faker.lorem.sentence(), + copyRightOwners: [faker.lorem.word()], + }; + + coursesClientAdapterMock.getCourseCommonCartridgeMetadata.mockResolvedValue(expected); + + return { courseId, expected }; + }; + + it('should return a CourseCommonCartridgeMetadataDto', async () => { + const { courseId, expected } = setup(); + + const result = await sut.findCourseCommonCartridgeMetadata(courseId); + + expect(result).toEqual(expected); + }); + }); }); diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts index 15918e20675..d4ff9b86993 100644 --- a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts @@ -1,13 +1,23 @@ import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; +import { CourseCommonCartridgeMetadataDto, CoursesClientAdapter } from '../common-cartridge-client/course-client'; @Injectable() export class CommonCartridgeExportService { - constructor(private readonly filesService: FilesStorageClientAdapterService) {} + constructor( + private readonly filesService: FilesStorageClientAdapterService, + private readonly coursesClientAdapter: CoursesClientAdapter + ) {} public async findCourseFileRecords(courseId: string): Promise { const courseFiles = await this.filesService.listFilesOfParent(courseId); return courseFiles; } + + public async findCourseCommonCartridgeMetadata(courseId: string): Promise { + const courseCommonCartridgeMetadata = await this.coursesClientAdapter.getCourseCommonCartridgeMetadata(courseId); + + return courseCommonCartridgeMetadata; + } } diff --git a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts index c6c68e4c91b..8fc9bcba92b 100644 --- a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts +++ b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CourseFileIdsResponse } from '../controller/dto'; import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; import { CommonCartridgeUc } from './common-cartridge.uc'; +import { CourseExportBodyResponse } from '../controller/dto/course-export-body.response'; describe('CommonCartridgeUc', () => { let module: TestingModule; @@ -36,14 +37,26 @@ describe('CommonCartridgeUc', () => { describe('exportCourse', () => { const setup = () => { const courseId = faker.string.uuid(); - const expected = new CourseFileIdsResponse([]); + const expected = new CourseExportBodyResponse({ + courseFileIds: new CourseFileIdsResponse([]), + courseCommonCartridgeMetadata: { + id: courseId, + title: faker.lorem.sentence(), + copyRightOwners: [], + }, + }); commonCartridgeExportServiceMock.findCourseFileRecords.mockResolvedValue([]); + commonCartridgeExportServiceMock.findCourseCommonCartridgeMetadata.mockResolvedValue({ + id: expected.courseCommonCartridgeMetadata?.id ?? '', + title: expected.courseCommonCartridgeMetadata?.title ?? '', + copyRightOwners: expected.courseCommonCartridgeMetadata?.copyRightOwners ?? [], + }); return { courseId, expected }; }; - it('should return a list of found FileRecords', async () => { + it('should return a course export response with file IDs and metadata of a course', async () => { const { courseId, expected } = setup(); const result = await sut.exportCourse(courseId); diff --git a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts index d00ec42c92d..8caa9381633 100644 --- a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts +++ b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts @@ -2,14 +2,23 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { CourseFileIdsResponse } from '../controller/dto'; import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; +import { CourseExportBodyResponse } from '../controller/dto/course-export-body.response'; +import { CourseCommonCartridgeMetadataDto } from '../common-cartridge-client/course-client'; @Injectable() export class CommonCartridgeUc { constructor(private readonly exportService: CommonCartridgeExportService) {} - public async exportCourse(courseId: EntityId): Promise { + public async exportCourse(courseId: EntityId): Promise { const files = await this.exportService.findCourseFileRecords(courseId); - const response = new CourseFileIdsResponse(files.map((file) => file.id)); + const courseFileIds = new CourseFileIdsResponse(files.map((file) => file.id)); + const courseCommonCartridgeMetadata: CourseCommonCartridgeMetadataDto = + await this.exportService.findCourseCommonCartridgeMetadata(courseId); + + const response = new CourseExportBodyResponse({ + courseFileIds, + courseCommonCartridgeMetadata, + }); return response; } diff --git a/scripts/filter_spec-apis.js b/scripts/filter_spec-apis.js new file mode 100644 index 00000000000..290bfc84cac --- /dev/null +++ b/scripts/filter_spec-apis.js @@ -0,0 +1,108 @@ +const fs = require('fs'); +const axios = require('axios'); +const pathHelper = require('path'); + +// Get the path prefix from command line arguments +const pathPrefix = process.argv[2]; +const outputPath = pathHelper.resolve(__dirname, 'filtered-spec.json'); + +// URL to fetch the Swagger JSON +const swaggerUrl = 'http://localhost:3030/api/v3/docs-json'; + +if (!pathPrefix) { + console.error('Please provide a controller name to filter as a command line argument.'); + process.exit(1); +} + +// Recursively search for an element with the provided name in the source object +function getElementWithName(name, src) { + const results = []; + + Object.keys(src).forEach((key) => { + const element = src[key]; + + if (key === name) { + results.push(element); + return; + } + + if (typeof element === 'object') { + results.push(...getElementWithName(name, element)); + } + }); + + return results; +} + +// Get all referenced schemas from the provided spec +function getReferencedSchemas(spec) { + return getElementWithName('$ref', spec).map((ref) => ref.split('/').pop()); +} + +// Expand the set of schemas by adding all referenced schemas +function expandSchemaSet(schemas, spec) { + while (true) { + const oldSize = schemas.size; + + schemas.forEach((schema) => { + if (spec.components.schemas[schema]) { + getReferencedSchemas(spec.components.schemas[schema]).forEach((ref) => schemas.add(ref)); + } + }); + + const newSize = schemas.size; + + if (oldSize === newSize) { + break; + } + } +} + +axios + .get(swaggerUrl) + // eslint-disable-next-line promise/always-return + .then((response) => { + const spec = response.data; + // Filter paths that start with the provided prefix + const filteredPaths = Object.keys(spec.paths) + .filter((path) => path.startsWith(pathPrefix)) + .reduce((obj, key) => { + obj[key] = spec.paths[key]; + return obj; + }, {}); + + // Get referenced schemas from the filtered paths + const schemas = new Set(); + Object.keys(filteredPaths).forEach((path) => { + getReferencedSchemas(filteredPaths[path]).forEach((schema) => schemas.add(schema)); + }); + + // Expand the set of schemas to include all referenced schemas from other schemas + expandSchemaSet(schemas, spec); + + // Filter schemas + const filteredSchemas = Object.keys(spec.components.schemas) + .filter((schema) => schemas.has(schema)) + .reduce((obj, key) => { + obj[key] = spec.components.schemas[key]; + return obj; + }, {}); + + // Create top-level fields from the original spec + const filteredSwaggerDoc = { + openapi: spec.openapi, + info: spec.info, + servers: spec.servers, + paths: filteredPaths, + components: { + securitySchemes: spec.components.securitySchemes, + schemas: filteredSchemas, + }, + }; + + fs.writeFileSync(outputPath, JSON.stringify(filteredSwaggerDoc, null, 2)); + console.log(`Filtered spec of ${pathPrefix} written to ${outputPath}`); + }) + .catch((error) => { + console.error(`Error fetching the OpenAPI spec: ${error}`); + }); diff --git a/sonar-project.properties b/sonar-project.properties index b7be9f713c3..bba6c7015e2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,8 +3,8 @@ sonar.projectKey=hpi-schul-cloud_schulcloud-server sonar.sources=. sonar.tests=. sonar.test.inclusions=**/*.spec.ts -sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts -sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts +sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts +sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts sonar.cpd.exclusions=**/controller/dto/*.ts sonar.javascript.lcov.reportPaths=merged-lcov.info sonar.typescript.tsconfigPaths=tsconfig.json,src/apps/server/tsconfig.app.json From 9e5c5e5f6bdf733fec037a4445255290d6d6d532 Mon Sep 17 00:00:00 2001 From: mkreuzkam-cap <144103168+mkreuzkam-cap@users.noreply.github.com> Date: Mon, 2 Sep 2024 12:08:56 +0200 Subject: [PATCH 10/29] EW-811: Decouple board module dependency (#5205) Co-authored-by: Firas Shmit Co-authored-by: Simone Radtke Co-authored-by: Simone Radtke <94017602+SimoneRadtke-Cap@users.noreply.github.com> --- .../board-client/board-api-client/.gitignore | 4 + .../board-client/board-api-client/.npmignore | 1 + .../.openapi-generator-ignore | 23 + .../board-api-client/.openapi-generator/FILES | 24 + .../board-client/board-api-client/api.ts | 18 + .../board-api-client/api/board-api.ts | 772 ++++++++++++++++++ .../board-client/board-api-client/base.ts | 86 ++ .../board-client/board-api-client/common.ts | 150 ++++ .../board-api-client/configuration.ts | 110 +++ .../board-client/board-api-client/git_push.sh | 57 ++ .../board-client/board-api-client/index.ts | 18 + .../models/api-validation-error.ts | 54 ++ .../models/board-context-response.ts | 41 + .../models/board-external-reference-type.ts | 31 + .../board-api-client/models/board-layout.ts | 32 + .../models/board-parent-type.ts | 31 + .../board-api-client/models/board-response.ts | 66 ++ .../models/card-skeleton-response.ts | 36 + .../models/column-response.ts | 54 ++ .../models/copy-api-response.ts | 114 +++ .../models/create-board-body-params.ts | 56 ++ .../models/create-board-response.ts | 30 + .../board-api-client/models/index.ts | 14 + .../models/timestamps-response.ts | 42 + .../models/update-board-title-params.ts | 30 + .../models/visibility-body-params.ts | 30 + .../board-client/board-client.adapter.spec.ts | 103 +++ .../board-client/board-client.adapter.ts | 41 + .../board-client/board-client.config.ts | 5 + .../board-client/board-client.module.spec.ts | 28 + .../board-client/board-client.module.ts | 26 + .../board-client/dto/board-skeleton.dto.ts | 21 + .../board-client/dto/card-skeleton.dto.ts | 10 + .../board-client/dto/column-skeleton.dto.ts | 15 + .../board-client/dto/index.ts | 3 + .../board-client/index.ts | 4 + .../board-skeleton-response.mapper.spec.ts | 65 ++ .../mapper/board-skeleton-response.mapper.ts | 29 + .../board-client/mapper/index.ts | 1 + .../common-cartridge.module.ts | 8 +- .../common-cartridge-export.service.spec.ts | 5 + .../common-cartridge-export.service.ts | 2 + sonar-project.properties | 4 +- 43 files changed, 2290 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.gitignore create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.npmignore create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator-ignore create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator/FILES create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api/board-api.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/base.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/common.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/configuration.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/git_push.sh create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/index.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/api-validation-error.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-context-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-external-reference-type.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-layout.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-parent-type.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/card-skeleton-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/column-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/copy-api-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-body-params.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/index.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/timestamps-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/update-board-title-params.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/visibility-body-params.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.spec.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.config.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.spec.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/board-skeleton.dto.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/card-skeleton.dto.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/column-skeleton.dto.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/index.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/index.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.spec.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/index.ts diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.gitignore b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.gitignore new file mode 100644 index 00000000000..149b5765472 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.gitignore @@ -0,0 +1,4 @@ +wwwroot/*.js +node_modules +typings +dist diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.npmignore b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.npmignore new file mode 100644 index 00000000000..999d88df693 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.npmignore @@ -0,0 +1 @@ +# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm \ No newline at end of file diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator-ignore b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator-ignore new file mode 100644 index 00000000000..7484ee590a3 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator/FILES b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator/FILES new file mode 100644 index 00000000000..dcd8a9a93e4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/.openapi-generator/FILES @@ -0,0 +1,24 @@ +.gitignore +.npmignore +api.ts +api/board-api.ts +base.ts +common.ts +configuration.ts +git_push.sh +index.ts +models/api-validation-error.ts +models/board-context-response.ts +models/board-external-reference-type.ts +models/board-layout.ts +models/board-parent-type.ts +models/board-response.ts +models/card-skeleton-response.ts +models/column-response.ts +models/copy-api-response.ts +models/create-board-body-params.ts +models/create-board-response.ts +models/index.ts +models/timestamps-response.ts +models/update-board-title-params.ts +models/visibility-body-params.ts diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api.ts new file mode 100644 index 00000000000..3c267401ba8 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 * from './api/board-api'; + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api/board-api.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api/board-api.ts new file mode 100644 index 00000000000..72900986b52 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/api/board-api.ts @@ -0,0 +1,772 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { ApiValidationError } from '../models'; +// @ts-ignore +import type { BoardContextResponse } from '../models'; +// @ts-ignore +import type { BoardResponse } from '../models'; +// @ts-ignore +import type { ColumnResponse } from '../models'; +// @ts-ignore +import type { CopyApiResponse } from '../models'; +// @ts-ignore +import type { CreateBoardBodyParams } from '../models'; +// @ts-ignore +import type { CreateBoardResponse } from '../models'; +// @ts-ignore +import type { UpdateBoardTitleParams } from '../models'; +// @ts-ignore +import type { VisibilityBodyParams } from '../models'; +/** + * BoardApi - axios parameter creator + * @export + */ +export const BoardApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Create a board copy. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerCopyBoard: async (boardId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boardId' is not null or undefined + assertParamExists('boardControllerCopyBoard', 'boardId', boardId) + const localVarPath = `/boards/{boardId}/copy` + .replace(`{${"boardId"}}`, encodeURIComponent(String(boardId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Create a new board. + * @param {CreateBoardBodyParams} createBoardBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerCreateBoard: async (createBoardBodyParams: CreateBoardBodyParams, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'createBoardBodyParams' is not null or undefined + assertParamExists('boardControllerCreateBoard', 'createBoardBodyParams', createBoardBodyParams) + const localVarPath = `/boards`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createBoardBodyParams, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Create a new column on a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerCreateColumn: async (boardId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boardId' is not null or undefined + assertParamExists('boardControllerCreateColumn', 'boardId', boardId) + const localVarPath = `/boards/{boardId}/columns` + .replace(`{${"boardId"}}`, encodeURIComponent(String(boardId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Delete a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerDeleteBoard: async (boardId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boardId' is not null or undefined + assertParamExists('boardControllerDeleteBoard', 'boardId', boardId) + const localVarPath = `/boards/{boardId}` + .replace(`{${"boardId"}}`, encodeURIComponent(String(boardId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get the context of a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerGetBoardContext: async (boardId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boardId' is not null or undefined + assertParamExists('boardControllerGetBoardContext', 'boardId', boardId) + const localVarPath = `/boards/{boardId}/context` + .replace(`{${"boardId"}}`, encodeURIComponent(String(boardId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get the skeleton of a a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerGetBoardSkeleton: async (boardId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boardId' is not null or undefined + assertParamExists('boardControllerGetBoardSkeleton', 'boardId', boardId) + const localVarPath = `/boards/{boardId}` + .replace(`{${"boardId"}}`, encodeURIComponent(String(boardId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Update the title of a board. + * @param {string} boardId The id of the board. + * @param {UpdateBoardTitleParams} updateBoardTitleParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerUpdateBoardTitle: async (boardId: string, updateBoardTitleParams: UpdateBoardTitleParams, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boardId' is not null or undefined + assertParamExists('boardControllerUpdateBoardTitle', 'boardId', boardId) + // verify required parameter 'updateBoardTitleParams' is not null or undefined + assertParamExists('boardControllerUpdateBoardTitle', 'updateBoardTitleParams', updateBoardTitleParams) + const localVarPath = `/boards/{boardId}/title` + .replace(`{${"boardId"}}`, encodeURIComponent(String(boardId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateBoardTitleParams, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Update the visibility of a board. + * @param {string} boardId The id of the board. + * @param {VisibilityBodyParams} visibilityBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerUpdateVisibility: async (boardId: string, visibilityBodyParams: VisibilityBodyParams, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'boardId' is not null or undefined + assertParamExists('boardControllerUpdateVisibility', 'boardId', boardId) + // verify required parameter 'visibilityBodyParams' is not null or undefined + assertParamExists('boardControllerUpdateVisibility', 'visibilityBodyParams', visibilityBodyParams) + const localVarPath = `/boards/{boardId}/visibility` + .replace(`{${"boardId"}}`, encodeURIComponent(String(boardId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(visibilityBodyParams, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * BoardApi - functional programming interface + * @export + */ +export const BoardApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = BoardApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Create a board copy. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerCopyBoard(boardId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerCopyBoard(boardId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerCopyBoard']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Create a new board. + * @param {CreateBoardBodyParams} createBoardBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerCreateBoard(createBoardBodyParams: CreateBoardBodyParams, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerCreateBoard(createBoardBodyParams, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerCreateBoard']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Create a new column on a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerCreateColumn(boardId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerCreateColumn(boardId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerCreateColumn']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Delete a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerDeleteBoard(boardId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerDeleteBoard(boardId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerDeleteBoard']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get the context of a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerGetBoardContext(boardId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerGetBoardContext(boardId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerGetBoardContext']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get the skeleton of a a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerGetBoardSkeleton(boardId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerGetBoardSkeleton(boardId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerGetBoardSkeleton']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Update the title of a board. + * @param {string} boardId The id of the board. + * @param {UpdateBoardTitleParams} updateBoardTitleParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerUpdateBoardTitle(boardId: string, updateBoardTitleParams: UpdateBoardTitleParams, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerUpdateBoardTitle(boardId, updateBoardTitleParams, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerUpdateBoardTitle']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Update the visibility of a board. + * @param {string} boardId The id of the board. + * @param {VisibilityBodyParams} visibilityBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async boardControllerUpdateVisibility(boardId: string, visibilityBodyParams: VisibilityBodyParams, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.boardControllerUpdateVisibility(boardId, visibilityBodyParams, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['BoardApi.boardControllerUpdateVisibility']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * BoardApi - factory interface + * @export + */ +export const BoardApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = BoardApiFp(configuration) + return { + /** + * + * @summary Create a board copy. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerCopyBoard(boardId: string, options?: any): AxiosPromise { + return localVarFp.boardControllerCopyBoard(boardId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Create a new board. + * @param {CreateBoardBodyParams} createBoardBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerCreateBoard(createBoardBodyParams: CreateBoardBodyParams, options?: any): AxiosPromise { + return localVarFp.boardControllerCreateBoard(createBoardBodyParams, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Create a new column on a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerCreateColumn(boardId: string, options?: any): AxiosPromise { + return localVarFp.boardControllerCreateColumn(boardId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Delete a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerDeleteBoard(boardId: string, options?: any): AxiosPromise { + return localVarFp.boardControllerDeleteBoard(boardId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get the context of a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerGetBoardContext(boardId: string, options?: any): AxiosPromise { + return localVarFp.boardControllerGetBoardContext(boardId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get the skeleton of a a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerGetBoardSkeleton(boardId: string, options?: any): AxiosPromise { + return localVarFp.boardControllerGetBoardSkeleton(boardId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Update the title of a board. + * @param {string} boardId The id of the board. + * @param {UpdateBoardTitleParams} updateBoardTitleParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerUpdateBoardTitle(boardId: string, updateBoardTitleParams: UpdateBoardTitleParams, options?: any): AxiosPromise { + return localVarFp.boardControllerUpdateBoardTitle(boardId, updateBoardTitleParams, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Update the visibility of a board. + * @param {string} boardId The id of the board. + * @param {VisibilityBodyParams} visibilityBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + boardControllerUpdateVisibility(boardId: string, visibilityBodyParams: VisibilityBodyParams, options?: any): AxiosPromise { + return localVarFp.boardControllerUpdateVisibility(boardId, visibilityBodyParams, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * BoardApi - interface + * @export + * @interface BoardApi + */ +export interface BoardApiInterface { + /** + * + * @summary Create a board copy. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerCopyBoard(boardId: string, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Create a new board. + * @param {CreateBoardBodyParams} createBoardBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerCreateBoard(createBoardBodyParams: CreateBoardBodyParams, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Create a new column on a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerCreateColumn(boardId: string, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Delete a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerDeleteBoard(boardId: string, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Get the context of a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerGetBoardContext(boardId: string, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Get the skeleton of a a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerGetBoardSkeleton(boardId: string, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Update the title of a board. + * @param {string} boardId The id of the board. + * @param {UpdateBoardTitleParams} updateBoardTitleParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerUpdateBoardTitle(boardId: string, updateBoardTitleParams: UpdateBoardTitleParams, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * + * @summary Update the visibility of a board. + * @param {string} boardId The id of the board. + * @param {VisibilityBodyParams} visibilityBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApiInterface + */ + boardControllerUpdateVisibility(boardId: string, visibilityBodyParams: VisibilityBodyParams, options?: RawAxiosRequestConfig): AxiosPromise; + +} + +/** + * BoardApi - object-oriented interface + * @export + * @class BoardApi + * @extends {BaseAPI} + */ +export class BoardApi extends BaseAPI implements BoardApiInterface { + /** + * + * @summary Create a board copy. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerCopyBoard(boardId: string, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerCopyBoard(boardId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Create a new board. + * @param {CreateBoardBodyParams} createBoardBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerCreateBoard(createBoardBodyParams: CreateBoardBodyParams, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerCreateBoard(createBoardBodyParams, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Create a new column on a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerCreateColumn(boardId: string, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerCreateColumn(boardId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Delete a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerDeleteBoard(boardId: string, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerDeleteBoard(boardId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get the context of a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerGetBoardContext(boardId: string, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerGetBoardContext(boardId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get the skeleton of a a board. + * @param {string} boardId The id of the board. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerGetBoardSkeleton(boardId: string, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerGetBoardSkeleton(boardId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Update the title of a board. + * @param {string} boardId The id of the board. + * @param {UpdateBoardTitleParams} updateBoardTitleParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerUpdateBoardTitle(boardId: string, updateBoardTitleParams: UpdateBoardTitleParams, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerUpdateBoardTitle(boardId, updateBoardTitleParams, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Update the visibility of a board. + * @param {string} boardId The id of the board. + * @param {VisibilityBodyParams} visibilityBodyParams + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BoardApi + */ + public boardControllerUpdateVisibility(boardId: string, visibilityBodyParams: VisibilityBodyParams, options?: RawAxiosRequestConfig) { + return BoardApiFp(this.configuration).boardControllerUpdateVisibility(boardId, visibilityBodyParams, options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/base.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/base.ts new file mode 100644 index 00000000000..82686c7b81b --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/base.ts @@ -0,0 +1,86 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +import type { Configuration } from './configuration'; +// Some imports not used depending on template conditions +// @ts-ignore +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; + +export const BASE_PATH = "http://localhost/api/v3".replace(/\/+$/, ""); + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string; + options: RawAxiosRequestConfig; +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined; + + constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { + if (configuration) { + this.configuration = configuration; + this.basePath = configuration.basePath ?? basePath; + } + } +}; + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + constructor(public field: string, msg?: string) { + super(msg); + this.name = "RequiredError" + } +} + +interface ServerMap { + [key: string]: { + url: string, + description: string, + }[]; +} + +/** + * + * @export + */ +export const operationServerMap: ServerMap = { +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/common.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/common.ts new file mode 100644 index 00000000000..6c119efb60d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/common.ts @@ -0,0 +1,150 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +import type { Configuration } from "./configuration"; +import type { RequestArgs } from "./base"; +import type { AxiosInstance, AxiosResponse } from 'axios'; +import { RequiredError } from "./base"; + +/** + * + * @export + */ +export const DUMMY_BASE_URL = 'https://example.com' + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey; + object[keyParamName] = localVarApiKeyValue; + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { username: configuration.username, password: configuration.password }; + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const accessToken = typeof configuration.accessToken === 'function' + ? await configuration.accessToken() + : await configuration.accessToken; + object["Authorization"] = "Bearer " + accessToken; + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = typeof configuration.accessToken === 'function' + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken; + object["Authorization"] = "Bearer " + localVarAccessTokenValue; + } +} + +function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void { + if (parameter == null) return; + if (typeof parameter === "object") { + if (Array.isArray(parameter)) { + (parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key)); + } + else { + Object.keys(parameter).forEach(currentKey => + setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`) + ); + } + } + else { + if (urlSearchParams.has(key)) { + urlSearchParams.append(key, parameter); + } + else { + urlSearchParams.set(key, parameter); + } + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search); + setFlattenedQueryParams(searchParams, objects); + url.search = searchParams.toString(); +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { + const nonString = typeof value !== 'string'; + const needsSerialization = nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers['Content-Type']) + : nonString; + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : (value || ""); +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { + return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { + const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url}; + return axios.request(axiosRequestArgs); + }; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/configuration.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/configuration.ts new file mode 100644 index 00000000000..8c97d307cf4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/configuration.ts @@ -0,0 +1,110 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 ConfigurationParameters { + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + username?: string; + password?: string; + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + basePath?: string; + serverIndex?: number; + baseOptions?: any; + formDataCtor?: new () => any; +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string; + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string; + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string; + /** + * override server index + * + * @type {number} + * @memberof Configuration + */ + serverIndex?: number; + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any; + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any; + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey; + this.username = param.username; + this.password = param.password; + this.accessToken = param.accessToken; + this.basePath = param.basePath; + this.serverIndex = param.serverIndex; + this.baseOptions = param.baseOptions; + this.formDataCtor = param.formDataCtor; + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); + return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/git_push.sh b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/git_push.sh new file mode 100644 index 00000000000..f53a75d4fab --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/git_push.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ +# +# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" + +git_user_id=$1 +git_repo_id=$2 +release_note=$3 +git_host=$4 + +if [ "$git_host" = "" ]; then + git_host="github.com" + echo "[INFO] No command line input provided. Set \$git_host to $git_host" +fi + +if [ "$git_user_id" = "" ]; then + git_user_id="GIT_USER_ID" + echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" +fi + +if [ "$git_repo_id" = "" ]; then + git_repo_id="GIT_REPO_ID" + echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" +fi + +if [ "$release_note" = "" ]; then + release_note="Minor update" + echo "[INFO] No command line input provided. Set \$release_note to $release_note" +fi + +# Initialize the local directory as a Git repository +git init + +# Adds the files in the local repository and stages them for commit. +git add . + +# Commits the tracked changes and prepares them to be pushed to a remote repository. +git commit -m "$release_note" + +# Sets the new remote +git_remote=$(git remote) +if [ "$git_remote" = "" ]; then # git remote not defined + + if [ "$GIT_TOKEN" = "" ]; then + echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." + git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git + else + git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git + fi + +fi + +git pull origin master + +# Pushes (Forces) the changes in the local repository up to the remote repository +echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" +git push origin master 2>&1 | grep -v 'To https' diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/index.ts new file mode 100644 index 00000000000..8b762df664e --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/index.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 * from "./api"; +export * from "./configuration"; +export * from "./models"; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/api-validation-error.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/api-validation-error.ts new file mode 100644 index 00000000000..3f49cf86f0d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/api-validation-error.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 ApiValidationError + */ +export interface ApiValidationError { + /** + * The response status code. + * @type {number} + * @memberof ApiValidationError + */ + 'code': number; + /** + * The error type. + * @type {string} + * @memberof ApiValidationError + */ + 'type': string; + /** + * The error title. + * @type {string} + * @memberof ApiValidationError + */ + 'title': string; + /** + * The error message. + * @type {string} + * @memberof ApiValidationError + */ + 'message': string; + /** + * The error details. + * @type {object} + * @memberof ApiValidationError + */ + 'details'?: object; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-context-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-context-response.ts new file mode 100644 index 00000000000..e2c9b1b0a19 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-context-response.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { BoardExternalReferenceType } from './board-external-reference-type'; + +/** + * + * @export + * @interface BoardContextResponse + */ +export interface BoardContextResponse { + /** + * + * @type {string} + * @memberof BoardContextResponse + */ + 'id': string; + /** + * + * @type {BoardExternalReferenceType} + * @memberof BoardContextResponse + */ + 'type': BoardExternalReferenceType; +} + + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-external-reference-type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-external-reference-type.ts new file mode 100644 index 00000000000..5510db52c19 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-external-reference-type.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 + * @enum {string} + */ + +export const BoardExternalReferenceType = { + COURSE: 'course', + USER: 'user' +} as const; + +export type BoardExternalReferenceType = typeof BoardExternalReferenceType[keyof typeof BoardExternalReferenceType]; + + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-layout.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-layout.ts new file mode 100644 index 00000000000..ea8a7890671 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-layout.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 + * @enum {string} + */ + +export const BoardLayout = { + COLUMNS: 'columns', + LIST: 'list', + GRID: 'grid' +} as const; + +export type BoardLayout = typeof BoardLayout[keyof typeof BoardLayout]; + + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-parent-type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-parent-type.ts new file mode 100644 index 00000000000..3c8d64356bc --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-parent-type.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 + * @enum {string} + */ + +export const BoardParentType = { + COURSE: 'course', + USER: 'user' +} as const; + +export type BoardParentType = typeof BoardParentType[keyof typeof BoardParentType]; + + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-response.ts new file mode 100644 index 00000000000..032a6c00330 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/board-response.ts @@ -0,0 +1,66 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ColumnResponse } from './column-response'; +// May contain unused imports in some cases +// @ts-ignore +import type { TimestampsResponse } from './timestamps-response'; + +/** + * + * @export + * @interface BoardResponse + */ +export interface BoardResponse { + /** + * + * @type {string} + * @memberof BoardResponse + */ + 'id': string; + /** + * + * @type {string} + * @memberof BoardResponse + */ + 'title': string; + /** + * + * @type {Array} + * @memberof BoardResponse + */ + 'columns': Array; + /** + * + * @type {TimestampsResponse} + * @memberof BoardResponse + */ + 'timestamps': TimestampsResponse; + /** + * + * @type {boolean} + * @memberof BoardResponse + */ + 'isVisible': boolean; + /** + * + * @type {string} + * @memberof BoardResponse + */ + 'layout': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/card-skeleton-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/card-skeleton-response.ts new file mode 100644 index 00000000000..8ffe57e4eda --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/card-skeleton-response.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 CardSkeletonResponse + */ +export interface CardSkeletonResponse { + /** + * + * @type {string} + * @memberof CardSkeletonResponse + */ + 'cardId': string; + /** + * The approximate height of the referenced card. Intended to be used for prerendering purposes. Note, that different devices can lead to this value not being precise + * @type {number} + * @memberof CardSkeletonResponse + */ + 'height': number; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/column-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/column-response.ts new file mode 100644 index 00000000000..1f87a974785 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/column-response.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { CardSkeletonResponse } from './card-skeleton-response'; +// May contain unused imports in some cases +// @ts-ignore +import type { TimestampsResponse } from './timestamps-response'; + +/** + * + * @export + * @interface ColumnResponse + */ +export interface ColumnResponse { + /** + * + * @type {string} + * @memberof ColumnResponse + */ + 'id': string; + /** + * + * @type {string} + * @memberof ColumnResponse + */ + 'title': string; + /** + * + * @type {Array} + * @memberof ColumnResponse + */ + 'cards': Array; + /** + * + * @type {TimestampsResponse} + * @memberof ColumnResponse + */ + 'timestamps': TimestampsResponse; +} + 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 new file mode 100644 index 00000000000..4921f0d6484 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/copy-api-response.ts @@ -0,0 +1,114 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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; +} + +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' +} 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' +} as const; + +export type CopyApiResponseStatus = typeof CopyApiResponseStatus[keyof typeof CopyApiResponseStatus]; + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-body-params.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-body-params.ts new file mode 100644 index 00000000000..aeff2cb8769 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-body-params.ts @@ -0,0 +1,56 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { BoardLayout } from './board-layout'; +// May contain unused imports in some cases +// @ts-ignore +import type { BoardParentType } from './board-parent-type'; + +/** + * + * @export + * @interface CreateBoardBodyParams + */ +export interface CreateBoardBodyParams { + /** + * The title of the board + * @type {string} + * @memberof CreateBoardBodyParams + */ + 'title': string; + /** + * The id of the parent + * @type {string} + * @memberof CreateBoardBodyParams + */ + 'parentId': string; + /** + * + * @type {BoardParentType} + * @memberof CreateBoardBodyParams + */ + 'parentType': BoardParentType; + /** + * + * @type {BoardLayout} + * @memberof CreateBoardBodyParams + */ + 'layout': BoardLayout; +} + + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-response.ts new file mode 100644 index 00000000000..5ae6b3d2a7a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/create-board-response.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 CreateBoardResponse + */ +export interface CreateBoardResponse { + /** + * + * @type {string} + * @memberof CreateBoardResponse + */ + 'id': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/index.ts new file mode 100644 index 00000000000..32513649aeb --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/index.ts @@ -0,0 +1,14 @@ +export * from './api-validation-error'; +export * from './board-context-response'; +export * from './board-external-reference-type'; +export * from './board-layout'; +export * from './board-parent-type'; +export * from './board-response'; +export * from './card-skeleton-response'; +export * from './column-response'; +export * from './copy-api-response'; +export * from './create-board-body-params'; +export * from './create-board-response'; +export * from './timestamps-response'; +export * from './update-board-title-params'; +export * from './visibility-body-params'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/timestamps-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/timestamps-response.ts new file mode 100644 index 00000000000..2da93bfa411 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/timestamps-response.ts @@ -0,0 +1,42 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 TimestampsResponse + */ +export interface TimestampsResponse { + /** + * + * @type {string} + * @memberof TimestampsResponse + */ + 'lastUpdatedAt': string; + /** + * + * @type {string} + * @memberof TimestampsResponse + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof TimestampsResponse + */ + 'deletedAt'?: string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/update-board-title-params.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/update-board-title-params.ts new file mode 100644 index 00000000000..d5d90955c3e --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/update-board-title-params.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 UpdateBoardTitleParams + */ +export interface UpdateBoardTitleParams { + /** + * + * @type {string} + * @memberof UpdateBoardTitleParams + */ + 'title': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/visibility-body-params.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/visibility-body-params.ts new file mode 100644 index 00000000000..46cf8306234 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-api-client/models/visibility-body-params.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * 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 VisibilityBodyParams + */ +export interface VisibilityBodyParams { + /** + * + * @type {boolean} + * @memberof VisibilityBodyParams + */ + 'isVisible': boolean; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.spec.ts new file mode 100644 index 00000000000..8dacf504e49 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.spec.ts @@ -0,0 +1,103 @@ +import { faker } from '@faker-js/faker'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { UnauthorizedException } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosResponse } from 'axios'; +import { Request } from 'express'; +import { BoardApi, BoardResponse } from './board-api-client'; +import { BoardClientAdapter } from './board-client.adapter'; + +const jwtToken = 'someJwtToken'; + +describe(BoardClientAdapter.name, () => { + let module: TestingModule; + let sut: BoardClientAdapter; + let boardApiMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BoardClientAdapter, + { + provide: BoardApi, + useValue: createMock(), + }, + { + provide: REQUEST, + useValue: createMock({ + headers: { + authorization: `Bearer ${jwtToken}`, + }, + }), + }, + ], + }).compile(); + + sut = module.get(BoardClientAdapter); + boardApiMock = module.get(BoardApi); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('getBoardSkeletonById', () => { + describe('When getBoardSkeletonById is called', () => { + const setup = () => { + const response = createMock>({ + data: { + id: faker.string.uuid(), + title: faker.lorem.sentence(), + columns: [], + isVisible: true, + layout: 'layout', + timestamps: { + createdAt: faker.date.past().toString(), + lastUpdatedAt: faker.date.recent().toString(), + }, + }, + }); + + boardApiMock.boardControllerGetBoardSkeleton.mockResolvedValue(response); + + return { boardId: response.data.id }; + }; + + it('it should return a board skeleton dto', async () => { + const { boardId } = setup(); + + await sut.getBoardSkeletonById(boardId); + + expect(boardApiMock.boardControllerGetBoardSkeleton).toHaveBeenCalled(); + }); + }); + + describe('When no JWT token is found', () => { + const setup = () => { + const boardId = faker.string.uuid(); + const request = createMock({ + headers: {}, + }); + + const adapter: BoardClientAdapter = new BoardClientAdapter(boardApiMock, request); + + return { boardId, adapter }; + }; + + it('should throw an UnauthorizedError', async () => { + const { boardId, adapter } = setup(); + + await expect(adapter.getBoardSkeletonById(boardId)).rejects.toThrowError(UnauthorizedException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.ts new file mode 100644 index 00000000000..02c8d09e746 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.adapter.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { extractJwtFromHeader } from '@shared/common'; +import { RawAxiosRequestConfig } from 'axios'; +import { Request } from 'express'; +import { BoardApi } from './board-api-client'; +import { BoardSkeletonDtoMapper } from './mapper'; +import { BoardSkeletonDto } from './dto'; + +@Injectable() +export class BoardClientAdapter { + constructor(private readonly boardApi: BoardApi, @Inject(REQUEST) private request: Request) {} + + public async getBoardSkeletonById(boardId: string): Promise { + const options = this.createOptionParams(); + const boardResponse = await this.boardApi + .boardControllerGetBoardSkeleton(boardId, options) + .then((response) => response.data); + + const boardSkeletonDto = BoardSkeletonDtoMapper.mapToBoardSkeletonDto(boardResponse); + + return boardSkeletonDto; + } + + private createOptionParams(): RawAxiosRequestConfig { + const jwt = this.getJwt(); + const options: RawAxiosRequestConfig = { headers: { authorization: `Bearer ${jwt}` } }; + + return options; + } + + private getJwt(): string { + const jwt = extractJwtFromHeader(this.request) ?? this.request.headers.authorization; + + if (!jwt) { + throw new UnauthorizedException('No JWT found in request'); + } + + return jwt; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.config.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.config.ts new file mode 100644 index 00000000000..ba6d1e4ad52 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.config.ts @@ -0,0 +1,5 @@ +import { ConfigurationParameters } from './board-api-client'; + +export interface BoardClientConfig extends ConfigurationParameters { + basePath: string; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.spec.ts new file mode 100644 index 00000000000..b46d574e0f8 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardClientModule } from './board-client.module'; +import { BoardClientAdapter } from './board-client.adapter'; + +describe('BoardClientModule', () => { + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + BoardClientModule.register({ + basePath: 'http://localhost:3030/api/v3', + }), + ], + }).compile(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('when module is initialized', () => { + it('should have the BoardClientAdapter defined', () => { + const boardClientAdapter = module.get(BoardClientAdapter); + expect(boardClientAdapter).toBeDefined(); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.ts new file mode 100644 index 00000000000..3ad0125843d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/board-client.module.ts @@ -0,0 +1,26 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { BoardClientAdapter } from './board-client.adapter'; +import { BoardApi, Configuration } from './board-api-client'; +import { BoardClientConfig } from './board-client.config'; + +@Module({}) +export class BoardClientModule { + static register(config: BoardClientConfig): DynamicModule { + const providers = [ + BoardClientAdapter, + { + provide: BoardApi, + useFactory: () => { + const configuration = new Configuration(config); + return new BoardApi(configuration); + }, + }, + ]; + + return { + module: BoardClientModule, + providers, + exports: [BoardClientAdapter], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/board-skeleton.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/board-skeleton.dto.ts new file mode 100644 index 00000000000..144ac8e4786 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/board-skeleton.dto.ts @@ -0,0 +1,21 @@ +import { ColumnSkeletonDto } from './column-skeleton.dto'; + +export class BoardSkeletonDto { + boardId: string; + + title: string; + + columns: ColumnSkeletonDto[]; + + isVisible: boolean; + + layout: string; + + constructor(props: BoardSkeletonDto) { + this.boardId = props.boardId; + this.title = props.title; + this.columns = props.columns; + this.isVisible = props.isVisible; + this.layout = props.layout; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/card-skeleton.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/card-skeleton.dto.ts new file mode 100644 index 00000000000..fe50f01386f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/card-skeleton.dto.ts @@ -0,0 +1,10 @@ +export class CardSkeletonDto { + cardId: string; + + height: number; + + constructor(props: CardSkeletonDto) { + this.cardId = props.cardId; + this.height = props.height; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/column-skeleton.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/column-skeleton.dto.ts new file mode 100644 index 00000000000..1094edefbc4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/column-skeleton.dto.ts @@ -0,0 +1,15 @@ +import { CardSkeletonDto } from './card-skeleton.dto'; + +export class ColumnSkeletonDto { + columnId: string; + + title: string; + + cards: CardSkeletonDto[]; + + constructor(props: ColumnSkeletonDto) { + this.columnId = props.columnId; + this.title = props.title; + this.cards = props.cards; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/index.ts new file mode 100644 index 00000000000..58a91c50d68 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/dto/index.ts @@ -0,0 +1,3 @@ +export { BoardSkeletonDto } from './board-skeleton.dto'; +export { ColumnSkeletonDto } from './column-skeleton.dto'; +export { CardSkeletonDto } from './card-skeleton.dto'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/index.ts new file mode 100644 index 00000000000..4347b129920 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/index.ts @@ -0,0 +1,4 @@ +export { BoardClientModule } from './board-client.module'; +export { BoardClientConfig } from './board-client.config'; +export { BoardClientAdapter } from './board-client.adapter'; +export * from './dto'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.spec.ts new file mode 100644 index 00000000000..ee1d26fa583 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.spec.ts @@ -0,0 +1,65 @@ +import { faker } from '@faker-js/faker'; +import { BoardResponse, CardSkeletonResponse, ColumnResponse } from '../board-api-client'; +import { BoardSkeletonDtoMapper } from './board-skeleton-response.mapper'; + +describe('BoardSkeletonDtoMapper', () => { + describe('mapToBoardSkeletonDto', () => { + describe('when mapping to BoardResponse', () => { + const setup = () => { + const cardResponse: CardSkeletonResponse = { + cardId: faker.string.uuid(), + height: faker.number.int(), + }; + + const columnResponse: ColumnResponse = { + id: faker.string.uuid(), + title: faker.lorem.sentence(), + cards: [cardResponse], + timestamps: { + createdAt: faker.date.past().toString(), + lastUpdatedAt: faker.date.recent().toString(), + }, + }; + + const boardResponse: BoardResponse = { + id: faker.string.uuid(), + title: faker.lorem.sentence(), + columns: [columnResponse], + isVisible: true, + layout: 'layout', + timestamps: { + createdAt: faker.date.past().toString(), + lastUpdatedAt: faker.date.recent().toString(), + }, + }; + + return { boardResponse }; + }; + it('should return BoardSkeletonDto', () => { + const { boardResponse } = setup(); + + const result = BoardSkeletonDtoMapper.mapToBoardSkeletonDto(boardResponse); + + expect(result).toEqual({ + // AI next 16 lines + boardId: boardResponse.id, + title: boardResponse.title, + isVisible: boardResponse.isVisible, + layout: boardResponse.layout, + columns: [ + { + columnId: boardResponse.columns[0].id, + title: boardResponse.columns[0].title, + cards: [ + { + cardId: boardResponse.columns[0].cards[0].cardId, + height: boardResponse.columns[0].cards[0].height, + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.ts new file mode 100644 index 00000000000..15e12d3b2a3 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/board-skeleton-response.mapper.ts @@ -0,0 +1,29 @@ +import { BoardResponse, ColumnResponse, CardSkeletonResponse } from '../board-api-client'; +import { BoardSkeletonDto, ColumnSkeletonDto, CardSkeletonDto } from '../dto'; + +export class BoardSkeletonDtoMapper { + public static mapToBoardSkeletonDto(boardResponse: BoardResponse): BoardSkeletonDto { + return new BoardSkeletonDto({ + boardId: boardResponse.id, + title: boardResponse.title, + columns: boardResponse.columns.map((column) => this.mapToColumnSkeletonDto(column)), + isVisible: boardResponse.isVisible, + layout: boardResponse.layout, + }); + } + + private static mapToColumnSkeletonDto(columnResponse: ColumnResponse): ColumnSkeletonDto { + return new ColumnSkeletonDto({ + columnId: columnResponse.id, + title: columnResponse.title, + cards: columnResponse.cards.map((card) => this.mapToCardSkeletonDto(card)), + }); + } + + private static mapToCardSkeletonDto(cardResponse: CardSkeletonResponse): CardSkeletonDto { + return new CardSkeletonDto({ + cardId: cardResponse.cardId, + height: cardResponse.height, + }); + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/index.ts new file mode 100644 index 00000000000..0213105c0d1 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/board-client/mapper/index.ts @@ -0,0 +1 @@ +export { BoardSkeletonDtoMapper } from './board-skeleton-response.mapper'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge.module.ts b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts index 2148d04d9ea..85e945167ed 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge.module.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts @@ -1,14 +1,15 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { Module } from '@nestjs/common'; import { ALL_ENTITIES } from '@shared/domain/entity'; import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { RabbitMQWrapperModule } from '@src/infra/rabbitmq'; -import { Configuration } from '@hpi-schul-cloud/commons'; import { defaultMikroOrmOptions } from '../server'; +import { BoardClientModule } from './common-cartridge-client/board-client'; +import { CoursesClientModule } from './common-cartridge-client/course-client'; import { CommonCartridgeExportService } from './service/common-cartridge-export.service'; import { CommonCartridgeUc } from './uc/common-cartridge.uc'; -import { CoursesClientModule } from './common-cartridge-client/course-client'; @Module({ imports: [ @@ -25,6 +26,9 @@ import { CoursesClientModule } from './common-cartridge-client/course-client'; user: DB_USERNAME, entities: ALL_ENTITIES, }), + BoardClientModule.register({ + basePath: `${Configuration.get('API_HOST') as string}/v3/`, + }), ], providers: [CommonCartridgeUc, CommonCartridgeExportService], exports: [CommonCartridgeUc], diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts index 6ac42f83918..beb45bd60b1 100644 --- a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts @@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Test, TestingModule } from '@nestjs/testing'; +import { BoardClientAdapter } from '../common-cartridge-client/board-client'; import { CommonCartridgeExportService } from './common-cartridge-export.service'; import { CoursesClientAdapter } from '../common-cartridge-client/course-client'; @@ -19,6 +20,10 @@ describe('CommonCartridgeExportService', () => { provide: FilesStorageClientAdapterService, useValue: createMock(), }, + { + provide: BoardClientAdapter, + useValue: createMock(), + }, { provide: CoursesClientAdapter, useValue: createMock(), diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts index d4ff9b86993..fbd0e81ee8f 100644 --- a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts @@ -1,11 +1,13 @@ import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; +import { BoardClientAdapter } from '../common-cartridge-client/board-client'; import { CourseCommonCartridgeMetadataDto, CoursesClientAdapter } from '../common-cartridge-client/course-client'; @Injectable() export class CommonCartridgeExportService { constructor( private readonly filesService: FilesStorageClientAdapterService, + private readonly boardClientAdapter: BoardClientAdapter, private readonly coursesClientAdapter: CoursesClientAdapter ) {} diff --git a/sonar-project.properties b/sonar-project.properties index bba6c7015e2..476998e113b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,8 +3,8 @@ sonar.projectKey=hpi-schul-cloud_schulcloud-server sonar.sources=. sonar.tests=. sonar.test.inclusions=**/*.spec.ts -sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts -sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts +sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts +sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts sonar.cpd.exclusions=**/controller/dto/*.ts sonar.javascript.lcov.reportPaths=merged-lcov.info sonar.typescript.tsconfigPaths=tsconfig.json,src/apps/server/tsconfig.app.json From ad4eb83b5b95fb99c77c0a50b9f5e19558ac97c6 Mon Sep 17 00:00:00 2001 From: Phillip Date: Mon, 2 Sep 2024 14:18:07 +0200 Subject: [PATCH 11/29] BC-7984 cleanup scripts directory (#5218) --- scripts/e2eTest.sh | 47 ------- scripts/libraryManagement.sh | 3 - scripts/migrate-etherpads.js | 248 ----------------------------------- 3 files changed, 298 deletions(-) delete mode 100644 scripts/e2eTest.sh delete mode 100755 scripts/libraryManagement.sh delete mode 100644 scripts/migrate-etherpads.js diff --git a/scripts/e2eTest.sh b/scripts/e2eTest.sh deleted file mode 100644 index a0c65f9a91e..00000000000 --- a/scripts/e2eTest.sh +++ /dev/null @@ -1,47 +0,0 @@ -#! /bin/bash - -set -e - -# Preconditions -dockerComposeUrl=https://github.com/docker/compose/releases/download/1.27.4/docker-compose-`uname -s`-`uname -m` -echo "load $dockerComposeUrl" -sudo rm /usr/local/bin/docker-compose -curl -L $dockerComposeUrl > docker-compose -chmod +x docker-compose -sudo mv docker-compose /usr/local/bin - -# Envirements -export BRANCH_NAME=${TRAVIS_PULL_REQUEST_BRANCH:=$TRAVIS_BRANCH} - -echo "BRANCH: $BRANCH_NAME" -fileName="end-to-end-tests.travis.sh" -urlBranch="https://raw.githubusercontent.com/hpi-schul-cloud/end-to-end-tests/$BRANCH_NAME/scripts/ci/$fileName" -urlDevelop="https://raw.githubusercontent.com/hpi-schul-cloud/end-to-end-tests/develop/scripts/ci/$fileName" -urlMaster="https://raw.githubusercontent.com/hpi-schul-cloud/end-to-end-tests/master/scripts/ci/$fileName" - -# Execute -if curl --head --silent --fail $urlBranch 2> /dev/null; -then - echo "select $BRANCH_NAME" - echo "load $urlBranch" - curl -f -O -s -S "$urlBranch" -elif [[ $BRANCH_NAME = feature* ]]; -then - echo "select develop" - echo "load $urlDevelop" - curl -f -O -s -S "$urlDevelop" -else - echo "select master" - echo "load $urlMaster" - curl -f -O -s -S "$urlMaster" -fi - -echo "$MY_DOCKER_PASSWORD" | docker login -u "$DOCKER_ID" --password-stdin - -chmod 700 $fileName -echo "------------------ loaded $fileName -------------------" -cat $fileName -echo "-------------------------------------------------------" -bash $fileName - -set +e diff --git a/scripts/libraryManagement.sh b/scripts/libraryManagement.sh deleted file mode 100755 index 798c1869208..00000000000 --- a/scripts/libraryManagement.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -npm run nest:start:h5p:library-management diff --git a/scripts/migrate-etherpads.js b/scripts/migrate-etherpads.js deleted file mode 100644 index 8ab173d6bce..00000000000 --- a/scripts/migrate-etherpads.js +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env node -const arg = require('arg'); - -const appPromise = require('../src/app'); -const { Configuration } = require('@hpi-schul-cloud/commons'); -const etherpadClient = require('../src/services/etherpad/utils/EtherpadClient.js'); -const { randomBytes } = require('crypto'); -const { ObjectId } = require('mongodb'); -const fs = require('fs').promises; - -const date = new Date().toISOString(); -const LOGDIR = __dirname + `/etherpad_migration_${date}.log`; -const ONE_RUN_LIMIT = 100_000; - -async function log(...theArgs) { - for (let step = 0; step < Object.keys(theArgs).length; step++) { - const date = new Date().toISOString(); - await fs.appendFile(LOGDIR, `[${date}]` + ': ' + theArgs[step] + '\n'); - } -} - -/** ***************************************** - * ARGUMENT PARSING - ****************************************** */ - -const args = arg( - { - // Types - '--help': Boolean, // show the help - '-h': '--help', - }, - { - permissive: true, - argv: process.argv.slice(2), - } -); - -const HELP = `Usage: node migrate-etherpads.js - -This script searches for etherpad pads in all lessons with an old pad url. -It then takes those old pads and uses the etherpad api's "copyPad" function -to copy the contents of the old pad to a new grouppad for given course. -Finally it saves the changes to the given lesson. - -Example: -node ./migrate-etherpads.js etherpad.dbildungscloud.de -npm run migrate-etherpads -- etherpad.dbildungscloud.de - -OPTIONS: ---help (-h) Show this help. -`; - -if (args['--help']) { - console.log(HELP); - process.exit(0); -} - -/** ***************************************** - * HELPER - ****************************************** */ -const getPadIdFromUrl = (path) => { - path += ''; - const parsedUrl = new URL(path); - path = parsedUrl.pathname; - return path.substring(path.lastIndexOf('/') + 1); -}; - -/** ***************************************** - * Progress Bar - ****************************************** */ -const { bgWhite } = require('chalk'); - -class ProgressBar { - constructor() { - this.total; - this.current = 0; - this.bar_length = 80 - 30; // process.stdout.columns - } - - init(total) { - this.total = total; - this.current = 0; - this.update(this.current); - } - - update(current = 1) { - this.current += current; - const current_progress = this.current / this.total; - this.draw(current_progress); - } - - draw(current_progress) { - const filled_bar_length = (current_progress * this.bar_length).toFixed(0); - const empty_bar_length = this.bar_length - filled_bar_length; - - const filled_bar = this.get_bar(filled_bar_length, ' ', bgWhite); - const empty_bar = this.get_bar(empty_bar_length, '-'); - //const progressSum = ((current_progress * 100).toFixed(2)) + "%"; - const progressSum = this.current + '/' + this.total; - - process.stdout.clearLine(); - process.stdout.cursorTo(0); - process.stdout.write(`Migrating Etherpads: [${filled_bar}${empty_bar}] | ${progressSum}`); - } - - get_bar(length, char, color = (a) => a) { - let str = ''; - for (let i = 0; i < length; i++) { - str += char; - } - return color(str); - } -} - -function chunkArray(myArray, chunk_size) { - var index = 0; - var arrayLength = myArray.length; - var tempArray = []; - - for (index = 0; index < arrayLength; index += chunk_size) { - myChunk = myArray.slice(index, index + chunk_size); - // Do something if you want with the group - tempArray.push(myChunk); - } - - return tempArray; -} - -/** ***************************************** - * MAIN - ****************************************** */ -const run = async (oldPadDomain) => { - const app = await appPromise(); - let searchRegex = new RegExp(`https://${oldPadDomain.replace(/\./g, '\\.')}.*`); - - const lessonsService = app.service('/lessons'); - const lessonResult = await lessonsService.Model.find({ - contents: { $elemMatch: { component: 'Etherpad', 'content.url': searchRegex } }, - }).limit(ONE_RUN_LIMIT); - - if (lessonResult.length <= 0) { - return Promise.reject('No Pads found to migrate.'); - } - - const CurrentProgress = new ProgressBar(); - CurrentProgress.total = lessonResult.length; - CurrentProgress.update(0); - - const lessons = chunkArray(lessonResult, 1); - - let errors = false; - for (let index = 0; index < lessons.length; index++) { - const foundLessons = lessons[index]; - await Promise.allSettled( - foundLessons.map(async (lesson) => { - let courseId = lesson.courseId; - let groupResult = await etherpadClient.createOrGetGroup({ - groupMapper: '' + courseId, - }); - let groupID = groupResult.data.groupID; - let overallResult = { - message: `Successful saving lesson ${lesson._id}`, - state: 'resolved', - }; - await Promise.allSettled( - lesson.contents.map(async (content) => { - if (content.component === 'Etherpad') { - let oldPadId = getPadIdFromUrl(content.content.url); - if (typeof content.content.title === 'undefined' || content.content.title === '') { - content.content.title = randomBytes(12).toString('hex'); - } - let padName = content.content.title; - let createResult = await etherpadClient.createOrGetGroupPad({ - groupID, - oldPadId, - padName, - }); - let newPadId = createResult.data.padID; - content.content.url = Configuration.get('ETHERPAD__PAD_URI') + `/${newPadId}`; - await log( - `Successfully migrated Etherpad lesson ${lesson._id} content ${content._id} from /p/${oldPadId} to ${newPadId}` - ); - return Promise.resolve(1); - } - }) - ).then(async (results) => { - await Promise.allSettled( - results.map(async (result, index) => { - if (result.status === 'rejected') { - let contentId = lesson.contents[index]._id; - const error = `lesson ${lesson._id} content ${contentId}: ${result.reason}`; - overallResult = { - message: 'some pads could not be migrated', - state: 'rejected', - }; - await log(error); - return Promise.reject(error); - } - return Promise.resolve(1); - }) - ); - }); - let result = await lessonsService.Model.updateOne({ _id: ObjectId(lesson._id) }, lesson); - if (overallResult.state === 'rejected') { - return Promise.reject(overallResult.message); - } - return Promise.resolve(overallResult.message); - }) - ).then(async (results) => { - await Promise.allSettled( - results.map(async (result, index) => { - if (result.status === 'rejected') { - errors = true; - let lessonId = foundLessons[index]._id; - const error = `Error saving lesson ${lessonId} ${result.reason}`; - await log(error); - return Promise.reject(error); - } - CurrentProgress.update(); - await log(result.value); - return Promise.resolve(1); - }) - ); - }); - } - if (errors) { - console.log(`\n\nThere were errors migrating pads from some lessons.\nCheck ${LOGDIR}`); - } else { - console.log(`\n\nLessons are successfully migrated.\nFor additional info Check ${LOGDIR}`); - } - process.stdout.write('\n'); - return Promise.resolve(1); -}; - -const main = () => { - if (args._.length === 0 || args._.length > 1) { - console.log(HELP); - process.exit(0); - } - return new Promise(async (resolve) => { - await run(args._[0]).catch(async (err) => { - console.log(err); - }); - process.exit(0); - }); -}; - -main(); From 9f1c77d195ea6c893b52b4b0b3882a4ae0eeb692 Mon Sep 17 00:00:00 2001 From: Max Bischof <106820326+bischofmax@users.noreply.github.com> Date: Tue, 3 Sep 2024 08:56:41 +0200 Subject: [PATCH 12/29] BC-7894 - Add FILESTORAGE_REMOVE to permission DrawingElement check (#5213) --- .../domain/rules/board-node.rule.spec.ts | 14 ++++++++++++-- .../authorization/domain/rules/board-node.rule.ts | 11 ++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/server/src/modules/authorization/domain/rules/board-node.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/board-node.rule.spec.ts index c18ac7c5edc..8a59b5e5e2b 100644 --- a/apps/server/src/modules/authorization/domain/rules/board-node.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/board-node.rule.spec.ts @@ -1,8 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; +import { BoardNodeAuthorizable, BoardRoles } from '@modules/board'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { BoardNodeAuthorizable, BoardRoles } from '@modules/board'; import { columnBoardFactory, drawingElementFactory, @@ -535,7 +535,7 @@ describe(BoardNodeRule.name, () => { }); describe('when boardDoAuthorizable.board is a drawingElement', () => { - describe('when required permissions do not include FILESTORAGE_CREATE or FILESTORAGE_VIEW', () => { + describe('when required permissions do not include FILESTORAGE_CREATE or FILESTORAGE_VIEW or FILESTORAGE_REMOVE', () => { describe('when user is Editor', () => { const setup = () => { const user = userFactory.buildWithId(); @@ -640,6 +640,16 @@ describe(BoardNodeRule.name, () => { requiredPermissions: [Permission.FILESTORAGE_CREATE], }); + expect(res).toBe(true); + }); + it('should return true if trying to "write" ', () => { + const { user, boardNodeAuthorizable } = setup(); + + const res = service.hasPermission(user, boardNodeAuthorizable, { + action: Action.write, + requiredPermissions: [Permission.FILESTORAGE_REMOVE], + }); + expect(res).toBe(true); }); }); diff --git a/apps/server/src/modules/authorization/domain/rules/board-node.rule.ts b/apps/server/src/modules/authorization/domain/rules/board-node.rule.ts index 6b530225360..d743d40bea7 100644 --- a/apps/server/src/modules/authorization/domain/rules/board-node.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/board-node.rule.ts @@ -1,7 +1,3 @@ -import { Injectable } from '@nestjs/common'; -import { User } from '@shared/domain/entity/user.entity'; -import { Permission } from '@shared/domain/interface'; -import { EntityId } from '@shared/domain/types'; import { BoardNodeAuthorizable, BoardRoles, @@ -12,6 +8,10 @@ import { SubmissionItem, UserWithBoardRoles, } from '@modules/board'; +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity/user.entity'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { AuthorizationHelper } from '../service/authorization.helper'; import { Action, AuthorizationContext, Rule } from '../type'; @@ -77,7 +77,8 @@ export class BoardNodeRule implements Rule { ): boolean { const requiresFileStoragePermission = context.requiredPermissions.includes(Permission.FILESTORAGE_CREATE) || - context.requiredPermissions.includes(Permission.FILESTORAGE_VIEW); + context.requiredPermissions.includes(Permission.FILESTORAGE_VIEW) || + context.requiredPermissions.includes(Permission.FILESTORAGE_REMOVE); return isDrawingElement(boardNodeAuthorizable.boardNode) && requiresFileStoragePermission; } From 232957d37622c07969eda48fe981ce2c9bbcc794 Mon Sep 17 00:00:00 2001 From: Cedric Evers <12080057+CeEv@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:44:56 +0200 Subject: [PATCH 13/29] BC-7899 Remove thumbnail related code in legacy files services. (#5210) --- config/globals.js | 4 -- src/services/fileStorage/docs/index.js | 10 +-- src/services/fileStorage/docs/openapi.yaml | 33 ---------- src/services/fileStorage/index.js | 2 - src/services/fileStorage/proxy-service.js | 61 +------------------ src/services/fileStorage/thumbnail-service.js | 26 -------- test/services/fileStorage/index.test.js | 4 -- 7 files changed, 6 insertions(+), 134 deletions(-) delete mode 100644 src/services/fileStorage/thumbnail-service.js diff --git a/config/globals.js b/config/globals.js index 6747c023d3c..33f8f57217b 100644 --- a/config/globals.js +++ b/config/globals.js @@ -72,10 +72,6 @@ const globals = { TEST_PW: (process.env.TEST_PW || '').trim(), TEST_HASH: (process.env.TEST_HASH || '').trim(), - // files - FILE_PREVIEW_SERVICE_URI: process.env.FILE_PREVIEW_SERVICE_URI || 'http://localhost:3000/filepreview', - FILE_PREVIEW_CALLBACK_URI: process.env.FILE_PREVIEW_CALLBACK_URI || 'http://localhost:3030/fileStorage/thumbnail/', - ENABLE_THUMBNAIL_GENERATION: process.env.ENABLE_THUMBNAIL_GENERATION || false, /** path must start and end with a slash */ SECURITY_CHECK_SERVICE_PATH: '/v1/fileStorage/securityCheck/', FILE_SECURITY_CHECK_MAX_FILE_SIZE: diff --git a/src/services/fileStorage/docs/index.js b/src/services/fileStorage/docs/index.js index e8efa520699..0b851bf3fa9 100644 --- a/src/services/fileStorage/docs/index.js +++ b/src/services/fileStorage/docs/index.js @@ -505,13 +505,11 @@ module.exports = { description: 'Returns the data signed url and meta properties', example: { signedUrl: { - url: - 'https://:/bucket-5f2987e020834114b8efd6f8/1561200908775-24-1.gif?Content-Type=image%2Fgif&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=sc-devteam%2F20190622%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20190622T105509Z&X-Amz-Expires=60&X-Amz-Signature=b098d101dea55fc3a8fa1e9accf4c99807e96ab22a91f3ee162e86c850e6a164&X-Amz-SignedHeaders=host', + url: 'https://:/bucket-5f2987e020834114b8efd6f8/1561200908775-24-1.gif?Content-Type=image%2Fgif&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=sc-devteam%2F20190622%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20190622T105509Z&X-Amz-Expires=60&X-Amz-Signature=b098d101dea55fc3a8fa1e9accf4c99807e96ab22a91f3ee162e86c850e6a164&X-Amz-SignedHeaders=host', header: { 'Content-Type': 'image/gif', 'x-amz-meta-name': '24-1.gif', 'x-amz-meta-flat-name': '1561200908775-24-1.gif', - 'x-amz-meta-thumbnail': 'https://schulcloud.org/images/login-right.png', }, }, }, @@ -546,8 +544,7 @@ module.exports = { description: 'Returns the data with the signed url', example: { signedUrl: { - url: - 'https://:/bucket-5f2987e020834114b8efd6f8/1561200908775-24-1.gif?Content-Type=image%2Fgif&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=sc-devteam%2F20190622%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20190622T105509Z&X-Amz-Expires=60&X-Amz-Signature=b098d101dea55fc3a8fa1e9accf4c99807e96ab22a91f3ee162e86c850e6a164&X-Amz-SignedHeaders=host', + url: 'https://:/bucket-5f2987e020834114b8efd6f8/1561200908775-24-1.gif?Content-Type=image%2Fgif&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=sc-devteam%2F20190622%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20190622T105509Z&X-Amz-Expires=60&X-Amz-Signature=b098d101dea55fc3a8fa1e9accf4c99807e96ab22a91f3ee162e86c850e6a164&X-Amz-SignedHeaders=host', }, }, }, @@ -578,8 +575,7 @@ module.exports = { description: 'Returns the data with the signed url', example: { signedUrl: { - url: - 'https://:/bucket-5f2987e020834114b8efd6f8/1561200908775-24-1.gif?Content-Type=image%2Fgif&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=sc-devteam%2F20190622%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20190622T105509Z&X-Amz-Expires=60&X-Amz-Signature=b098d101dea55fc3a8fa1e9accf4c99807e96ab22a91f3ee162e86c850e6a164&X-Amz-SignedHeaders=host', + url: 'https://:/bucket-5f2987e020834114b8efd6f8/1561200908775-24-1.gif?Content-Type=image%2Fgif&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=sc-devteam%2F20190622%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20190622T105509Z&X-Amz-Expires=60&X-Amz-Signature=b098d101dea55fc3a8fa1e9accf4c99807e96ab22a91f3ee162e86c850e6a164&X-Amz-SignedHeaders=host', }, }, }, diff --git a/src/services/fileStorage/docs/openapi.yaml b/src/services/fileStorage/docs/openapi.yaml index 861a9ed80fe..da92de47613 100644 --- a/src/services/fileStorage/docs/openapi.yaml +++ b/src/services/fileStorage/docs/openapi.yaml @@ -977,39 +977,6 @@ paths: tags: - files security: [] - '/fileStorage/thumbnail/{id}': - patch: - parameters: - - in: path - name: id - description: ID of thumbnail to update - schema: - type: integer - required: true - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/thumbnail' - '401': - description: not authenticated - '404': - description: not found - '500': - description: general error - description: Updates the resource identified by id using data. - summary: '' - tags: - - fileStorage - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/thumbnail' '/fileStorage/securityCheck/{id}': put: parameters: diff --git a/src/services/fileStorage/index.js b/src/services/fileStorage/index.js index d1f2d80ea6e..37c026f1c69 100644 --- a/src/services/fileStorage/index.js +++ b/src/services/fileStorage/index.js @@ -3,7 +3,6 @@ const path = require('path'); const modelService = require('./model-service'); const proxyService = require('./proxy-service'); -const thumbnailService = require('./thumbnail-service'); const { service: securityCheckService } = require('./SecurityCheckService'); module.exports = (app) => { @@ -12,6 +11,5 @@ module.exports = (app) => { app.configure(proxyService); app.configure(modelService); - app.configure(thumbnailService); app.configure(securityCheckService); }; diff --git a/src/services/fileStorage/proxy-service.js b/src/services/fileStorage/proxy-service.js index 7d742e20556..1f4b22fb9fe 100644 --- a/src/services/fileStorage/proxy-service.js +++ b/src/services/fileStorage/proxy-service.js @@ -32,8 +32,6 @@ const { userModel } = require('../user/model'); const logger = require('../../logger'); const { equal: equalIds } = require('../../helper/compare').ObjectId; const { - FILE_PREVIEW_SERVICE_URI, - FILE_PREVIEW_CALLBACK_URI, FILE_SECURITY_CHECK_MAX_FILE_SIZE, SECURITY_CHECK_SERVICE_PATH, } = require('../../../config/globals'); @@ -80,58 +78,6 @@ const getStorageProviderIdAndBucket = async (userId, fileObject, strategy) => { }; }; -const prepareThumbnailGeneration = async ( - file, - strategy, - userId, - { name: dataName }, - { storageFileName, name: propName } -) => { - if (Configuration.get('ENABLE_THUMBNAIL_GENERATION') === true) { - const fileObject = await FileModel.findOne({ _id: file }).lean().exec(); - - if (!fileObject) { - throw new NotFound('File seems not to be there.'); - } - - const { storageProviderId, bucket } = await getStorageProviderIdAndBucket(userId, fileObject); - - Promise.all([ - strategy.getSignedUrl({ - storageProviderId, - bucket, - flatFileName: storageFileName, - localFileName: storageFileName, - download: true, - Expires: 3600 * 24, - }), - strategy.generateSignedUrl({ - userId, - flatFileName: storageFileName.replace(/(\..+)$/, '-thumbnail.png'), - fileType: returnFileType(dataName || propName), // data.type - }), - ]).then(([downloadUrl, signedS3Url]) => - rp - .post({ - url: FILE_PREVIEW_SERVICE_URI, - body: { - downloadUrl, - signedS3Url, - callbackUrl: url.resolve(FILE_PREVIEW_CALLBACK_URI, file.thumbnailRequestToken), - options: { - width: 120, - }, - }, - json: true, - }) - .catch((err) => { - logger.warning(new Error('Can not create tumbnail', err)); // todo err message is lost and throw error - }) - ); - } - return Promise.resolve(); -}; - /** * * @param {File} file the file object @@ -267,7 +213,7 @@ const fileStorageService = { if (!file) file = await FileModel.create(props); prepareSecurityCheck(file, creatorId, strategy).catch(asyncErrorHandler); - prepareThumbnailGeneration(file, strategy, creatorId, data, props).catch(asyncErrorHandler); + return file; }, @@ -434,7 +380,7 @@ const signedUrlService = { const header = { name: encodeURIComponent(filename), 'flat-name': encodeURIComponent(flatFileName), - thumbnail: 'https://schulcloud.org/images/login-right.png', + thumbnail: '', }; return parentPromise @@ -457,7 +403,6 @@ const signedUrlService = { 'Content-Type': fileType, 'x-amz-meta-name': header.name, 'x-amz-meta-flat-name': header['flat-name'], - 'x-amz-meta-thumbnail': header.thumbnail, }, })) .catch((err) => { @@ -836,7 +781,7 @@ const newFileService = { size: buffer.length, storageFileName: flatFileName, type: returnFileType(name), - thumbnail: 'https://schulcloud.org/images/login-right.png', + thumbnail: '', name, owner, parent, diff --git a/src/services/fileStorage/thumbnail-service.js b/src/services/fileStorage/thumbnail-service.js deleted file mode 100644 index b83ae407160..00000000000 --- a/src/services/fileStorage/thumbnail-service.js +++ /dev/null @@ -1,26 +0,0 @@ -const { FileModel } = require('./model'); - -class ThumbnailService { - patch(id, data) { - return FileModel.updateOne( - { thumbnailRequestToken: id }, - { - $set: { - thumbnailRequestToken: null, - thumbnail: data.thumbnail, - }, - } - ).exec(); - } - - setup(app) { - this.app = app; - } -} - -module.exports = function service() { - const app = this; - - // Initialize our service with any options it requires - app.use('/fileStorage/thumbnail', new ThumbnailService()); -}; diff --git a/test/services/fileStorage/index.test.js b/test/services/fileStorage/index.test.js index 0f806ec0fbd..c13994c0313 100644 --- a/test/services/fileStorage/index.test.js +++ b/test/services/fileStorage/index.test.js @@ -461,10 +461,6 @@ describe('fileStorage services', () => { }); }); - it('registered the thumbnail service', () => { - assert.ok(app.service('fileStorage/thumbnail')); - }); - describe('directory service', () => { it('registered the directory service', () => { assert.ok(app.service('fileStorage/directories')); From 68a17474a43360cc9941307f2f580faddd08ba6e Mon Sep 17 00:00:00 2001 From: Christian Spohr <105202075+csp175@users.noreply.github.com> Date: Tue, 3 Sep 2024 15:50:31 +0200 Subject: [PATCH 14/29] removed selenium test from push.yml (#5221) Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> --- .github/workflows/push.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 32a20eac108..9717fd83b29 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -185,14 +185,6 @@ jobs: with: sarif_file: 'trivy-results.sarif' - end-to-end-tests: - needs: - - build_and_push - - branch_meta - uses: hpi-schul-cloud/end-to-end-tests/.github/workflows/e2e_call.yml@main - with: - ref: ${{ needs.branch_meta.outputs.branch }} - cy-e2e-tests: needs: - branch_meta @@ -202,10 +194,3 @@ jobs: ref: ${{ needs.branch_meta.outputs.branch }} secrets: service-account-token: ${{ secrets.CYPRESS_ONEPWD_SERVICE_ACCOUNT_TOKEN }} - - test-successful: - runs-on: ubuntu-latest - needs: - - end-to-end-tests - steps: - - run: echo "Test was successful" From 21a5f8e8dd4df84ebae04740db9b3b13617ee47c Mon Sep 17 00:00:00 2001 From: Steliyan Dinkov <133751031+sdinkov@users.noreply.github.com> Date: Tue, 3 Sep 2024 23:54:42 +0200 Subject: [PATCH 15/29] N21-2075: sync existing course (#5165) * add sync existing course * N21-2075 modify course metadata * update course entity mapping * update courses /all endpoint * update get course infos * update course DO * N21-2075 permission check * update course synchronization * add migration * update course find all * update seed data roles * update pagination handling for courses * update course do service * update classes repo spec * N21-2075 Calendar flag for Nuxt * update courses info response * migration: Migration20240823151836 * add course info controller * update sourse info + course repo * update classe service tests --------- Co-authored-by: Mrika Llabani Co-authored-by: mrikallab <93978883+mrikallab@users.noreply.github.com> --- .../mikro-orm/Migration20240823151836.ts | 37 +++ .../domain/rules/course.rule.spec.ts | 42 ++- .../authorization/domain/rules/course.rule.ts | 32 +- .../src/modules/class/domain/class.do.ts | 8 + .../class/domain/testing/class.do.spec.ts | 24 ++ .../modules/class/repo/classes.repo.spec.ts | 30 ++ .../src/modules/class/repo/classes.repo.ts | 12 + .../modules/class/repo/mapper/class.mapper.ts | 2 +- .../class/service/class.service.spec.ts | 29 ++ .../modules/class/service/class.service.ts | 9 + .../api-test/course-info.api.spec.ts | 151 +++++++++ .../controller/api-test/course.api.spec.ts | 108 ++++++ .../controller/course-info.controller.ts | 48 +++ .../learnroom/controller/course.controller.ts | 23 +- .../controller/dto/course-sync.body.params.ts | 12 + .../modules/learnroom/controller/dto/index.ts | 2 + .../dto/request/course-filter-params.ts | 10 + .../dto/request/course-sort-params.ts | 11 + .../dto/response/course-info-data-response.ts | 27 ++ .../dto/response/course-info-list.response.ts | 13 + .../controller/dto/response/index.ts | 2 + .../src/modules/learnroom/domain/do/course.ts | 8 + ...dy-synchronized.loggable-exception.spec.ts | 31 ++ ...already-synchronized.loggable-exception.ts | 21 ++ .../modules/learnroom/domain/error/index.ts | 1 + .../src/modules/learnroom/domain/index.ts | 4 +- .../domain/interface/course-filter.ts | 7 + .../interface/course-sort-props.enum.ts | 3 + .../domain/interface/course-status.enum.ts | 4 + .../domain/interface/course.repo.interface.ts | 5 + .../learnroom/domain/interface/index.ts | 5 +- .../modules/learnroom/learnroom-api.module.ts | 21 +- .../src/modules/learnroom/learnroom.module.ts | 10 +- .../mapper/course-info-response.mapper.ts | 36 ++ .../mikro-orm/course.repo.integration.spec.ts | 83 ++++- .../learnroom/repo/mikro-orm/course.repo.ts | 43 ++- .../service/course-do.service.spec.ts | 93 +++++- .../learnroom/service/course-do.service.ts | 27 +- .../learnroom/uc/course-info.uc.spec.ts | 311 ++++++++++++++++++ .../modules/learnroom/uc/course-info.uc.ts | 119 +++++++ .../learnroom/uc/course-sync.uc.spec.ts | 53 ++- .../modules/learnroom/uc/course-sync.uc.ts | 18 +- .../learnroom/uc/dto/course-info.dto.ts | 21 ++ .../src/modules/learnroom/uc/dto/index.ts | 1 + apps/server/src/modules/learnroom/uc/index.ts | 1 + .../modules/server/api/dto/config.response.ts | 8 + .../server/api/test/server.api.spec.ts | 2 + .../src/modules/server/server.config.ts | 4 + .../src/shared/domain/entity/course.entity.ts | 2 +- .../domain/interface/permission.enum.ts | 1 + .../src/shared/repo/course/course.repo.ts | 49 +-- .../shared/repo/course/course.scope.spec.ts | 202 ++++++++++++ .../src/shared/repo/course/course.scope.ts | 67 ++++ apps/server/src/shared/repo/course/index.ts | 1 + backup/setup/migrations.json | 9 + backup/setup/roles.json | 1 + config/default.schema.json | 10 + src/services/user-group/services/courses.js | 6 + 58 files changed, 1843 insertions(+), 77 deletions(-) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20240823151836.ts create mode 100644 apps/server/src/modules/learnroom/controller/api-test/course-info.api.spec.ts create mode 100644 apps/server/src/modules/learnroom/controller/course-info.controller.ts create mode 100644 apps/server/src/modules/learnroom/controller/dto/course-sync.body.params.ts create mode 100644 apps/server/src/modules/learnroom/controller/dto/request/course-filter-params.ts create mode 100644 apps/server/src/modules/learnroom/controller/dto/request/course-sort-params.ts create mode 100644 apps/server/src/modules/learnroom/controller/dto/response/course-info-data-response.ts create mode 100644 apps/server/src/modules/learnroom/controller/dto/response/course-info-list.response.ts create mode 100644 apps/server/src/modules/learnroom/controller/dto/response/index.ts create mode 100644 apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.ts create mode 100644 apps/server/src/modules/learnroom/domain/interface/course-filter.ts create mode 100644 apps/server/src/modules/learnroom/domain/interface/course-sort-props.enum.ts create mode 100644 apps/server/src/modules/learnroom/domain/interface/course-status.enum.ts create mode 100644 apps/server/src/modules/learnroom/mapper/course-info-response.mapper.ts create mode 100644 apps/server/src/modules/learnroom/uc/course-info.uc.spec.ts create mode 100644 apps/server/src/modules/learnroom/uc/course-info.uc.ts create mode 100644 apps/server/src/modules/learnroom/uc/dto/course-info.dto.ts create mode 100644 apps/server/src/modules/learnroom/uc/dto/index.ts create mode 100644 apps/server/src/shared/repo/course/course.scope.spec.ts create mode 100644 apps/server/src/shared/repo/course/course.scope.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20240823151836.ts b/apps/server/src/migrations/mikro-orm/Migration20240823151836.ts new file mode 100644 index 00000000000..9a692086200 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240823151836.ts @@ -0,0 +1,37 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240823151836 extends Migration { + async up(): Promise { + const adminRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'administrator' }, + { + $addToSet: { + permissions: { + $each: ['COURSE_ADMINISTRATION'], + }, + }, + } + ); + + if (adminRoleUpdate.modifiedCount > 0) { + console.info('Permission COURSE_ADMINISTRATION added to role administrator.'); + } + } + + async down(): Promise { + const adminRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'administrator' }, + { + $pull: { + permissions: { + $in: ['COURSE_ADMINISTRATION'], + }, + }, + } + ); + + if (adminRoleUpdate.modifiedCount > 0) { + console.info('Rollback: Permission COURSE_ADMINISTRATION added to role administrator.'); + } + } +} diff --git a/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts index d66b7856ca9..46d616cf45b 100644 --- a/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts @@ -8,6 +8,7 @@ import { Action } from '../type'; import { CourseRule } from './course.rule'; describe('CourseRule', () => { + let module: TestingModule; let service: CourseRule; let authorizationHelper: AuthorizationHelper; let user: User; @@ -19,7 +20,7 @@ describe('CourseRule', () => { beforeAll(async () => { await setupEntities(); - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ providers: [AuthorizationHelper, CourseRule], }).compile(); @@ -32,12 +33,20 @@ describe('CourseRule', () => { user = userFactory.build({ roles: [role] }); }); + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + describe('when validating an entity', () => { it('should call hasAllPermissions on AuthorizationHelper', () => { entity = courseEntityFactory.build({ teachers: [user] }); const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, []); + expect(spy).toHaveBeenCalledWith(user, []); }); it('should call hasAccessToEntity on AuthorizationHelper if action = "read"', () => { @@ -73,6 +82,35 @@ describe('CourseRule', () => { }); }); + describe('when validating an entity and the user has COURSE_ADMINISTRATION permission', () => { + const setup = () => { + const permissionD = Permission.COURSE_ADMINISTRATION; + const adminRole = roleFactory.build({ permissions: [permissionD] }); + const adminUser = userFactory.build({ roles: [adminRole] }); + + return { + adminUser, + permissionD, + }; + }; + + it('should call hasAllPermissions with admin permissions on AuthorizationHelper', () => { + const { permissionD, adminUser } = setup(); + entity = courseEntityFactory.build(); + const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); + service.hasPermission(adminUser, entity, { action: Action.read, requiredPermissions: [] }); + expect(spy).toHaveBeenNthCalledWith(2, adminUser, [permissionD]); + }); + + it('should not call hasAccessToEntity on AuthorizationHelper', () => { + const { adminUser } = setup(); + entity = courseEntityFactory.build(); + const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity'); + service.hasPermission(adminUser, entity, { action: Action.read, requiredPermissions: [] }); + expect(spy).toHaveBeenCalledTimes(0); + }); + }); + describe('when validating a domain object', () => { describe('when the user is authorized', () => { const setup = () => { diff --git a/apps/server/src/modules/authorization/domain/rules/course.rule.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.ts index f4c3b51f84a..c97afb098fa 100644 --- a/apps/server/src/modules/authorization/domain/rules/course.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.ts @@ -1,6 +1,7 @@ import { Course } from '@modules/learnroom/domain'; import { Injectable } from '@nestjs/common'; import { Course as CourseEntity, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; import { AuthorizationHelper } from '../service/authorization.helper'; import { Action, AuthorizationContext, Rule } from '../type'; @@ -16,14 +17,27 @@ export class CourseRule implements Rule { public hasPermission(user: User, object: CourseEntity | Course, context: AuthorizationContext): boolean { const { action, requiredPermissions } = context; - const hasPermission = - this.authorizationHelper.hasAllPermissions(user, requiredPermissions) && - this.authorizationHelper.hasAccessToEntity( - user, - object, - action === Action.read ? ['teachers', 'substitutionTeachers', 'students'] : ['teachers', 'substitutionTeachers'] - ); - - return hasPermission; + + const hasRequiredPermission = this.authorizationHelper.hasAllPermissions(user, requiredPermissions); + const hasAdminPermission = this.authorizationHelper.hasAllPermissions(user, [Permission.COURSE_ADMINISTRATION]); + + const hasAccessToEntity = hasAdminPermission + ? true + : this.authorizationHelper.hasAccessToEntity( + user, + object, + this.isReadAction(action) + ? ['teachers', 'substitutionTeachers', 'students'] + : ['teachers', 'substitutionTeachers'] + ); + + return hasAccessToEntity && hasRequiredPermission; + } + + isReadAction(action: Action) { + if (action === Action.read) { + return true; + } + return false; } } diff --git a/apps/server/src/modules/class/domain/class.do.ts b/apps/server/src/modules/class/domain/class.do.ts index fd6449a9d46..68a7e621947 100644 --- a/apps/server/src/modules/class/domain/class.do.ts +++ b/apps/server/src/modules/class/domain/class.do.ts @@ -74,4 +74,12 @@ export class Class extends DomainObject { public removeUser(userId: string) { this.props.userIds = this.props.userIds?.filter((userId1) => userId1 !== userId); } + + public getClassFullName(): string { + const classFullName = this.props.gradeLevel + ? this.props.gradeLevel.toString().concat(this.props.name) + : this.props.name; + + return classFullName; + } } diff --git a/apps/server/src/modules/class/domain/testing/class.do.spec.ts b/apps/server/src/modules/class/domain/testing/class.do.spec.ts index 510af786665..7ff616ab4ba 100644 --- a/apps/server/src/modules/class/domain/testing/class.do.spec.ts +++ b/apps/server/src/modules/class/domain/testing/class.do.spec.ts @@ -86,4 +86,28 @@ describe(Class.name, () => { }); }); }); + + describe('getClassFullName', () => { + describe('When function is called', () => { + it('should return full class name consisting of grade level and class name', () => { + const gradeLevel = 1; + const name = 'A'; + const domainObject = classFactory.build({ name, gradeLevel }); + + const result = domainObject.getClassFullName(); + + expect(result).toEqual('1A'); + }); + + it('should return full class name consisting of class name only', () => { + const gradeLevel = undefined; + const name = 'A'; + const domainObject = classFactory.build({ name, gradeLevel }); + + const result = domainObject.getClassFullName(); + + expect(result).toEqual('A'); + }); + }); + }); }); diff --git a/apps/server/src/modules/class/repo/classes.repo.spec.ts b/apps/server/src/modules/class/repo/classes.repo.spec.ts index 9904a627e6c..dbb58fb5b44 100644 --- a/apps/server/src/modules/class/repo/classes.repo.spec.ts +++ b/apps/server/src/modules/class/repo/classes.repo.spec.ts @@ -177,4 +177,34 @@ describe(ClassesRepo.name, () => { }); }); }); + + describe('findClassById', () => { + describe('when class is not found in classes', () => { + it('should return null', async () => { + const result = await repo.findClassById(new ObjectId().toHexString()); + + expect(result).toEqual(null); + }); + }); + + describe('when class is in classes', () => { + const setup = async () => { + const class1: ClassEntity = classEntityFactory.buildWithId(); + await em.persistAndFlush([class1]); + em.clear(); + + return { + class1, + }; + }; + + it('should find class with particular classId', async () => { + const { class1 } = await setup(); + + const result = await repo.findClassById(class1.id); + + expect(result?.id).toEqual(class1.id); + }); + }); + }); }); diff --git a/apps/server/src/modules/class/repo/classes.repo.ts b/apps/server/src/modules/class/repo/classes.repo.ts index 7b3c4784d16..5f744cd1793 100644 --- a/apps/server/src/modules/class/repo/classes.repo.ts +++ b/apps/server/src/modules/class/repo/classes.repo.ts @@ -55,4 +55,16 @@ export class ClassesRepo { await this.em.persistAndFlush(existingEntities); } + + public async findClassById(id: EntityId): Promise { + const clazz = await this.em.findOne(ClassEntity, { id }); + + if (!clazz) { + return null; + } + + const domainObject: Class = ClassMapper.mapToDO(clazz); + + return domainObject; + } } diff --git a/apps/server/src/modules/class/repo/mapper/class.mapper.ts b/apps/server/src/modules/class/repo/mapper/class.mapper.ts index 8ae5e3b79b9..bd904a39bd6 100644 --- a/apps/server/src/modules/class/repo/mapper/class.mapper.ts +++ b/apps/server/src/modules/class/repo/mapper/class.mapper.ts @@ -4,7 +4,7 @@ import { ClassSourceOptions } from '../../domain/class-source-options.do'; import { ClassEntity } from '../../entity'; export class ClassMapper { - private static mapToDO(entity: ClassEntity): Class { + static mapToDO(entity: ClassEntity): Class { return new Class({ id: entity.id, name: entity.name, diff --git a/apps/server/src/modules/class/service/class.service.spec.ts b/apps/server/src/modules/class/service/class.service.spec.ts index c7275661cca..7e483ae6544 100644 --- a/apps/server/src/modules/class/service/class.service.spec.ts +++ b/apps/server/src/modules/class/service/class.service.spec.ts @@ -12,6 +12,7 @@ import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { InternalServerErrorException } from '@nestjs/common'; import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { EntityId } from '@shared/domain/types'; import { setupEntities } from '@shared/testing'; import { Logger } from '@src/core/logger'; @@ -126,6 +127,34 @@ describe(ClassService.name, () => { }); }); + describe('findById', () => { + describe('when the user has classes', () => { + const setup = () => { + const clazz: Class = classFactory.build(); + + classesRepo.findClassById.mockResolvedValueOnce(clazz); + + return { + clazz, + }; + }; + + it('should return the class', async () => { + const { clazz } = setup(); + + const result: Class = await service.findById(clazz.id); + + expect(result).toEqual(clazz); + }); + + it('should throw error', async () => { + classesRepo.findClassById.mockResolvedValueOnce(null); + + await expect(service.findById('someId')).rejects.toThrowError(NotFoundLoggableException); + }); + }); + }); + describe('deleteUserDataFromClasses', () => { describe('when user is missing', () => { const setup = () => { diff --git a/apps/server/src/modules/class/service/class.service.ts b/apps/server/src/modules/class/service/class.service.ts index e650e4bee38..c4b19d1c0ab 100644 --- a/apps/server/src/modules/class/service/class.service.ts +++ b/apps/server/src/modules/class/service/class.service.ts @@ -13,6 +13,7 @@ import { } from '@modules/deletion'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { Class } from '../domain'; @@ -100,4 +101,12 @@ export class ClassService implements DeletionService, IEventHandler item.id); } + + public async findById(id: EntityId): Promise { + const clazz: Class | null = await this.classesRepo.findClassById(id); + if (!clazz) { + throw new NotFoundLoggableException(Class.name, { id }); + } + return clazz; + } } diff --git a/apps/server/src/modules/learnroom/controller/api-test/course-info.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course-info.api.spec.ts new file mode 100644 index 00000000000..602dd6c8461 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/api-test/course-info.api.spec.ts @@ -0,0 +1,151 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Course as CourseEntity } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { + cleanupCollections, + courseFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { CourseSortProps, CourseStatus } from '../../domain'; +import { CourseInfoListResponse } from '../dto/response'; + +const createStudent = () => { + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({}, [Permission.COURSE_VIEW]); + return { account: studentAccount, user: studentUser }; +}; +const createTeacher = () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [ + Permission.COURSE_VIEW, + Permission.COURSE_EDIT, + ]); + return { account: teacherAccount, user: teacherUser }; +}; + +const createAdmin = () => { + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.COURSE_ADMINISTRATION]); + return { account: adminAccount, user: adminUser }; +}; + +describe('Course Info Controller (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, 'course-info'); + }); + + afterAll(async () => { + await cleanupCollections(em); + await app.close(); + }); + + describe('[GET] /course-info', () => { + describe('when logged in as admin', () => { + const setup = async () => { + const student = createStudent(); + const teacher = createTeacher(); + const admin = createAdmin(); + const school = schoolEntityFactory.buildWithId({}); + + const currentCourses: CourseEntity[] = courseFactory.buildList(5, { + school, + untilDate: new Date('2045-07-31T23:59:59'), + }); + const archivedCourses: CourseEntity[] = courseFactory.buildList(10, { + school, + untilDate: new Date('2024-07-31T23:59:59'), + }); + + admin.user.school = school; + await em.persistAndFlush(school); + await em.persistAndFlush(currentCourses); + await em.persistAndFlush(archivedCourses); + await em.persistAndFlush([admin.account, admin.user]); + em.clear(); + + return { + student, + currentCourses, + archivedCourses, + teacher, + admin, + school, + }; + }; + + it('should return the correct response structure', async () => { + const { admin } = await setup(); + const query = {}; + + const loggedInClient = await testApiClient.login(admin.account); + const response = await loggedInClient.get().query(query); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('skip'); + expect(response.body).toHaveProperty('limit'); + expect(response.body).toHaveProperty('total'); + }); + + it('should return archived courses in pages', async () => { + const { admin } = await setup(); + const query = { skip: 0, limit: 10, sortBy: CourseSortProps.NAME, status: CourseStatus.ARCHIVE }; + + const loggedInClient = await testApiClient.login(admin.account); + const response = await loggedInClient.get().query(query); + + const { total, skip, limit, data } = response.body as CourseInfoListResponse; + expect(response.statusCode).toBe(200); + expect(skip).toBe(0); + expect(limit).toBe(10); + expect(total).toBe(10); + expect(data.length).toBe(10); + }); + + it('should return current courses in pages', async () => { + const { admin, currentCourses } = await setup(); + const query = { skip: 4, limit: 2, sortBy: CourseSortProps.NAME, status: CourseStatus.CURRENT }; + + const loggedInClient = await testApiClient.login(admin.account); + const response = await loggedInClient.get().query(query); + + const { total, skip, limit, data } = response.body as CourseInfoListResponse; + expect(response.statusCode).toBe(200); + expect(skip).toBe(4); + expect(limit).toBe(2); + expect(total).toBe(5); + expect(data.length).toBe(1); + expect(data[0].id).toBe(currentCourses[4].id); + }); + }); + + describe('when not authorized', () => { + it('should return unauthorized', async () => { + const query = {}; + + const response = await testApiClient.get().query(query); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts index b191b26a0e8..c9991fd5ebc 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts @@ -245,6 +245,114 @@ describe('Course Controller (API)', () => { }); }); + describe('[POST] /courses/:courseId/start-sync', () => { + describe('when a course is not synchronized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + + return { + loggedInClient, + course, + group, + }; + }; + + it('should start the synchronization', async () => { + const { loggedInClient, course, group } = await setup(); + const params = { groupId: group.id }; + + const response = await loggedInClient.post(`${course.id}/start-sync`).send(params); + + const result: CourseEntity = await em.findOneOrFail(CourseEntity, course.id); + expect(response.statusCode).toEqual(HttpStatus.NO_CONTENT); + expect(result.syncedWithGroup?.id).toBe(group.id); + }); + }); + + describe('when a course is already synchronized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const otherGroup = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + syncedWithGroup: otherGroup, + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + + return { + loggedInClient, + course, + group, + otherGroup, + }; + }; + + it('should not start the synchronization', async () => { + const { loggedInClient, course, group, otherGroup } = await setup(); + const params = { groupId: group.id }; + + const response = await loggedInClient.post(`${course.id}/start-sync`).send(params); + + const result: CourseEntity = await em.findOneOrFail(CourseEntity, course.id); + expect(response.statusCode).toEqual(HttpStatus.UNPROCESSABLE_ENTITY); + expect(response.body).toEqual({ + code: HttpStatus.UNPROCESSABLE_ENTITY, + message: 'Unprocessable Entity', + title: 'Course Already Synchronized', + type: 'COURSE_ALREADY_SYNCHRONIZED', + }); + expect(result.syncedWithGroup?.id).toBe(otherGroup.id); + }); + }); + + describe('when the user is unauthorized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + return { + course, + group, + }; + }; + + it('should return unauthorized', async () => { + const { course, group } = await setup(); + const params = { groupId: group.id }; + + const response = await testApiClient.post(`${course.id}/start-sync`).send(params); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); + describe('[GET] /courses/:courseId/cc-metadata', () => { const setup = async () => { const teacher = createTeacher(); diff --git a/apps/server/src/modules/learnroom/controller/course-info.controller.ts b/apps/server/src/modules/learnroom/controller/course-info.controller.ts new file mode 100644 index 00000000000..38a4355f7d8 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/course-info.controller.ts @@ -0,0 +1,48 @@ +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { Controller, Get, HttpStatus, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PaginationParams } from '@shared/controller/'; +import { Page } from '@shared/domain/domainobject'; +import { ErrorResponse } from '@src/core/error/dto'; +import { CourseInfoResponseMapper } from '../mapper/course-info-response.mapper'; +import { CourseInfoDto } from '../uc/dto'; +import { CourseFilterParams } from './dto/request/course-filter-params'; +import { CourseSortParams } from './dto/request/course-sort-params'; +import { CourseInfoListResponse } from './dto/response'; +import { CourseInfoUc } from '../uc/course-info.uc'; + +@ApiTags('Course Info') +@JwtAuthentication() +@Controller('course-info') +export class CourseInfoController { + constructor(private readonly courseInfoUc: CourseInfoUc) {} + + @Get() + @ApiOperation({ summary: 'Get course information.' }) + @ApiResponse({ status: HttpStatus.OK, type: CourseInfoListResponse }) + @ApiResponse({ status: '4XX', type: ErrorResponse }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + async getCourseInfo( + @CurrentUser() currentUser: ICurrentUser, + @Query() pagination: PaginationParams, + @Query() sortingQuery: CourseSortParams, + @Query() filterParams: CourseFilterParams + ): Promise { + const courses: Page = await this.courseInfoUc.getCourseInfo( + currentUser.userId, + currentUser.schoolId, + sortingQuery.sortBy, + filterParams.status, + pagination, + sortingQuery.sortOrder + ); + + const response: CourseInfoListResponse = CourseInfoResponseMapper.mapToCourseInfoListResponse( + courses, + pagination.skip, + pagination.limit + ); + + return response; + } +} diff --git a/apps/server/src/modules/learnroom/controller/course.controller.ts b/apps/server/src/modules/learnroom/controller/course.controller.ts index 3d0d8314651..7d20c2f5292 100644 --- a/apps/server/src/modules/learnroom/controller/course.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course.controller.ts @@ -30,8 +30,14 @@ import { Response } from 'express'; import { CourseMapper } from '../mapper/course.mapper'; import { CourseExportUc, CourseImportUc, CourseSyncUc, CourseUc } from '../uc'; import { CommonCartridgeFileValidatorPipe } from '../utils'; -import { CourseImportBodyParams, CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto'; -import { CourseExportBodyParams } from './dto/course-export.body.params'; +import { + CourseExportBodyParams, + CourseImportBodyParams, + CourseMetadataListResponse, + CourseQueryParams, + CourseSyncBodyParams, + CourseUrlParams, +} from './dto'; import { CourseCommonCartridgeMetadataResponse } from './dto/course-cc-metadata.response'; @ApiTags('Courses') @@ -111,6 +117,19 @@ export class CourseController { await this.courseSyncUc.stopSynchronization(currentUser.userId, params.courseId); } + @Post(':courseId/start-sync/') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Start the synchronization of a course with a group.' }) + @ApiNoContentResponse({ description: 'The course was successfully synchronized to a group.' }) + @ApiUnprocessableEntityResponse({ description: 'The course is already synchronized with a group.' }) + public async startSynchronization( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: CourseUrlParams, + @Body() bodyParams: CourseSyncBodyParams + ): Promise { + await this.courseSyncUc.startSynchronization(currentUser.userId, params.courseId, bodyParams.groupId); + } + @Get(':courseId/user-permissions') @ApiOperation({ summary: 'Get permissions for a user in a course.' }) @ApiBadRequestResponse({ description: 'Request data has invalid format.' }) diff --git a/apps/server/src/modules/learnroom/controller/dto/course-sync.body.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-sync.body.params.ts new file mode 100644 index 00000000000..63fdffa5bf3 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/course-sync.body.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class CourseSyncBodyParams { + @IsMongoId() + @ApiProperty({ + description: 'The id of the group', + required: true, + nullable: false, + }) + groupId!: string; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/index.ts b/apps/server/src/modules/learnroom/controller/dto/index.ts index c459afc49d1..756a0f26429 100644 --- a/apps/server/src/modules/learnroom/controller/dto/index.ts +++ b/apps/server/src/modules/learnroom/controller/dto/index.ts @@ -12,3 +12,5 @@ export * from './patch-visibility.params'; export * from './course-room-element.url.params'; export * from './course-room.url.params'; export * from './single-column-board'; +export * from './course-sync.body.params'; +export * from './course-export.body.params'; diff --git a/apps/server/src/modules/learnroom/controller/dto/request/course-filter-params.ts b/apps/server/src/modules/learnroom/controller/dto/request/course-filter-params.ts new file mode 100644 index 00000000000..d1e8ede12cf --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/request/course-filter-params.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { CourseStatus } from '../../../domain'; + +export class CourseFilterParams { + @IsOptional() + @IsEnum(CourseStatus) + @ApiPropertyOptional({ enum: CourseStatus, enumName: 'CourseStatus' }) + status?: CourseStatus; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/request/course-sort-params.ts b/apps/server/src/modules/learnroom/controller/dto/request/course-sort-params.ts new file mode 100644 index 00000000000..eef900bb569 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/request/course-sort-params.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SortingParams } from '@shared/controller'; +import { IsEnum, IsOptional } from 'class-validator'; +import { CourseSortProps } from '../../../domain/interface/course-sort-props.enum'; + +export class CourseSortParams extends SortingParams { + @IsOptional() + @IsEnum(CourseSortProps) + @ApiPropertyOptional({ enum: CourseSortProps, enumName: 'CourseSortProps' }) + sortBy?: CourseSortProps; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/response/course-info-data-response.ts b/apps/server/src/modules/learnroom/controller/dto/response/course-info-data-response.ts new file mode 100644 index 00000000000..789c50e60d1 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/response/course-info-data-response.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; + +export class CourseInfoDataResponse { + @ApiProperty() + id: EntityId; + + @ApiProperty() + name: string; + + @ApiProperty({ type: [String] }) + teacherNames: string[]; + + @ApiProperty({ type: [String] }) + classNames: string[]; + + @ApiPropertyOptional() + syncedGroup?: string; + + constructor(props: CourseInfoDataResponse) { + this.id = props.id; + this.name = props.name; + this.classNames = props.classNames; + this.teacherNames = props.teacherNames; + this.syncedGroup = props.syncedGroup; + } +} diff --git a/apps/server/src/modules/learnroom/controller/dto/response/course-info-list.response.ts b/apps/server/src/modules/learnroom/controller/dto/response/course-info-list.response.ts new file mode 100644 index 00000000000..d988fe45dbc --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/response/course-info-list.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; +import { CourseInfoDataResponse } from './course-info-data-response'; + +export class CourseInfoListResponse extends PaginationResponse { + constructor(data: CourseInfoDataResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [CourseInfoDataResponse] }) + data: CourseInfoDataResponse[]; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/response/index.ts b/apps/server/src/modules/learnroom/controller/dto/response/index.ts new file mode 100644 index 00000000000..dd0dbbc3a7f --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/response/index.ts @@ -0,0 +1,2 @@ +export { CourseInfoListResponse } from './course-info-list.response'; +export { CourseInfoDataResponse } from './course-info-data-response'; diff --git a/apps/server/src/modules/learnroom/domain/do/course.ts b/apps/server/src/modules/learnroom/domain/do/course.ts index 067c5aa899f..a6e8eb84bce 100644 --- a/apps/server/src/modules/learnroom/domain/do/course.ts +++ b/apps/server/src/modules/learnroom/domain/do/course.ts @@ -65,6 +65,14 @@ export class Course extends DomainObject { return this.props.substitutionTeacherIds; } + get classes(): EntityId[] { + return this.props.classIds; + } + + get groups(): EntityId[] { + return this.props.groupIds; + } + set startDate(value: Date | undefined) { this.props.startDate = value; } diff --git a/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.spec.ts b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.spec.ts new file mode 100644 index 00000000000..15c37f4ce4a --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { courseFactory } from '../../testing'; +import { CourseAlreadySynchronizedLoggableException } from './course-already-synchronized.loggable-exception'; + +describe(CourseAlreadySynchronizedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const course = courseFactory.build(); + + const exception = new CourseAlreadySynchronizedLoggableException(course.id); + + return { + exception, + course, + }; + }; + + it('should log the correct message', () => { + const { exception, course } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'COURSE_ALREADY_SYNCHRONIZED', + stack: expect.any(String), + data: { + courseId: course.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.ts b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.ts new file mode 100644 index 00000000000..cd747ad5008 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.ts @@ -0,0 +1,21 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class CourseAlreadySynchronizedLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly courseId: EntityId) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'COURSE_ALREADY_SYNCHRONIZED', + stack: this.stack, + data: { + courseId: this.courseId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/learnroom/domain/error/index.ts b/apps/server/src/modules/learnroom/domain/error/index.ts index 0f64cd09bd0..e6c6d6cc70a 100644 --- a/apps/server/src/modules/learnroom/domain/error/index.ts +++ b/apps/server/src/modules/learnroom/domain/error/index.ts @@ -1 +1,2 @@ export { CourseNotSynchronizedLoggableException } from './course-not-synchronized.loggable-exception'; +export { CourseAlreadySynchronizedLoggableException } from './course-already-synchronized.loggable-exception'; diff --git a/apps/server/src/modules/learnroom/domain/index.ts b/apps/server/src/modules/learnroom/domain/index.ts index 7ac280479fe..e38999d9811 100644 --- a/apps/server/src/modules/learnroom/domain/index.ts +++ b/apps/server/src/modules/learnroom/domain/index.ts @@ -1,4 +1,4 @@ export { Course, CourseProps } from './do'; -export { CourseRepo, COURSE_REPO } from './interface'; -export { CourseNotSynchronizedLoggableException } from './error'; +export { CourseAlreadySynchronizedLoggableException, CourseNotSynchronizedLoggableException } from './error'; +export { COURSE_REPO, CourseFilter, CourseRepo, CourseSortProps, CourseStatus } from './interface'; export { CourseSynchronizationStoppedLoggable } from './loggable'; diff --git a/apps/server/src/modules/learnroom/domain/interface/course-filter.ts b/apps/server/src/modules/learnroom/domain/interface/course-filter.ts new file mode 100644 index 00000000000..6a1f93ac5dc --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/course-filter.ts @@ -0,0 +1,7 @@ +import { EntityId } from '@shared/domain/types'; +import { CourseStatus } from './course-status.enum'; + +export interface CourseFilter { + schoolId?: EntityId; + status?: CourseStatus; +} diff --git a/apps/server/src/modules/learnroom/domain/interface/course-sort-props.enum.ts b/apps/server/src/modules/learnroom/domain/interface/course-sort-props.enum.ts new file mode 100644 index 00000000000..c2eb5eb5323 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/course-sort-props.enum.ts @@ -0,0 +1,3 @@ +export enum CourseSortProps { + NAME = 'name', +} diff --git a/apps/server/src/modules/learnroom/domain/interface/course-status.enum.ts b/apps/server/src/modules/learnroom/domain/interface/course-status.enum.ts new file mode 100644 index 00000000000..2866db339dc --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/course-status.enum.ts @@ -0,0 +1,4 @@ +export enum CourseStatus { + ARCHIVE = 'archive', + CURRENT = 'current', +} diff --git a/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts b/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts index 2e40c8bc2a2..e1c63920c92 100644 --- a/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts +++ b/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts @@ -1,12 +1,17 @@ import type { Group } from '@modules/group'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BaseDomainObjectRepoInterface } from '@shared/repo/base-domain-object.repo.interface'; import { Course } from '../do'; +import { CourseFilter } from './course-filter'; export interface CourseRepo extends BaseDomainObjectRepoInterface { findCourseById(id: EntityId): Promise; findBySyncedGroup(group: Group): Promise; + + getCourseInfo(filter: CourseFilter, options?: IFindOptions): Promise>; } export const COURSE_REPO = Symbol('COURSE_REPO'); diff --git a/apps/server/src/modules/learnroom/domain/interface/index.ts b/apps/server/src/modules/learnroom/domain/interface/index.ts index 6c0fd29b1f0..a890705533d 100644 --- a/apps/server/src/modules/learnroom/domain/interface/index.ts +++ b/apps/server/src/modules/learnroom/domain/interface/index.ts @@ -1 +1,4 @@ -export { CourseRepo, COURSE_REPO } from './course.repo.interface'; +export { CourseFilter } from './course-filter'; +export { CourseSortProps } from './course-sort-props.enum'; +export { CourseStatus } from './course-status.enum'; +export { COURSE_REPO, CourseRepo } from './course.repo.interface'; diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index e451643a3b6..3c565127dab 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -1,26 +1,34 @@ import { AuthorizationModule } from '@modules/authorization'; import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { ClassModule } from '@modules/class'; import { CopyHelperModule } from '@modules/copy-helper'; +import { GroupModule } from '@modules/group'; + import { LessonModule } from '@modules/lesson'; import { RoleModule } from '@modules/role'; +import { SchoolModule } from '@modules/school'; +import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { CourseRepo, DashboardModelMapper, DashboardRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; +import { CourseRoomsController } from './controller/course-rooms.controller'; import { CourseController } from './controller/course.controller'; import { DashboardController } from './controller/dashboard.controller'; -import { CourseRoomsController } from './controller/course-rooms.controller'; import { LearnroomModule } from './learnroom.module'; import { RoomBoardResponseMapper } from './mapper/room-board-response.mapper'; + +import { CourseInfoController } from './controller/course-info.controller'; import { CourseCopyUC, CourseExportUc, CourseImportUc, + CourseInfoUc, + CourseRoomsAuthorisationService, + CourseRoomsUc, CourseSyncUc, CourseUc, DashboardUc, LessonCopyUC, RoomBoardDTOFactory, - CourseRoomsAuthorisationService, - CourseRoomsUc, } from './uc'; /** @@ -35,11 +43,16 @@ import { LearnroomModule, AuthorizationReferenceModule, RoleModule, + SchoolModule, + GroupModule, + UserModule, + ClassModule, ], - controllers: [DashboardController, CourseController, CourseRoomsController], + controllers: [DashboardController, CourseController, CourseInfoController, CourseRoomsController], providers: [ DashboardUc, CourseUc, + CourseInfoUc, CourseRoomsUc, RoomBoardResponseMapper, RoomBoardDTOFactory, diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index f405aa4d51d..0adf76cacd0 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -1,8 +1,12 @@ import { BoardModule } from '@modules/board'; +import { ClassModule } from '@modules/class'; import { CopyHelperModule } from '@modules/copy-helper'; +import { GroupModule } from '@modules/group'; import { LessonModule } from '@modules/lesson'; +import { SchoolModule } from '@modules/school'; import { TaskModule } from '@modules/task'; import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; +import { UserModule } from '@modules/user'; import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { @@ -28,10 +32,10 @@ import { CourseCopyService, CourseDoService, CourseGroupService, + CourseRoomsService, CourseService, DashboardService, GroupDeletedHandlerService, - CourseRoomsService, } from './service'; import { CommonCartridgeFileValidatorPipe } from './utils'; @@ -48,6 +52,10 @@ import { CommonCartridgeFileValidatorPipe } from './utils'; LoggerModule, TaskModule, CqrsModule, + UserModule, + ClassModule, + SchoolModule, + GroupModule, ], providers: [ { diff --git a/apps/server/src/modules/learnroom/mapper/course-info-response.mapper.ts b/apps/server/src/modules/learnroom/mapper/course-info-response.mapper.ts new file mode 100644 index 00000000000..34d2e468ebf --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/course-info-response.mapper.ts @@ -0,0 +1,36 @@ +import { Page } from '@shared/domain/domainobject'; +import { CourseInfoListResponse, CourseInfoDataResponse } from '../controller/dto/response'; +import { CourseInfoDto } from '../uc/dto'; + +export class CourseInfoResponseMapper { + public static mapToCourseInfoListResponse( + courseInfos: Page, + skip?: number, + limit?: number + ): CourseInfoListResponse { + const courseInfoResponses: CourseInfoDataResponse[] = courseInfos.data.map((courseInfo) => + this.mapToCourseInfoResponse(courseInfo) + ); + + const response: CourseInfoListResponse = new CourseInfoListResponse( + courseInfoResponses, + courseInfos.total, + skip, + limit + ); + + return response; + } + + private static mapToCourseInfoResponse(courseInfo: CourseInfoDto): CourseInfoDataResponse { + const courseInfoResponse: CourseInfoDataResponse = new CourseInfoDataResponse({ + id: courseInfo.id, + name: courseInfo.name, + classNames: courseInfo.classes, + teacherNames: courseInfo.teachers, + syncedGroup: courseInfo.syncedGroupName, + }); + + return courseInfoResponse; + } +} diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts index c637c041432..194917950f1 100644 --- a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts @@ -7,6 +7,7 @@ import { Group } from '@modules/group'; import { GroupEntity } from '@modules/group/entity'; import { Test, TestingModule } from '@nestjs/testing'; import { Course as CourseEntity, CourseFeatures, CourseGroup, SchoolEntity, User } from '@shared/domain/entity'; +import { SortOrder } from '@shared/domain/interface'; import { cleanupCollections, courseFactory as courseEntityFactory, @@ -16,7 +17,7 @@ import { schoolEntityFactory, userFactory, } from '@shared/testing'; -import { Course, COURSE_REPO, CourseProps } from '../../domain'; +import { Course, COURSE_REPO, CourseProps, CourseStatus } from '../../domain'; import { courseFactory } from '../../testing'; import { CourseMikroOrmRepo } from './course.repo'; import { CourseEntityMapper } from './mapper/course.entity.mapper'; @@ -174,4 +175,84 @@ describe(CourseMikroOrmRepo.name, () => { }); }); }); + + describe('findCourses', () => { + describe('when entitys are not found', () => { + const setup = async () => { + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); + const courseEntities: CourseEntity[] = courseEntityFactory.buildList(2, { + school: schoolEntity, + untilDate: new Date('2050-04-24'), + }); + + await em.persistAndFlush([schoolEntity, ...courseEntities]); + em.clear(); + + const filter = { schoolId: schoolEntity.id, status: CourseStatus.ARCHIVE }; + + const courseDOs = courseEntities.map((courseEntity) => CourseEntityMapper.mapEntityToDo(courseEntity)); + return { courseDOs, filter }; + }; + + it('should return empty array', async () => { + const { filter } = await setup(); + + const result = await repo.getCourseInfo(filter); + + expect(result.data).toEqual([]); + }); + }); + + describe('when entitys are found for school', () => { + const setup = async () => { + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); + const courseEntities: CourseEntity[] = courseEntityFactory.buildList(5, { + school: schoolEntity, + untilDate: new Date('1995-04-24'), + }); + + courseEntities.push( + ...courseEntityFactory.buildList(3, { + school: schoolEntity, + untilDate: new Date('2050-04-24'), + }) + ); + + await em.persistAndFlush([schoolEntity, ...courseEntities]); + em.clear(); + + const pagination = { skip: 0, limit: 10 }; + const options = { + pagination, + order: { + name: SortOrder.desc, + }, + }; + const filter = { schoolId: schoolEntity.id, status: CourseStatus.ARCHIVE }; + + const courseDOs = courseEntities.map((courseEntity) => CourseEntityMapper.mapEntityToDo(courseEntity)); + + return { courseDOs, options, filter }; + }; + + it('should return archived courses', async () => { + const { options, filter } = await setup(); + + const result = await repo.getCourseInfo(filter, options); + + expect(result.data.length).toEqual(5); + expect(result.total).toEqual(5); + }); + + it('should return current courses', async () => { + const { options, filter } = await setup(); + + filter.status = CourseStatus.CURRENT; + const result = await repo.getCourseInfo(filter, options); + + expect(result.data.length).toEqual(3); + expect(result.total).toEqual(3); + }); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts index f4efdf13bfb..647d43aed26 100644 --- a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts @@ -1,9 +1,12 @@ -import { EntityData, EntityName } from '@mikro-orm/core'; +import { EntityData, EntityName, FindOptions } from '@mikro-orm/core'; import { Group } from '@modules/group'; +import { Page } from '@shared/domain/domainobject'; import { Course as CourseEntity } from '@shared/domain/entity'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { CourseScope } from '@shared/repo'; import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; -import { Course, CourseRepo } from '../../domain'; +import { Course, CourseFilter, CourseRepo, CourseStatus } from '../../domain'; import { CourseEntityMapper } from './mapper/course.entity.mapper'; export class CourseMikroOrmRepo extends BaseDomainObjectRepo implements CourseRepo { @@ -44,4 +47,40 @@ export class CourseMikroOrmRepo extends BaseDomainObjectRepo): Promise> { + const scope: CourseScope = new CourseScope(); + scope.bySchoolId(filter.schoolId); + if (filter.status === CourseStatus.CURRENT) { + scope.forActiveCourses(); + } else { + scope.forArchivedCourses(); + } + + const findOptions = this.mapToMikroOrmOptions(options); + + const [entities, total] = await this.em.findAndCount(CourseEntity, scope.query, findOptions); + await Promise.all( + entities.map(async (entity: CourseEntity): Promise => { + if (!entity.courseGroups.isInitialized()) { + await entity.courseGroups.init(); + } + }) + ); + + const courses: Course[] = entities.map((entity: CourseEntity): Course => CourseEntityMapper.mapEntityToDo(entity)); + const page: Page = new Page(courses, total); + + return page; + } + + private mapToMikroOrmOptions

(options?: IFindOptions): FindOptions { + const findOptions: FindOptions = { + offset: options?.pagination?.skip, + limit: options?.pagination?.limit, + orderBy: options?.order, + }; + + return findOptions; + } } diff --git a/apps/server/src/modules/learnroom/service/course-do.service.spec.ts b/apps/server/src/modules/learnroom/service/course-do.service.spec.ts index b015194b5ee..a6fae948299 100644 --- a/apps/server/src/modules/learnroom/service/course-do.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-do.service.spec.ts @@ -3,8 +3,18 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Group } from '@modules/group'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions, SortOrder } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { groupFactory } from '@shared/testing'; -import { Course, COURSE_REPO, CourseNotSynchronizedLoggableException, CourseRepo } from '../domain'; +import { + Course, + COURSE_REPO, + CourseAlreadySynchronizedLoggableException, + CourseFilter, + CourseNotSynchronizedLoggableException, + CourseRepo, +} from '../domain'; import { courseFactory } from '../testing'; import { CourseDoService } from './course-do.service'; @@ -169,4 +179,85 @@ describe(CourseDoService.name, () => { }); }); }); + + describe('startSynchronization', () => { + describe('when a course is not synchronized with a group', () => { + const setup = () => { + const course: Course = courseFactory.build(); + const group: Group = groupFactory.build(); + + return { + course, + group, + }; + }; + + it('should save a course with a synchronized group', async () => { + const { course, group } = setup(); + + await service.startSynchronization(course, group); + + expect(courseRepo.save).toHaveBeenCalledWith( + new Course({ + ...course.getProps(), + syncedWithGroup: group.id, + }) + ); + }); + }); + + describe('when a course is synchronized with a group', () => { + const setup = () => { + const course: Course = courseFactory.build({ syncedWithGroup: new ObjectId().toHexString() }); + const group: Group = groupFactory.build(); + + return { + course, + group, + }; + }; + it('should throw an unprocessable entity exception', async () => { + const { course, group } = setup(); + + await expect(service.startSynchronization(course, group)).rejects.toThrow( + CourseAlreadySynchronizedLoggableException + ); + }); + }); + }); + + describe('findCourses', () => { + describe('when course are found', () => { + const setup = () => { + const courses: Course[] = courseFactory.buildList(5); + const schoolId: EntityId = new ObjectId().toHexString(); + const filter: CourseFilter = { schoolId }; + const options: IFindOptions = { + order: { + name: SortOrder.asc, + }, + pagination: { + limit: 2, + skip: 1, + }, + }; + + courseRepo.getCourseInfo.mockResolvedValueOnce(new Page(courses, 5)); + + return { + courses, + schoolId, + filter, + options, + }; + }; + + it('should return the courses by passing filter and options', async () => { + const { courses, filter, options } = setup(); + const result: Page = await service.getCourseInfo(filter, options); + + expect(result.data).toEqual(courses); + }); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/service/course-do.service.ts b/apps/server/src/modules/learnroom/service/course-do.service.ts index 5bcba757d1f..951693dee25 100644 --- a/apps/server/src/modules/learnroom/service/course-do.service.ts +++ b/apps/server/src/modules/learnroom/service/course-do.service.ts @@ -1,8 +1,17 @@ import { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; import { type Group } from '@modules/group'; import { Inject, Injectable } from '@nestjs/common'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { type Course, COURSE_REPO, CourseNotSynchronizedLoggableException, CourseRepo } from '../domain'; +import { + type Course, + COURSE_REPO, + CourseAlreadySynchronizedLoggableException, + CourseFilter, + CourseNotSynchronizedLoggableException, + CourseRepo, +} from '../domain'; @Injectable() export class CourseDoService implements AuthorizationLoaderServiceGeneric { @@ -35,4 +44,20 @@ export class CourseDoService implements AuthorizationLoaderServiceGeneric { + if (course.syncedWithGroup) { + throw new CourseAlreadySynchronizedLoggableException(course.id); + } + + course.syncedWithGroup = group.id; + + await this.courseRepo.save(course); + } + + public async getCourseInfo(filter: CourseFilter, options?: IFindOptions): Promise> { + const courses = await this.courseRepo.getCourseInfo(filter, options); + + return courses; + } } diff --git a/apps/server/src/modules/learnroom/uc/course-info.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-info.uc.spec.ts new file mode 100644 index 00000000000..2369fc6404c --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-info.uc.spec.ts @@ -0,0 +1,311 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { classFactory } from '@modules/class/domain/testing'; +import { GroupService } from '@modules/group'; +import { SchoolService } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; +import { UserService } from '@modules/user'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions, Permission, RoleName, SortOrder } from '@shared/domain/interface'; +import { groupFactory, setupEntities, UserAndAccountTestFactory, userDoFactory, userFactory } from '@shared/testing'; +import { Course, CourseFilter, CourseSortProps, CourseStatus } from '../domain'; +import { CourseDoService } from '../service'; +import { courseFactory as courseDoFactory } from '../testing'; +import { CourseInfoUc } from './course-info.uc'; +import { CourseInfoDto } from './dto'; + +describe('CourseInfoUc', () => { + let module: TestingModule; + let uc: CourseInfoUc; + + let authorizationService: DeepMocked; + let schoolService: DeepMocked; + let courseDoService: DeepMocked; + let groupService: DeepMocked; + let userService: DeepMocked; + let classService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + CourseInfoUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: SchoolService, + useValue: createMock(), + }, + { + provide: CourseDoService, + useValue: createMock(), + }, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(CourseInfoUc); + authorizationService = module.get(AuthorizationService); + schoolService = module.get(SchoolService); + courseDoService = module.get(CourseDoService); + groupService = module.get(GroupService); + userService = module.get(UserService); + classService = module.get(ClassService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getCourseInfo', () => { + describe('when calling getCourseInfo', () => { + const setup = () => { + const user = userFactory.withRoleByName(RoleName.TEACHER).buildWithId(); + const teacher = userDoFactory.build({ id: user.id, firstName: 'firstName', lastName: 'lastName' }); + const { adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.COURSE_ADMINISTRATION]); + const group = groupFactory.build({ name: 'groupName' }); + const clazz = classFactory.build({ name: 'A', gradeLevel: 1 }); + const courses = courseDoFactory.buildList(5, { + syncedWithGroup: group.id, + teacherIds: [user.id], + groupIds: [group.id], + classIds: [clazz.id], + }); + const pagination = { skip: 0, limit: 5 }; + const school = schoolFactory.build(); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValue(adminUser); + authorizationService.checkPermission.mockReturnValueOnce(undefined); + groupService.findById.mockResolvedValue(group); + userService.findById.mockResolvedValue(teacher); + classService.findById.mockResolvedValue(clazz); + courseDoService.getCourseInfo.mockResolvedValueOnce(new Page(courses, 5)); + + return { + user, + courses, + pagination, + school, + adminUser, + group, + clazz, + }; + }; + + it('should call school service getSchoolById', async () => { + const { adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(schoolService.getSchoolById).toHaveBeenCalledWith(school.id); + }); + + it('should call user service getUserWithPermissions', async () => { + const { adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(adminUser.id); + }); + + it('should call authorization service checkPermission', async () => { + const { adminUser, school } = setup(); + const expectedPermissions = { + action: 'read', + requiredPermissions: ['COURSE_ADMINISTRATION'], + }; + await uc.getCourseInfo(adminUser.id, school.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(adminUser, school, expectedPermissions); + }); + + it('should call user service findById', async () => { + const { user, adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(userService.findById).toHaveBeenCalledWith(user.id); + }); + + it('should call class service findById', async () => { + const { clazz, adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(classService.findById).toHaveBeenCalledWith(clazz.id); + }); + it('should call group service findById', async () => { + const { group, adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(groupService.findById).toHaveBeenCalledWith(group.id); + }); + + it('should call with default options', async () => { + const { adminUser, school } = setup(); + const filter: CourseFilter = { schoolId: school.id, status: undefined }; + const options: IFindOptions = { + order: { + name: SortOrder.asc, + }, + pagination: undefined, + }; + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(courseDoService.getCourseInfo).toHaveBeenCalledWith(filter, options); + }); + + it('should call with non-default options and filter', async () => { + const { school, adminUser } = setup(); + const filter: CourseFilter = { schoolId: school.id, status: CourseStatus.ARCHIVE }; + const options: IFindOptions = { + order: { + name: SortOrder.asc, + }, + pagination: { skip: 0, limit: 5 }, + }; + + await uc.getCourseInfo( + adminUser.id, + school.id, + CourseSortProps.NAME, + CourseStatus.ARCHIVE, + { skip: 0, limit: 5 }, + SortOrder.asc + ); + + expect(courseDoService.getCourseInfo).toHaveBeenCalledWith(filter, options); + }); + }); + + describe('when courses are found', () => { + const setup = () => { + const user = userFactory.withRoleByName(RoleName.TEACHER).buildWithId(); + const teacher = userDoFactory.build({ id: user.id, firstName: 'firstName', lastName: 'lastName' }); + const { adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.COURSE_ADMINISTRATION]); + const group = groupFactory.build({ name: 'groupName' }); + const clazz = classFactory.build({ name: 'A', gradeLevel: 1 }); + const course1 = courseDoFactory.build({ + id: 'course1', + name: 'course1', + syncedWithGroup: group.id, + teacherIds: [user.id], + groupIds: [group.id], + classIds: [clazz.id], + }); + const course2 = courseDoFactory.build({ + id: 'course2', + name: 'course2', + teacherIds: [user.id], + groupIds: [group.id], + classIds: [clazz.id], + }); + const school = schoolFactory.build(); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValue(adminUser); + authorizationService.checkPermission.mockReturnValueOnce(undefined); + groupService.findById.mockResolvedValue(group); + userService.findById.mockResolvedValue(teacher); + classService.findById.mockResolvedValue(clazz); + courseDoService.getCourseInfo.mockResolvedValueOnce(new Page([course1, course2], 1)); + + return { + school, + adminUser, + }; + }; + + it('should return courses with sorted and filtered results', async () => { + const { school, adminUser } = setup(); + + const result: Page = await uc.getCourseInfo(adminUser.id, school.id); + + expect(result.data[0]).toMatchObject({ + id: 'course1', + name: 'course1', + teachers: ['firstName lastName'], + classes: ['1A', 'groupName'], + syncedGroupName: 'groupName', + }); + expect(result.data[1]).toMatchObject({ + id: 'course2', + name: 'course2', + teachers: ['firstName lastName'], + classes: ['1A', 'groupName'], + syncedGroupName: undefined, + }); + }); + }); + + describe('when user does not have permission', () => { + const setup = () => { + const { adminUser } = UserAndAccountTestFactory.buildAdmin({}, []); + const school = schoolFactory.build(); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValue(adminUser); + authorizationService.checkPermission.mockImplementationOnce(() => { + throw new ForbiddenException(); + }); + + return { + school, + adminUser, + }; + }; + it('should throw an forbidden exception', async () => { + const { school, adminUser } = setup(); + + const getCourseInfo = async () => uc.getCourseInfo(adminUser.id, school.id); + + await expect(getCourseInfo()).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when courses are not found', () => { + const setup = () => { + const adminUserId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toHexString(); + courseDoService.getCourseInfo.mockResolvedValueOnce(new Page([], 0)); + + return { + adminUserId, + schoolId, + }; + }; + + it('should return an empty page if no courses are found', async () => { + const { adminUserId, schoolId } = setup(); + + const result = await uc.getCourseInfo(adminUserId, schoolId); + + expect(result.total).toBe(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/uc/course-info.uc.ts b/apps/server/src/modules/learnroom/uc/course-info.uc.ts new file mode 100644 index 00000000000..e133a86616d --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-info.uc.ts @@ -0,0 +1,119 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { Group, GroupService } from '@modules/group'; +import { School, SchoolService } from '@modules/school'; +import { UserService } from '@modules/user'; +import { Injectable } from '@nestjs/common'; +import { Page, UserDO } from '@shared/domain/domainobject'; +import { User } from '@shared/domain/entity'; +import { IFindOptions, Pagination, Permission, SortOrder, SortOrderMap } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { Course as CourseDO } from '../domain'; +import { CourseFilter, CourseStatus } from '../domain/interface'; +import { CourseSortProps } from '../domain/interface/course-sort-props.enum'; +import { CourseDoService } from '../service'; +import { CourseInfoDto } from './dto'; + +@Injectable() +export class CourseInfoUc { + public constructor( + private readonly authService: AuthorizationService, + private readonly schoolService: SchoolService, + private readonly courseDoService: CourseDoService, + private readonly groupService: GroupService, + private readonly userService: UserService, + private readonly classService: ClassService + ) {} + + public async getCourseInfo( + userId: EntityId, + schoolId: EntityId, + sortByField: CourseSortProps = CourseSortProps.NAME, + courseStatusQueryType?: CourseStatus, + pagination?: Pagination, + sortOrder: SortOrder = SortOrder.asc + ): Promise> { + const school: School = await this.schoolService.getSchoolById(schoolId); + + const user: User = await this.authService.getUserWithPermissions(userId); + this.authService.checkPermission( + user, + school, + AuthorizationContextBuilder.read([Permission.COURSE_ADMINISTRATION]) + ); + + const order: SortOrderMap = { [sortByField]: sortOrder }; + const filter: CourseFilter = { schoolId, status: courseStatusQueryType }; + const options: IFindOptions = { pagination, order }; + const courses: Page = await this.courseDoService.getCourseInfo(filter, options); + + const resolvedCourses: CourseInfoDto[] = await this.getCourseData(courses.data); + + const page = new Page(resolvedCourses, courses.total); + + return page; + } + + private async getCourseData(courses: CourseDO[]): Promise { + const courseInfos: CourseInfoDto[] = await Promise.all( + courses.map(async (course) => { + const groupName = course.syncedWithGroup ? await this.getSyncedGroupName(course.syncedWithGroup) : undefined; + const teacherNames: string[] = await this.getCourseTeacherFullNames(course.teachers); + const classNames: string[] = await this.getCourseClassNamaes(course.classes); + const groupNames: string[] = await this.getCourseGroupNames(course.groups); + + const mapped = new CourseInfoDto({ + id: course.id, + name: course.name, + classes: [...classNames, ...groupNames], + teachers: teacherNames, + syncedGroupName: groupName, + }); + + return mapped; + }) + ); + + return courseInfos; + } + + private async getSyncedGroupName(groupId: EntityId): Promise { + const group: Group = await this.groupService.findById(groupId); + + return group.name; + } + + private async getCourseTeacherFullNames(teacherIds: EntityId[]): Promise { + const teacherNames: string[] = await Promise.all( + teacherIds.map(async (teacherId): Promise => { + const teacher: UserDO = await this.userService.findById(teacherId); + const fullName = teacher.firstName.concat(' ', teacher.lastName); + + return fullName; + }) + ); + return teacherNames; + } + + private async getCourseClassNamaes(classIds: EntityId[]): Promise { + const classes: string[] = await Promise.all[]>( + classIds.map(async (classId): Promise => { + const clazz = await this.classService.findById(classId); + + return clazz.getClassFullName(); + }) + ); + return classes; + } + + private async getCourseGroupNames(groupIds: EntityId[]): Promise { + const groups: string[] = await Promise.all[]>( + groupIds.map(async (groupId): Promise => { + const group = await this.groupService.findById(groupId); + + return group.name; + }) + ); + return groups; + } +} diff --git a/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts index f9171895b9d..60366ab3065 100644 --- a/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts @@ -1,8 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { GroupService } from '@modules/group'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { setupEntities, userFactory } from '@shared/testing'; +import { groupFactory, setupEntities, userFactory } from '@shared/testing'; import { CourseDoService } from '../service'; import { courseFactory } from '../testing'; import { CourseSyncUc } from './course-sync.uc'; @@ -13,6 +14,7 @@ describe(CourseSyncUc.name, () => { let authorizationService: DeepMocked; let courseService: DeepMocked; + let groupService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -26,12 +28,17 @@ describe(CourseSyncUc.name, () => { provide: CourseDoService, useValue: createMock(), }, + { + provide: GroupService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(CourseSyncUc); authorizationService = module.get(AuthorizationService); courseService = module.get(CourseDoService); + groupService = module.get(GroupService); await setupEntities(); }); @@ -79,4 +86,48 @@ describe(CourseSyncUc.name, () => { }); }); }); + + describe('startSynchronization', () => { + describe('when a user starts a synchronization of a course with a group', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.build(); + const group = groupFactory.build(); + + courseService.findById.mockResolvedValueOnce(course); + groupService.findById.mockResolvedValueOnce(group); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + course, + group, + }; + }; + + it('should check the users permission', async () => { + const { user, course, group } = setup(); + + await uc.startSynchronization(user.id, course.id, group.id); + + expect(courseService.findById).toHaveBeenCalledWith(course.id); + expect(groupService.findById).toHaveBeenCalledWith(group.id); + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + course, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + }); + + it('should start the synchronization', async () => { + const { user, course, group } = setup(); + + await uc.startSynchronization(user.id, course.id, group.id); + expect(courseService.findById).toHaveBeenCalledWith(course.id); + expect(groupService.findById).toHaveBeenCalledWith(group.id); + + expect(courseService.startSynchronization).toHaveBeenCalledWith(course, group); + }); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/uc/course-sync.uc.ts b/apps/server/src/modules/learnroom/uc/course-sync.uc.ts index 5ffe55abe3e..53a4fe2e173 100644 --- a/apps/server/src/modules/learnroom/uc/course-sync.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-sync.uc.ts @@ -1,4 +1,5 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { Group, GroupService } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { type User as UserEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; @@ -10,7 +11,8 @@ import { CourseDoService } from '../service'; export class CourseSyncUc { constructor( private readonly authorizationService: AuthorizationService, - private readonly courseService: CourseDoService + private readonly courseService: CourseDoService, + private readonly groupService: GroupService ) {} public async stopSynchronization(userId: EntityId, courseId: EntityId): Promise { @@ -25,4 +27,18 @@ export class CourseSyncUc { await this.courseService.stopSynchronization(course); } + + public async startSynchronization(userId: string, courseId: string, groupId: string) { + const course: Course = await this.courseService.findById(courseId); + const group: Group = await this.groupService.findById(groupId); + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + + this.authorizationService.checkPermission( + user, + course, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + + await this.courseService.startSynchronization(course, group); + } } diff --git a/apps/server/src/modules/learnroom/uc/dto/course-info.dto.ts b/apps/server/src/modules/learnroom/uc/dto/course-info.dto.ts new file mode 100644 index 00000000000..a26fe97612a --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/dto/course-info.dto.ts @@ -0,0 +1,21 @@ +import { EntityId } from '@shared/domain/types'; + +export class CourseInfoDto { + id: EntityId; + + name: string; + + teachers: string[]; + + classes: string[]; + + syncedGroupName?: string; + + constructor(props: CourseInfoDto) { + this.id = props.id; + this.name = props.name; + this.classes = props.classes; + this.teachers = props.teachers; + this.syncedGroupName = props.syncedGroupName; + } +} diff --git a/apps/server/src/modules/learnroom/uc/dto/index.ts b/apps/server/src/modules/learnroom/uc/dto/index.ts new file mode 100644 index 00000000000..3632d17b639 --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/dto/index.ts @@ -0,0 +1 @@ +export { CourseInfoDto } from './course-info.dto'; diff --git a/apps/server/src/modules/learnroom/uc/index.ts b/apps/server/src/modules/learnroom/uc/index.ts index c50d28e74d7..6c5de6d226a 100644 --- a/apps/server/src/modules/learnroom/uc/index.ts +++ b/apps/server/src/modules/learnroom/uc/index.ts @@ -2,6 +2,7 @@ export * from './course-copy.uc'; export * from './course-export.uc'; export * from './course-import.uc'; export * from './course-sync.uc'; +export * from './course-info.uc'; export * from './course.uc'; export * from './dashboard.uc'; export * from './lesson-copy.uc'; diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index 2642d2d6761..d92b29aed4d 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -32,6 +32,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: boolean; + @ApiProperty() + FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED: boolean; + @ApiProperty() FEATURE_CTL_TOOLS_COPY_ENABLED: boolean; @@ -134,6 +137,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_USER_MIGRATION_ENABLED: boolean; + @ApiProperty() + CALENDAR_SERVICE_ENABLED: boolean; + @ApiProperty() FEATURE_COPY_SERVICE_ENABLED: boolean; @@ -225,6 +231,7 @@ export class ConfigResponse { this.ACCESSIBILITY_REPORT_EMAIL = config.ACCESSIBILITY_REPORT_EMAIL; this.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN = config.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN; this.ALERT_STATUS_URL = config.ALERT_STATUS_URL; + this.CALENDAR_SERVICE_ENABLED = config.CALENDAR_SERVICE_ENABLED; this.FEATURE_ES_COLLECTIONS_ENABLED = config.FEATURE_ES_COLLECTIONS_ENABLED; this.FEATURE_EXTENSIONS_ENABLED = config.FEATURE_EXTENSIONS_ENABLED; this.FEATURE_TEAMS_ENABLED = config.FEATURE_TEAMS_ENABLED; @@ -279,6 +286,7 @@ export class ConfigResponse { this.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION = config.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION; this.CTL_TOOLS_RELOAD_TIME_MS = config.CTL_TOOLS_RELOAD_TIME_MS; this.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED; + this.FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED; this.FEATURE_CTL_TOOLS_COPY_ENABLED = config.FEATURE_CTL_TOOLS_COPY_ENABLED; this.FEATURE_SHOW_MIGRATION_WIZARD = config.FEATURE_SHOW_MIGRATION_WIZARD; this.MIGRATION_WIZARD_DOCUMENTATION_LINK = config.MIGRATION_WIZARD_DOCUMENTATION_LINK; diff --git a/apps/server/src/modules/server/api/test/server.api.spec.ts b/apps/server/src/modules/server/api/test/server.api.spec.ts index 58fe1d2f799..5627d34e17e 100644 --- a/apps/server/src/modules/server/api/test/server.api.spec.ts +++ b/apps/server/src/modules/server/api/test/server.api.spec.ts @@ -38,6 +38,7 @@ describe('Server Controller (API)', () => { 'ADMIN_TABLES_DISPLAY_CONSENT_COLUMN', 'ALERT_STATUS_URL', 'CTL_TOOLS_RELOAD_TIME_MS', + 'CALENDAR_SERVICE_ENABLED', 'DOCUMENT_BASE_DIR', 'FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED', 'FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED', @@ -70,6 +71,7 @@ describe('Server Controller (API)', () => { 'FEATURE_SCHOOL_TERMS_OF_USE_ENABLED', 'FEATURE_SHOW_MIGRATION_WIZARD', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', + 'FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED', 'FEATURE_SHOW_OUTDATED_USERS', 'FEATURE_TASK_SHARE', 'FEATURE_TEAMS_ENABLED', diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 147ad77b200..8d392ce1cb2 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -72,6 +72,7 @@ export interface ServerConfig ACCESSIBILITY_REPORT_EMAIL: string; ADMIN_TABLES_DISPLAY_CONSENT_COLUMN: boolean; ALERT_STATUS_URL: string | null; + CALENDAR_SERVICE_ENABLED: boolean; FEATURE_ES_COLLECTIONS_ENABLED: boolean; FEATURE_EXTENSIONS_ENABLED: boolean; FEATURE_TEAMS_ENABLED: boolean; @@ -105,6 +106,7 @@ export interface ServerConfig FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED: boolean; FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: boolean; FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: boolean; + FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED: boolean; FEATURE_SHOW_MIGRATION_WIZARD: boolean; MIGRATION_WIZARD_DOCUMENTATION_LINK?: string; FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: boolean; @@ -132,6 +134,7 @@ const config: ServerConfig = { Configuration.get('ALERT_STATUS_URL') === null ? (Configuration.get('ALERT_STATUS_URL') as null) : (Configuration.get('ALERT_STATUS_URL') as string), + CALENDAR_SERVICE_ENABLED: Configuration.get('CALENDAR_SERVICE_ENABLED') as boolean, FEATURE_ES_COLLECTIONS_ENABLED: Configuration.get('FEATURE_ES_COLLECTIONS_ENABLED') as boolean, FEATURE_EXTENSIONS_ENABLED: Configuration.get('FEATURE_EXTENSIONS_ENABLED') as boolean, FEATURE_TEAMS_ENABLED: Configuration.get('FEATURE_TEAMS_ENABLED') as boolean, @@ -221,6 +224,7 @@ const config: ServerConfig = { FEATURE_SHOW_OUTDATED_USERS: Configuration.get('FEATURE_SHOW_OUTDATED_USERS') as boolean, FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: Configuration.get('FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION') as boolean, FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: Configuration.get('FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED') as boolean, + FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED: Configuration.get('FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED') as boolean, FEATURE_SHOW_MIGRATION_WIZARD: Configuration.get('FEATURE_SHOW_MIGRATION_WIZARD') as boolean, FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: Configuration.get( 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED' diff --git a/apps/server/src/shared/domain/entity/course.entity.ts b/apps/server/src/shared/domain/entity/course.entity.ts index 84a9005e48e..3bad3a7e62a 100644 --- a/apps/server/src/shared/domain/entity/course.entity.ts +++ b/apps/server/src/shared/domain/entity/course.entity.ts @@ -120,7 +120,7 @@ export class Course extends BaseEntityWithTimestamps implements Learnroom, Entit if (props.features) this.features = props.features; this.classes.set(props.classes || []); this.groups.set(props.groups || []); - this.syncedWithGroup = props.syncedWithGroup; + if (props.syncedWithGroup) this.syncedWithGroup = props.syncedWithGroup; } public getStudentIds(): EntityId[] { diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index d85ed328e55..70d57507164 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -26,6 +26,7 @@ export enum Permission { CONTEXT_TOOL_USER = 'CONTEXT_TOOL_USER', COURSEGROUP_CREATE = 'COURSEGROUP_CREATE', COURSEGROUP_EDIT = 'COURSEGROUP_EDIT', + COURSE_ADMINISTRATION = 'COURSE_ADMINISTRATION', COURSE_CREATE = 'COURSE_CREATE', COURSE_DELETE = 'COURSE_DELETE', COURSE_EDIT = 'COURSE_EDIT', diff --git a/apps/server/src/shared/repo/course/course.repo.ts b/apps/server/src/shared/repo/course/course.repo.ts index fb52b8c115e..ebe1074db02 100644 --- a/apps/server/src/shared/repo/course/course.repo.ts +++ b/apps/server/src/shared/repo/course/course.repo.ts @@ -1,56 +1,11 @@ -import { FilterQuery, QueryOrderMap } from '@mikro-orm/core'; +import { QueryOrderMap } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; import { Course } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; import { BaseRepo } from '../base.repo'; -import { Scope } from '../scope'; - -class CourseScope extends Scope { - forAllGroupTypes(userId: EntityId): CourseScope { - const isStudent = { students: userId }; - const isTeacher = { teachers: userId }; - const isSubstitutionTeacher = { substitutionTeachers: userId }; - - if (userId) { - this.addQuery({ $or: [isStudent, isTeacher, isSubstitutionTeacher] }); - } - - return this; - } - - forTeacherOrSubstituteTeacher(userId: EntityId): CourseScope { - const isTeacher = { teachers: userId }; - const isSubstitutionTeacher = { substitutionTeachers: userId }; - - if (userId) { - this.addQuery({ $or: [isTeacher, isSubstitutionTeacher] }); - } - - return this; - } - - forTeacher(userId: EntityId): CourseScope { - this.addQuery({ teachers: userId }); - return this; - } - - forActiveCourses(): CourseScope { - const now = new Date(); - const noUntilDate = { untilDate: { $exists: false } } as FilterQuery; - const untilDateInFuture = { untilDate: { $gte: now } }; - - this.addQuery({ $or: [noUntilDate, untilDateInFuture] }); - - return this; - } - - forCourseId(courseId: EntityId): CourseScope { - this.addQuery({ id: courseId }); - return this; - } -} +import { CourseScope } from './course.scope'; @Injectable() export class CourseRepo extends BaseRepo { diff --git a/apps/server/src/shared/repo/course/course.scope.spec.ts b/apps/server/src/shared/repo/course/course.scope.spec.ts new file mode 100644 index 00000000000..84d0a22767d --- /dev/null +++ b/apps/server/src/shared/repo/course/course.scope.spec.ts @@ -0,0 +1,202 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { CourseScope } from './course.scope'; + +describe(CourseScope.name, () => { + let scope: CourseScope; + + beforeEach(() => { + scope = new CourseScope(); + scope.allowEmptyQuery(true); + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + describe('forAllGroupTypes', () => { + describe('when id is defined', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const isStudent = { students: userId }; + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + return { + userId, + isStudent, + isTeacher, + isSubstitutionTeacher, + }; + }; + + it('should add query', () => { + const { userId, isStudent, isTeacher, isSubstitutionTeacher } = setup(); + + scope.forAllGroupTypes(userId); + + expect(scope.query).toEqual({ $or: [isStudent, isTeacher, isSubstitutionTeacher] }); + }); + }); + }); + + describe('forTeacherOrSubstituteTeacher', () => { + describe('when id is defined', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + return { + userId, + isTeacher, + isSubstitutionTeacher, + }; + }; + + it('should add query', () => { + const { userId, isTeacher, isSubstitutionTeacher } = setup(); + + scope.forTeacherOrSubstituteTeacher(userId); + + expect(scope.query).toEqual({ $or: [isTeacher, isSubstitutionTeacher] }); + }); + }); + }); + + describe('forTeacher', () => { + describe('when id is defined', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const isTeacher = { teachers: userId }; + + return { + userId, + isTeacher, + }; + }; + + it('should add query', () => { + const { userId } = setup(); + + scope.forTeacher(userId); + + expect(scope.query).toEqual({ teachers: userId }); + }); + }); + }); + + describe('forActiveCourses', () => { + describe('when called', () => { + const setup = () => { + const now = new Date(); + + const noUntilDate = { untilDate: { $exists: false } }; + const untilDateInFuture = { untilDate: { $gte: now } }; + + return { + noUntilDate, + untilDateInFuture, + }; + }; + + it('should add query', () => { + const { noUntilDate, untilDateInFuture } = setup(); + + scope.forActiveCourses(); + + expect(scope.query).toEqual({ $or: [noUntilDate, untilDateInFuture] }); + }); + }); + }); + + describe('forCourseId', () => { + describe('when id is defined', () => { + const setup = () => { + const courseId = new ObjectId().toHexString(); + + return { courseId }; + }; + + it('should add query', () => { + const { courseId } = setup(); + + scope.forCourseId(courseId); + + expect(scope.query).toEqual({ id: courseId }); + }); + }); + }); + + describe('bySchoolId', () => { + describe('when id is defined', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + + return { schoolId }; + }; + + it('should add query', () => { + const { schoolId } = setup(); + + scope.bySchoolId(schoolId); + + expect(scope.query).toEqual({ school: schoolId }); + }); + }); + + describe('when id is not defined', () => { + it('should add query', () => { + scope.bySchoolId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + }); + + describe('bySchoolId', () => { + describe('when id is defined', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + + return { schoolId }; + }; + + it('should add query', () => { + const { schoolId } = setup(); + + scope.bySchoolId(schoolId); + + expect(scope.query).toEqual({ school: schoolId }); + }); + }); + + describe('when id is not defined', () => { + it('should add query', () => { + scope.bySchoolId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + }); + + describe('forArchivedCourses', () => { + describe('when called', () => { + const setup = () => { + const now = new Date(); + const untilDateExists = { untilDate: { $exists: true } }; + const untilDateInPast = { untilDate: { $lt: now } }; + + return { + untilDateExists, + untilDateInPast, + }; + }; + + it('should add query', () => { + const { untilDateExists, untilDateInPast } = setup(); + + scope.forArchivedCourses(); + + expect(scope.query).toEqual({ $and: [untilDateExists, untilDateInPast] }); + }); + }); + }); +}); diff --git a/apps/server/src/shared/repo/course/course.scope.ts b/apps/server/src/shared/repo/course/course.scope.ts new file mode 100644 index 00000000000..35bcb8cdfe2 --- /dev/null +++ b/apps/server/src/shared/repo/course/course.scope.ts @@ -0,0 +1,67 @@ +import { FilterQuery } from '@mikro-orm/core'; + +import { Course } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { Scope } from '../scope'; + +export class CourseScope extends Scope { + forAllGroupTypes(userId: EntityId): this { + const isStudent = { students: userId }; + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + if (userId) { + this.addQuery({ $or: [isStudent, isTeacher, isSubstitutionTeacher] }); + } + + return this; + } + + forTeacherOrSubstituteTeacher(userId: EntityId): this { + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + if (userId) { + this.addQuery({ $or: [isTeacher, isSubstitutionTeacher] }); + } + + return this; + } + + forTeacher(userId: EntityId): this { + this.addQuery({ teachers: userId }); + return this; + } + + forActiveCourses(): this { + const now = new Date(); + const noUntilDate = { untilDate: { $exists: false } } as FilterQuery; + const untilDateInFuture = { untilDate: { $gte: now } }; + + this.addQuery({ $or: [noUntilDate, untilDateInFuture] }); + + return this; + } + + forCourseId(courseId: EntityId): this { + this.addQuery({ id: courseId }); + return this; + } + + bySchoolId(schoolId: EntityId | undefined): this { + if (schoolId) { + this.addQuery({ school: schoolId }); + } + return this; + } + + forArchivedCourses(): this { + const now = new Date(); + const untilDateExists = { untilDate: { $exists: true } } as FilterQuery; + const untilDateInPast = { untilDate: { $lt: now } }; + + this.addQuery({ $and: [untilDateExists, untilDateInPast] }); + + return this; + } +} diff --git a/apps/server/src/shared/repo/course/index.ts b/apps/server/src/shared/repo/course/index.ts index f2a743f00e6..1e8ed2bf9f5 100644 --- a/apps/server/src/shared/repo/course/index.ts +++ b/apps/server/src/shared/repo/course/index.ts @@ -1 +1,2 @@ export * from './course.repo'; +export * from './course.scope'; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 65915f515f2..2a7feac0983 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -205,5 +205,14 @@ "created_at": { "$date": "2024-07-25T14:57:30.752Z" } + }, + { + "_id": { + "$oid": "66c8a9d1d2ae9ba6c4b43c5d" + }, + "name": "Migration20240823151836", + "created_at": { + "$date": "2024-08-23T15:25:05.360Z" + } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 03922f4ff3b..8de703b913b 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -90,6 +90,7 @@ "COURSE_CREATE", "COURSE_EDIT", "COURSE_REMOVE", + "COURSE_ADMINISTRATION", "DATASOURCES_CREATE", "DATASOURCES_DELETE", "DATASOURCES_EDIT", diff --git a/config/default.schema.json b/config/default.schema.json index 7b28b3ff4fc..5ba007e6743 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1393,6 +1393,11 @@ "default": false, "description": "Enables the new class list view" }, + "FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the new course list view" + }, "FEATURE_GROUPS_IN_COURSE_ENABLED": { "type": "boolean", "default": false, @@ -1661,6 +1666,11 @@ "default": false, "description": "Enables the AI Tutor" }, + "CALENDAR_SERVICE_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables calender service" + }, "FEATURE_ROOMS_ENABLED": { "type": "boolean", "default": "false", diff --git a/src/services/user-group/services/courses.js b/src/services/user-group/services/courses.js index 7f773999f60..2d358210e6e 100644 --- a/src/services/user-group/services/courses.js +++ b/src/services/user-group/services/courses.js @@ -1,5 +1,6 @@ const { authenticate } = require('@feathersjs/authentication'); const { iff, isProvider } = require('feathers-hooks-common'); +const { Configuration } = require('@hpi-schul-cloud/commons/lib'); const { ifNotLocal, restrictToCurrentSchool, @@ -34,6 +35,8 @@ const { const { checkScopePermissions } = require('../../helpers/scopePermissions/hooks'); +const newRoomViewEnabled = Configuration.get('FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED'); + class Courses { constructor(options) { this.options = options || {}; @@ -60,6 +63,9 @@ class Courses { } remove(id, params) { + if (newRoomViewEnabled) { + this.app.service('/calendar/courses').remove(id, prepareInternalParams(params)); + } return this.app.service('courseModel').remove(id, prepareInternalParams(params)); } From af3e711f3704d871ccea9b3dce92ff84a98e0f81 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Wed, 4 Sep 2024 11:33:53 +0200 Subject: [PATCH 16/29] BC-7881 - Create migration job for existing tldraw-docs from MongoDB to S3 (#5212) --- .../schulcloud-server-core/tasks/main.yml | 9 +++ .../templates/tldraw-migration-job.yml.j2 | 43 ++++++++++ apps/server/src/modules/tldraw/config.ts | 15 ++++ apps/server/src/modules/tldraw/job/index.ts | 3 +- .../job/tldraw-migration.console.spec.ts | 81 +++++++++++++++++++ .../tldraw/job/tldraw-migration.console.ts | 71 ++++++++++++++++ .../modules/tldraw/tldraw-console.module.ts | 15 +++- 7 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 ansible/roles/schulcloud-server-core/templates/tldraw-migration-job.yml.j2 create mode 100644 apps/server/src/modules/tldraw/job/tldraw-migration.console.spec.ts create mode 100644 apps/server/src/modules/tldraw/job/tldraw-migration.console.ts diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 17687a9cceb..ffb6e8ad0da 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -175,6 +175,15 @@ tags: - ingress + - name: tldaraw migration Job + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-migration-job.yml.j2 + state: "{{ 'present' if WITH_TLDRAW2 else 'absent'}}" + 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 new file mode 100644 index 00000000000..b18a4e7e934 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-migration-job.yml.j2 @@ -0,0 +1,43 @@ +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 diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts index 534ecbc06aa..a691d758292 100644 --- a/apps/server/src/modules/tldraw/config.ts +++ b/apps/server/src/modules/tldraw/config.ts @@ -1,4 +1,5 @@ import { Configuration } from '@hpi-schul-cloud/commons'; +import { env } from 'process'; export interface TldrawConfig { TLDRAW_DB_URL: string; @@ -23,6 +24,20 @@ export interface TldrawConfig { export const TLDRAW_DB_URL: string = Configuration.get('TLDRAW_DB_URL') as string; export const TLDRAW_SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; +export const S3_CONNECTION_NAME = 'tldraw-s3'; +// we need to check if the endpoint is production or not +const s3Endpoint = env.S3_ENDPOINT || ''; +const endpoint = env.NODE_ENV === 'production' ? `https://${s3Endpoint}` : s3Endpoint; +// There are temporary configurations for S3 it should read directly from env +export const tldrawS3Config = { + connectionName: S3_CONNECTION_NAME, + endpoint, + region: env.S3_REGION as string, + bucket: env.S3_BUCKET as string, + accessKeyId: env.S3_ACCESS_KEY as string, + secretAccessKey: env.S3_SECRET_KEY as string, +}; + const tldrawConfig = { TLDRAW_DB_URL, NEST_LOG_LEVEL: Configuration.get('TLDRAW__LOG_LEVEL') as string, diff --git a/apps/server/src/modules/tldraw/job/index.ts b/apps/server/src/modules/tldraw/job/index.ts index 7ab9c738039..64931538b48 100644 --- a/apps/server/src/modules/tldraw/job/index.ts +++ b/apps/server/src/modules/tldraw/job/index.ts @@ -1 +1,2 @@ -export * from './tldraw-files.console'; +export * from './tldraw-files.console'; +export * from './tldraw-migration.console'; diff --git a/apps/server/src/modules/tldraw/job/tldraw-migration.console.spec.ts b/apps/server/src/modules/tldraw/job/tldraw-migration.console.spec.ts new file mode 100644 index 00000000000..71e86cf7059 --- /dev/null +++ b/apps/server/src/modules/tldraw/job/tldraw-migration.console.spec.ts @@ -0,0 +1,81 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacyLogger } from '@src/core/logger'; +import { S3_CONNECTION_NAME } from '../config'; +import { WsSharedDocDo } from '../domain'; +import { YMongodb } from '../repo'; +import { TldrawMigrationConsole } from './tldraw-migration.console'; + +jest.mock('yjs', () => { + return { + Doc: jest.fn(), + encodeStateAsUpdateV2: jest.fn().mockReturnValue('encodedState'), + }; +}); + +describe(TldrawMigrationConsole.name, () => { + let console: TldrawMigrationConsole; + let yMongodb: DeepMocked; + let s3Adapter: DeepMocked; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TldrawMigrationConsole, + { + provide: S3_CONNECTION_NAME, + useValue: createMock(), + }, + { + provide: YMongodb, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(TldrawMigrationConsole); + s3Adapter = module.get(S3_CONNECTION_NAME); + yMongodb = module.get(YMongodb); + }); + + it('should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('migrate', () => { + it('should migrate all documents', async () => { + const docNames = ['doc1', 'doc2']; + const doc1 = { + name: 'doc1', + store: { pendingStructs: 'pendingStructs', pendingDs: 'pendingDs' }, + } as unknown as WsSharedDocDo; + const doc2 = { + name: 'doc2', + store: { pendingStructs: 'pendingStructs', pendingDs: 'pendingDs' }, + } as unknown as WsSharedDocDo; + yMongodb.getAllDocumentNames.mockResolvedValue(docNames); + yMongodb.getDocument.mockImplementation((docName: string) => { + if (docName === 'doc1') { + return Promise.resolve(doc1); + } + if (docName === 'doc2') { + return Promise.resolve(doc2); + } + throw new Error('Invalid docName'); + }); + s3Adapter.create.mockImplementation((key) => Promise.resolve({ Key: key } as any)); + + const result = await console.migrate(1); + + expect(result).toEqual(['doc1/index/doc1', 'doc2/index/doc2']); + expect(yMongodb.getAllDocumentNames).toBeCalledTimes(1); + expect(yMongodb.getDocument).toBeCalledTimes(2); + expect(s3Adapter.create).toBeCalledTimes(2); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/job/tldraw-migration.console.ts b/apps/server/src/modules/tldraw/job/tldraw-migration.console.ts new file mode 100644 index 00000000000..58df75de722 --- /dev/null +++ b/apps/server/src/modules/tldraw/job/tldraw-migration.console.ts @@ -0,0 +1,71 @@ +import { S3ClientAdapter } from '@infra/s3-client'; +import { Inject } from '@nestjs/common'; +import { LegacyLogger } from '@src/core/logger'; +import { Command, Console } from 'nestjs-console'; +import { Readable } from 'stream'; +import { Doc, encodeStateAsUpdateV2 } from 'yjs'; +import { S3_CONNECTION_NAME } from '../config'; +import { YMongodb } from '../repo'; + +export const encodeS3ObjectName = (docName: string) => + `${encodeURIComponent(docName)}/index/${encodeURIComponent(docName)}`; + +@Console({ command: 'migration', description: 'tldraw migrate from mongodb to s3' }) +export class TldrawMigrationConsole { + constructor( + private readonly tldrawBoardRepo: YMongodb, + private logger: LegacyLogger, + @Inject(S3_CONNECTION_NAME) private readonly storageClient: S3ClientAdapter + ) { + this.logger.setContext(TldrawMigrationConsole.name); + } + + @Command({ + command: 'run [chunks]', + description: 'execute migrate', + }) + async migrate(chunks = 100): Promise { + const affectedDocs: Array = []; + + this.logger.log(`Start tldraw migration form mongodb to s3`); + const docNames = await this.tldrawBoardRepo.getAllDocumentNames(); + + const docNameChunks = this.chunk(docNames, chunks); + for await (const docNameChunk of docNameChunks) { + const promises = docNameChunk.map(async (docName) => { + const result = await this.tldrawBoardRepo.getDocument(docName); + + const { name, connections, awareness, awarenessChannel, isFinalizing, ...doc } = result; + + if (result.store.pendingStructs) { + this.logger.log(`Remove pendingStructs from ${docName}`); + result.store.pendingStructs = null; + result.store.pendingDs = null; + } + + const file = { + data: Readable.from(Buffer.from(encodeStateAsUpdateV2(doc as Doc))), + mimeType: 'binary/octet-stream', + }; + + const res = await this.storageClient.create(encodeS3ObjectName(docName), file); + if ('Key' in res) { + affectedDocs.push(res.Key as string); + } + this.logger.log(res); + }); + + await Promise.all(promises); + } + + this.logger.log(`Found ${docNames.length} tldraw docs in mongodb`); + this.logger.log(`migration job finished with ${affectedDocs.length} affected docs`); + + return affectedDocs; + } + + private chunk(array: string[], size: number): string[][] { + const r = Array(Math.ceil(array.length / size)).fill(null); + return r.map((e, i) => array.slice(i * size, i * size + size)); + } +} diff --git a/apps/server/src/modules/tldraw/tldraw-console.module.ts b/apps/server/src/modules/tldraw/tldraw-console.module.ts index 80cddece592..cc25308ce27 100644 --- a/apps/server/src/modules/tldraw/tldraw-console.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-console.module.ts @@ -1,5 +1,6 @@ import { ConsoleWriterModule } from '@infra/console'; import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { S3ClientModule } from '@infra/s3-client'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { Module, NotFoundException } from '@nestjs/common'; @@ -10,9 +11,9 @@ import { CoreModule } from '@src/core'; import { Logger, LoggerModule } from '@src/core/logger'; import { ConsoleModule } from 'nestjs-console'; import { FilesStorageClientModule } from '../files-storage-client'; -import { config, TldrawConfig, TLDRAW_DB_URL } from './config'; +import { config, TLDRAW_DB_URL, TldrawConfig, tldrawS3Config } from './config'; import { TldrawDrawing } from './entities'; -import { TldrawFilesConsole } from './job'; +import { TldrawFilesConsole, TldrawMigrationConsole } from './job'; import { TldrawRepo, YMongodb } from './repo'; import { TldrawFilesStorageAdapterService } from './service'; import { TldrawDeleteFilesUc } from './uc'; @@ -25,6 +26,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { @Module({ imports: [ + S3ClientModule.register([tldrawS3Config]), CoreModule, ConsoleModule, ConsoleWriterModule, @@ -42,7 +44,14 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { }), ConfigModule.forRoot(createConfigModuleOptions(config)), ], - providers: [TldrawRepo, YMongodb, TldrawFilesConsole, TldrawFilesStorageAdapterService, TldrawDeleteFilesUc], + providers: [ + TldrawRepo, + YMongodb, + TldrawFilesConsole, + TldrawFilesStorageAdapterService, + TldrawDeleteFilesUc, + TldrawMigrationConsole, + ], }) export class TldrawConsoleModule { constructor(private readonly logger: Logger, private readonly configService: ConfigService) { From 8067e8b8e4124d0486c06f0b165a05470aabc4e8 Mon Sep 17 00:00:00 2001 From: Christian Spohr <105202075+csp175@users.noreply.github.com> Date: Wed, 4 Sep 2024 14:50:50 +0200 Subject: [PATCH 17/29] BC-7913 Delete .github/workflows/test_unstable_e2e.yml (#5224) --- .github/workflows/test_unstable_e2e.yml | 47 ------------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/test_unstable_e2e.yml diff --git a/.github/workflows/test_unstable_e2e.yml b/.github/workflows/test_unstable_e2e.yml deleted file mode 100644 index a3f64e4de8e..00000000000 --- a/.github/workflows/test_unstable_e2e.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Run unstable e2e tests - -on: - pull_request: - branches: - - main - types: [labeled, synchronize] - workflow_dispatch: - label: - name: 'run unstable tests' - -permissions: - contents: read - -jobs: - end-to-end-unstable-tests: - runs-on: ubuntu-latest - # run the action, when label 'run unstable tests' has been set - if: "contains( github.event.label.name , 'run unstable tests' ) || contains( github.event.pull_request.labels.*.name , 'run unstable tests' )" - steps: - - uses: actions/checkout@v4 - - name: Set BRANCH_NAME on pull_request - run: | - echo ${{ github.head_ref }} - branch_name=${{ github.head_ref }} - echo "BRANCH_NAME=$branch_name" >> $GITHUB_ENV - - name: run git change - run: | - git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" - git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "git@github.com:" - - name: execute tests - run: curl "https://raw.githubusercontent.com/hpi-schul-cloud/end-to-end-tests/main/scripts/ci/fetch.github.sh" | bash -s unstable - env: - ES_USER: ${{ secrets.ES_USER }} - ES_PASSWORD: ${{ secrets.ES_PASSWORD }} - SECRET_ES_MERLIN_USERNAME: ${{ secrets.SECRET_ES_MERLIN_USERNAME }} - SECRET_ES_MERLIN_PW: ${{ secrets.SECRET_ES_MERLIN_PW }} - DOCKER_ID: ${{ secrets.DOCKER_ID }} - MY_DOCKER_PASSWORD: ${{ secrets.MY_DOCKER_PASSWORD }} - - uses: actions/upload-artifact@v4 - name: upload results - if: always() - with: - name: report - path: end-to-end-tests/reports - From 8030276511b68132f3438ab7ce470fdb15c01e44 Mon Sep 17 00:00:00 2001 From: Thomas Feldtkeller Date: Wed, 4 Sep 2024 16:54:03 +0200 Subject: [PATCH 18/29] BC-8014 - fix 404 on course page (#5225) * due to some caching issue, some clients still accessed old routes renamed by BC-7904. This commit restores the old endpoints, which now exist alongside the new ones. --- .../controller/rooms.controller.spec.ts | 209 ++++++++++++++++++ .../learnroom/controller/rooms.controller.ts | 92 ++++++++ .../modules/learnroom/learnroom-api.module.ts | 3 +- sonar-project.properties | 2 +- 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts create mode 100644 apps/server/src/modules/learnroom/controller/rooms.controller.ts diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts new file mode 100644 index 00000000000..2be5c89b715 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts @@ -0,0 +1,209 @@ +import { createMock } from '@golevelup/ts-jest'; +import { CopyApiResponse, CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityId } from '@shared/domain/types'; +import { currentUserFactory } from '@shared/testing'; +import { RoomBoardResponseMapper } from '../mapper/room-board-response.mapper'; +import { RoomBoardDTO } from '../types'; +import { CourseCopyUC } from '../uc/course-copy.uc'; +import { LessonCopyUC } from '../uc/lesson-copy.uc'; +import { CourseRoomsUc } from '../uc/course-rooms.uc'; +import { SingleColumnBoardResponse } from './dto'; +import { RoomsController } from './rooms.controller'; + +describe('rooms controller', () => { + let controller: RoomsController; + let mapper: RoomBoardResponseMapper; + let uc: CourseRoomsUc; + let courseCopyUc: CourseCopyUC; + let lessonCopyUc: LessonCopyUC; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + RoomsController, + { + provide: CourseRoomsUc, + useValue: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getBoard(roomId: EntityId, userId: EntityId): Promise { + throw new Error('please write mock for RoomsUc.getBoard'); + }, + updateVisibilityOfLegacyBoardElement( + roomId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars + elementId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars + userId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars + visibility: boolean // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + throw new Error('please write mock for RoomsUc.updateVisibilityOfBoardElement'); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reorderBoardElements(roomId: EntityId, userId: EntityId, orderedList: EntityId[]): Promise { + throw new Error('please write mock for RoomsUc.reorderBoardElements'); + }, + }, + }, + { + provide: CourseCopyUC, + useValue: createMock(), + }, + { + provide: LessonCopyUC, + useValue: createMock(), + }, + { + provide: RoomBoardResponseMapper, + useValue: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mapToResponse(board: RoomBoardDTO): SingleColumnBoardResponse { + throw new Error('please write mock for Boardmapper.mapToResponse'); + }, + }, + }, + ], + }).compile(); + controller = module.get(RoomsController); + mapper = module.get(RoomBoardResponseMapper); + uc = module.get(CourseRoomsUc); + courseCopyUc = module.get(CourseCopyUC); + lessonCopyUc = module.get(LessonCopyUC); + }); + + describe('getRoomBoard', () => { + describe('when simple room is fetched', () => { + const setup = () => { + const currentUser = currentUserFactory.build(); + + const ucResult = { + roomId: 'id', + title: 'title', + displayColor: '#FFFFFF', + elements: [], + isArchived: false, + isSynchronized: false, + } as RoomBoardDTO; + const ucSpy = jest.spyOn(uc, 'getBoard').mockImplementation(() => Promise.resolve(ucResult)); + + const mapperResult = new SingleColumnBoardResponse({ + roomId: 'id', + title: 'title', + displayColor: '#FFFFFF', + elements: [], + isArchived: false, + isSynchronized: false, + }); + const mapperSpy = jest.spyOn(mapper, 'mapToResponse').mockImplementation(() => mapperResult); + return { currentUser, ucResult, ucSpy, mapperResult, mapperSpy }; + }; + + it('should call uc with ids', async () => { + const { currentUser, ucSpy } = setup(); + + await controller.getRoomBoard({ roomId: 'roomId' }, currentUser); + + expect(ucSpy).toHaveBeenCalledWith('roomId', currentUser.userId); + }); + + it('should call mapper with uc result', async () => { + const { currentUser, ucResult, mapperSpy } = setup(); + + await controller.getRoomBoard({ roomId: 'roomId' }, currentUser); + + expect(mapperSpy).toHaveBeenCalledWith(ucResult); + }); + + it('should return mapped result', async () => { + const { currentUser, mapperResult } = setup(); + + const result = await controller.getRoomBoard({ roomId: 'roomId' }, currentUser); + + expect(result).toEqual(mapperResult); + }); + }); + }); + + describe('patchVisibility', () => { + it('should call uc', async () => { + const currentUser = currentUserFactory.build(); + const ucSpy = jest.spyOn(uc, 'updateVisibilityOfLegacyBoardElement').mockImplementation(() => Promise.resolve()); + await controller.patchElementVisibility( + { roomId: 'roomid', elementId: 'elementId' }, + { visibility: true }, + currentUser + ); + expect(ucSpy).toHaveBeenCalled(); + }); + }); + + describe('patchOrder', () => { + it('should call uc', async () => { + const currentUser = currentUserFactory.build(); + const ucSpy = jest.spyOn(uc, 'reorderBoardElements').mockImplementation(() => Promise.resolve()); + await controller.patchOrderingOfElements({ roomId: 'roomid' }, { elements: ['id', 'id', 'id'] }, currentUser); + expect(ucSpy).toHaveBeenCalledWith('roomid', currentUser.userId, ['id', 'id', 'id']); + }); + }); + + describe('copyCourse', () => { + describe('when course should be copied via API call', () => { + const setup = () => { + const currentUser = currentUserFactory.build(); + const ucResult = { + title: 'example title', + type: 'COURSE' as CopyElementType, + status: 'SUCCESS' as CopyStatusEnum, + elements: [], + } as CopyStatus; + const ucSpy = jest.spyOn(courseCopyUc, 'copyCourse').mockImplementation(() => Promise.resolve(ucResult)); + return { currentUser, ucSpy }; + }; + it('should call uc', async () => { + const { currentUser, ucSpy } = setup(); + + await controller.copyCourse(currentUser, { roomId: 'roomId' }); + expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'roomId'); + }); + + it('should return result of correct type', async () => { + const { currentUser } = setup(); + + const result = await controller.copyCourse(currentUser, { roomId: 'roomId' }); + expect(result).toBeInstanceOf(CopyApiResponse); + }); + }); + }); + + describe('copyLesson', () => { + describe('when lesson should be copied via API call', () => { + const setup = () => { + const currentUser = currentUserFactory.build(); + const ucResult = { + title: 'example title', + type: 'LESSON' as CopyElementType, + status: 'SUCCESS' as CopyStatusEnum, + elements: [], + } as CopyStatus; + const ucSpy = jest.spyOn(lessonCopyUc, 'copyLesson').mockImplementation(() => Promise.resolve(ucResult)); + return { currentUser, ucSpy }; + }; + + it('should call uc with parentId', async () => { + const { currentUser, ucSpy } = setup(); + + await controller.copyLesson(currentUser, { lessonId: 'lessonId' }, { courseId: 'id' }); + expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'lessonId', { + courseId: 'id', + userId: currentUser.userId, + }); + }); + + it('should return result of correct type', async () => { + const { currentUser } = setup(); + + const result = await controller.copyLesson(currentUser, { lessonId: 'lessonId' }, {}); + expect(result).toBeInstanceOf(CopyApiResponse); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.ts new file mode 100644 index 00000000000..6c29c37cd7d --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/rooms.controller.ts @@ -0,0 +1,92 @@ +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RequestTimeout } from '@shared/common'; +import { RoomBoardResponseMapper } from '../mapper/room-board-response.mapper'; +import { CourseCopyUC } from '../uc/course-copy.uc'; +import { LessonCopyUC } from '../uc/lesson-copy.uc'; +import { CourseRoomsUc } from '../uc/course-rooms.uc'; +import { + LessonCopyApiParams, + LessonUrlParams, + PatchOrderParams, + PatchVisibilityParams, + CourseRoomElementUrlParams, + CourseRoomUrlParams, + SingleColumnBoardResponse, +} from './dto'; + +// TODO: remove this file, and remove it from sonar-project.properties + +@ApiTags('Rooms') +@JwtAuthentication() +@Controller('rooms') +export class RoomsController { + constructor( + private readonly roomsUc: CourseRoomsUc, + private readonly mapper: RoomBoardResponseMapper, + private readonly courseCopyUc: CourseCopyUC, + private readonly lessonCopyUc: LessonCopyUC + ) {} + + @Get(':roomId/board') + async getRoomBoard( + @Param() urlParams: CourseRoomUrlParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const board = await this.roomsUc.getBoard(urlParams.roomId, currentUser.userId); + const mapped = this.mapper.mapToResponse(board); + return mapped; + } + + @Patch(':roomId/elements/:elementId/visibility') + async patchElementVisibility( + @Param() urlParams: CourseRoomElementUrlParams, + @Body() params: PatchVisibilityParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.roomsUc.updateVisibilityOfLegacyBoardElement( + urlParams.roomId, + urlParams.elementId, + currentUser.userId, + params.visibility + ); + } + + @Patch(':roomId/board/order') + async patchOrderingOfElements( + @Param() urlParams: CourseRoomUrlParams, + @Body() params: PatchOrderParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.roomsUc.reorderBoardElements(urlParams.roomId, currentUser.userId, params.elements); + } + + @Post(':roomId/copy') + @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') + async copyCourse( + @CurrentUser() currentUser: ICurrentUser, + @Param() urlParams: CourseRoomUrlParams + ): Promise { + const copyStatus = await this.courseCopyUc.copyCourse(currentUser.userId, urlParams.roomId); + const dto = CopyMapper.mapToResponse(copyStatus); + return dto; + } + + @Post('lessons/:lessonId/copy') + @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') + async copyLesson( + @CurrentUser() currentUser: ICurrentUser, + @Param() urlParams: LessonUrlParams, + @Body() params: LessonCopyApiParams + ): Promise { + const copyStatus = await this.lessonCopyUc.copyLesson( + currentUser.userId, + urlParams.lessonId, + CopyMapper.mapLessonCopyToDomain(params, currentUser.userId) + ); + const dto = CopyMapper.mapToResponse(copyStatus); + return dto; + } +} diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index 3c565127dab..3770fdda168 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -30,6 +30,7 @@ import { LessonCopyUC, RoomBoardDTOFactory, } from './uc'; +import { RoomsController } from './controller/rooms.controller'; /** * @deprecated - the learnroom module is deprecated and will be removed in the future @@ -48,7 +49,7 @@ import { UserModule, ClassModule, ], - controllers: [DashboardController, CourseController, CourseInfoController, CourseRoomsController], + controllers: [DashboardController, CourseController, CourseInfoController, CourseRoomsController, RoomsController], providers: [ DashboardUc, CourseUc, diff --git a/sonar-project.properties b/sonar-project.properties index 476998e113b..4de86ab56ca 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.projectKey=hpi-schul-cloud_schulcloud-server sonar.sources=. sonar.tests=. sonar.test.inclusions=**/*.spec.ts -sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts +sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts, **/learnroom/controller/rooms.controller.ts sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts sonar.cpd.exclusions=**/controller/dto/*.ts sonar.javascript.lcov.reportPaths=merged-lcov.info From 0f9c2a642ff170422a6599834e83f4a266ad6a80 Mon Sep 17 00:00:00 2001 From: Steliyan Dinkov <133751031+sdinkov@users.noreply.github.com> Date: Thu, 5 Sep 2024 17:09:38 +0200 Subject: [PATCH 19/29] Refactoring - Fully move System and User Import from @shared to @modules (#5226) * move ImportUser entity and repo * move systeFactory to system module * move ExternalSourceEmbeddable to system module --- .../keycloak-configuration.service.spec.ts | 3 +- .../domain/services/account.service.spec.ts | 3 +- .../services/ldap.service.spec.ts | 2 +- .../strategy/ldap.strategy.spec.ts | 3 +- .../domain/rules/system.rule.spec.ts | 3 +- .../src/modules/group/entity/group.entity.ts | 2 +- .../modules/group/repo/group-domain.mapper.ts | 4 +-- .../modules/group/uc/class-group.uc.spec.ts | 2 +- .../uc/school-system-options.uc.spec.ts | 3 +- .../oauth/service/oauth.service.spec.ts | 3 +- .../provisioning-system-input.mapper.spec.ts | 2 +- .../service/provisioning.service.spec.ts | 2 +- .../system-deleted.handler.spec.ts | 2 +- .../domain/service/school.service.spec.ts | 2 +- .../modules/system/domain/system.do.spec.ts | 2 +- .../entity/external-source.embeddable.ts | 2 +- .../server/src/modules/system/entity/index.ts | 5 ++-- .../system/repo/mikro-orm/system.repo.spec.ts | 9 ++---- .../system/service/system.service.spec.ts | 2 +- .../src/modules/system/testing/index.ts | 6 ++++ .../system/testing}/system.factory.ts | 4 +-- .../src/modules/system/uc/system.uc.spec.ts | 5 ++-- .../api-test/import-user.api.spec.ts | 3 +- .../controller/import-user.controller.ts | 3 +- .../interface/import-user-filter.interface.ts | 12 ++++++++ .../import-user-match-creator-scope.enum.ts | 5 ++++ ...import-user-name-match-filter.interface.ts | 6 ++++ .../user-import/domain/interface/index.ts | 3 ++ .../entity/import-user.entity.spec.ts | 2 +- .../user-import}/entity/import-user.entity.ts | 16 +++++----- .../src/modules/user-import/entity/index.ts | 1 + apps/server/src/modules/user-import/index.ts | 4 +-- .../mapper/import-user.mapper.spec.ts | 8 ++--- .../user-import/mapper/import-user.mapper.ts | 10 +++---- .../user-import/mapper/match.mapper.spec.ts | 10 +++---- .../user-import/mapper/match.mapper.ts | 12 ++++---- .../mapper/role-name.mapper.spec.ts | 4 +-- .../user-import/mapper/role-name.mapper.ts | 6 ++-- .../mapper/schulconnex-import-user.mapper.ts | 3 +- .../mapper/user-match.mapper.spec.ts | 2 +- .../user-import/mapper/user-match.mapper.ts | 9 +++--- .../repo/import-user.repo.spec.ts} | 29 ++++++++++++------- .../user-import/repo/import-user.repo.ts} | 27 ++++++++++------- .../user-import/repo/import-user.scope.ts} | 17 ++++++----- .../src/modules/user-import/repo/index.ts | 2 ++ ...lconnex-fetch-import-users.service.spec.ts | 5 ++-- .../schulconnex-fetch-import-users.service.ts | 3 +- .../service/user-import.service.spec.ts | 7 +++-- .../service/user-import.service.ts | 5 ++-- .../uc/user-import-fetch.uc.spec.ts | 5 ++-- .../user-import/uc/user-import-fetch.uc.ts | 3 +- .../user-import/uc/user-import.uc.spec.ts | 15 ++++++---- .../modules/user-import/uc/user-import.uc.ts | 19 +++++++----- .../modules/user-import/user-import.module.ts | 3 +- .../api-test/user-login-migration.api.spec.ts | 3 +- .../user-login-migration.service.spec.ts | 3 +- .../src/shared/domain/entity/all-entities.ts | 2 +- apps/server/src/shared/domain/entity/index.ts | 2 -- .../shared/domain/types/importuser.types.ts | 24 --------------- apps/server/src/shared/domain/types/index.ts | 1 - .../src/shared/repo/importuser/index.ts | 1 - apps/server/src/shared/repo/index.ts | 1 - apps/server/src/shared/repo/user/user.repo.ts | 5 ++-- .../testing/factory/domainobject/index.ts | 6 ---- .../testing/factory/group-entity.factory.ts | 2 +- .../testing/factory/import-user.factory.ts | 5 ++-- 66 files changed, 211 insertions(+), 174 deletions(-) rename apps/server/src/{shared/domain => modules/system}/entity/external-source.embeddable.ts (88%) create mode 100644 apps/server/src/modules/system/testing/index.ts rename apps/server/src/{shared/testing/factory/domainobject/system => modules/system/testing}/system.factory.ts (94%) create mode 100644 apps/server/src/modules/user-import/domain/interface/import-user-filter.interface.ts create mode 100644 apps/server/src/modules/user-import/domain/interface/import-user-match-creator-scope.enum.ts create mode 100644 apps/server/src/modules/user-import/domain/interface/import-user-name-match-filter.interface.ts create mode 100644 apps/server/src/modules/user-import/domain/interface/index.ts rename apps/server/src/{shared/domain => modules/user-import}/entity/import-user.entity.spec.ts (97%) rename apps/server/src/{shared/domain => modules/user-import}/entity/import-user.entity.ts (87%) create mode 100644 apps/server/src/modules/user-import/entity/index.ts rename apps/server/src/{shared/repo/importuser/importuser.repo.integration.spec.ts => modules/user-import/repo/import-user.repo.spec.ts} (97%) rename apps/server/src/{shared/repo/importuser/importuser.repo.ts => modules/user-import/repo/import-user.repo.ts} (75%) rename apps/server/src/{shared/repo/importuser/importuser.scope.ts => modules/user-import/repo/import-user.scope.ts} (85%) create mode 100644 apps/server/src/modules/user-import/repo/index.ts delete mode 100644 apps/server/src/shared/domain/types/importuser.types.ts delete mode 100644 apps/server/src/shared/repo/importuser/index.ts diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts index 1e7f10c87e5..3e18477c5c6 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-configuration.service.spec.ts @@ -11,8 +11,7 @@ import { SystemService } from '@modules/system'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; - -import { systemFactory } from '@shared/testing'; +import { systemFactory } from '@modules/system/testing'; import { AxiosResponse } from 'axios'; import { of } from 'rxjs'; import { v1 } from 'uuid'; diff --git a/apps/server/src/modules/account/domain/services/account.service.spec.ts b/apps/server/src/modules/account/domain/services/account.service.spec.ts index 77d9d7ebf35..e7cc9a001e9 100644 --- a/apps/server/src/modules/account/domain/services/account.service.spec.ts +++ b/apps/server/src/modules/account/domain/services/account.service.spec.ts @@ -10,13 +10,14 @@ import { OperationType, } from '@modules/deletion'; import { deletionRequestFactory } from '@modules/deletion/domain/testing'; +import { systemFactory } from '@modules/system/testing'; import { ConfigService } from '@nestjs/config'; import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; import { AuthorizationError, EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; import { User } from '@shared/domain/entity'; import { UserRepo } from '@shared/repo'; -import { schoolEntityFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import 'reflect-metadata'; import { Account, AccountSave, UpdateAccount } from '..'; diff --git a/apps/server/src/modules/authentication/services/ldap.service.spec.ts b/apps/server/src/modules/authentication/services/ldap.service.spec.ts index 54c9d3ad93b..46dc5e147f6 100644 --- a/apps/server/src/modules/authentication/services/ldap.service.spec.ts +++ b/apps/server/src/modules/authentication/services/ldap.service.spec.ts @@ -1,7 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { System } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { Test, TestingModule } from '@nestjs/testing'; -import { systemFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { LdapUserCouldNotBeAuthenticatedLoggableException } from '../loggable'; import { LdapService } from './ldap.service'; diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts index a98dd5295fd..fed838d5dc0 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ICurrentUser } from '@infra/auth-guard'; import { Account } from '@modules/account'; import { System, SystemService } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { UnauthorizedException } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; @@ -9,7 +10,7 @@ import { LegacySchoolDo } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { LegacySchoolRepo, UserRepo } from '@shared/repo'; -import { legacySchoolDoFactory, schoolEntityFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { legacySchoolDoFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { accountDoFactory, defaultTestPassword, defaultTestPasswordHash } from '@src/modules/account/testing'; import { LdapAuthorizationBodyParams } from '../controllers/dto'; diff --git a/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts index 04390c64e48..b947ac31f68 100644 --- a/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts @@ -1,10 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { System } from '@modules/system'; import { SystemEntity } from '@modules/system/entity'; +import { systemFactory } from '@modules/system/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { schoolEntityFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { schoolEntityFactory, setupEntities, systemEntityFactory, userFactory } from '@shared/testing'; import { AuthorizationContextBuilder } from '../mapper'; import { AuthorizationHelper } from '../service/authorization.helper'; import { SystemRule } from './system.rule'; diff --git a/apps/server/src/modules/group/entity/group.entity.ts b/apps/server/src/modules/group/entity/group.entity.ts index 01c41e17843..78d04ea10ff 100644 --- a/apps/server/src/modules/group/entity/group.entity.ts +++ b/apps/server/src/modules/group/entity/group.entity.ts @@ -1,6 +1,6 @@ import { Embedded, Entity, Enum, ManyToOne, Property } from '@mikro-orm/core'; +import { ExternalSourceEmbeddable } from '@modules/system/entity'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { ExternalSourceEmbeddable } from '@shared/domain/entity/external-source.embeddable'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; import { EntityId } from '@shared/domain/types'; import { GroupUserEmbeddable } from './group-user.embeddable'; diff --git a/apps/server/src/modules/group/repo/group-domain.mapper.ts b/apps/server/src/modules/group/repo/group-domain.mapper.ts index f366ca69f8d..affe2ec6343 100644 --- a/apps/server/src/modules/group/repo/group-domain.mapper.ts +++ b/apps/server/src/modules/group/repo/group-domain.mapper.ts @@ -1,8 +1,8 @@ import { EntityData } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; -import { SystemEntity } from '@modules/system/entity'; +import { ExternalSourceEmbeddable, SystemEntity } from '@modules/system/entity'; import { ExternalSource } from '@shared/domain/domainobject'; -import { ExternalSourceEmbeddable, Role, SchoolEntity, User } from '@shared/domain/entity'; +import { Role, SchoolEntity, User } from '@shared/domain/entity'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; import { GroupEntity, GroupEntityTypes, GroupUserEmbeddable, GroupValidPeriodEmbeddable } from '../entity'; diff --git a/apps/server/src/modules/group/uc/class-group.uc.spec.ts b/apps/server/src/modules/group/uc/class-group.uc.spec.ts index e3274185de7..81c09cab8b7 100644 --- a/apps/server/src/modules/group/uc/class-group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/class-group.uc.spec.ts @@ -15,6 +15,7 @@ import { RoleDto } from '@modules/role/service/dto/role.dto'; import { School, SchoolService } from '@modules/school/domain'; import { schoolFactory, schoolYearFactory } from '@modules/school/testing'; import { System, SystemService } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -27,7 +28,6 @@ import { roleDtoFactory, schoolYearFactory as schoolYearEntityFactory, setupEntities, - systemFactory, UserAndAccountTestFactory, userDoFactory, userFactory, diff --git a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts index 02a2262b3d2..1699dccdb8a 100644 --- a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts +++ b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts @@ -2,11 +2,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { System, SystemService } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Permission } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { schoolSystemOptionsFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { schoolSystemOptionsFactory, setupEntities, userFactory } from '@shared/testing'; import { AnyProvisioningOptions, SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; import { ProvisioningStrategyMissingLoggableException } from '../loggable'; import { ProvisioningOptionsUpdateService, SchoolSystemOptionsService } from '../service'; diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index b31f8c38c99..7782b24067b 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -6,13 +6,14 @@ import { LegacySchoolService } from '@modules/legacy-school'; import { ProvisioningService } from '@modules/provisioning'; import { OauthConfigEntity } from '@modules/system/entity'; import { SystemService } from '@modules/system/service'; +import { systemFactory } from '@modules/system/testing'; import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { SchoolFeature } from '@shared/domain/types'; -import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; +import { legacySchoolDoFactory, setupEntities, userDoFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { OauthDataDto } from '@src/modules/provisioning/dto'; import { System } from '@src/modules/system'; diff --git a/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts b/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts index 7de363b3ef1..14d7ef36e7b 100644 --- a/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/mapper/provisioning-system-input.mapper.spec.ts @@ -1,5 +1,5 @@ +import { systemFactory } from '@modules/system/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { systemFactory } from '@shared/testing'; import { ProvisioningSystemDto } from '../dto'; import { ProvisioningSystemInputMapper } from './provisioning-system-input.mapper'; diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts index 170ce718ef5..8320e2ceb53 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { System, SystemService } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { systemFactory } from '@shared/testing'; import { ExternalUserDto, OauthDataDto, diff --git a/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.spec.ts b/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.spec.ts index e4372297239..e4f61b2d86e 100644 --- a/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.spec.ts +++ b/apps/server/src/modules/school/domain/event-handler/system-deleted.handler.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { SystemDeletedEvent } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { Test, TestingModule } from '@nestjs/testing'; -import { systemFactory } from '@shared/testing'; import { schoolFactory } from '../../testing'; import { School } from '../do'; import { SchoolService } from '../service'; diff --git a/apps/server/src/modules/school/domain/service/school.service.spec.ts b/apps/server/src/modules/school/domain/service/school.service.spec.ts index 556b4ee89be..3693207c2ed 100644 --- a/apps/server/src/modules/school/domain/service/school.service.spec.ts +++ b/apps/server/src/modules/school/domain/service/school.service.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { systemFactory } from '@modules/system/testing'; import { NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions, SortOrder } from '@shared/domain/interface'; -import { systemFactory } from '@shared/testing'; import { SystemService } from '@src/modules/system'; import { schoolFactory } from '../../testing'; import { SchoolForLdapLogin, SchoolProps, SystemForLdapLogin } from '../do'; diff --git a/apps/server/src/modules/system/domain/system.do.spec.ts b/apps/server/src/modules/system/domain/system.do.spec.ts index 9d5f9dc5a08..f2af650a6dc 100644 --- a/apps/server/src/modules/system/domain/system.do.spec.ts +++ b/apps/server/src/modules/system/domain/system.do.spec.ts @@ -1,4 +1,4 @@ -import { systemFactory } from '@shared/testing'; +import { systemFactory } from '@modules/system/testing'; describe('System', () => { describe('isDeletable', () => { diff --git a/apps/server/src/shared/domain/entity/external-source.embeddable.ts b/apps/server/src/modules/system/entity/external-source.embeddable.ts similarity index 88% rename from apps/server/src/shared/domain/entity/external-source.embeddable.ts rename to apps/server/src/modules/system/entity/external-source.embeddable.ts index 568b8030770..f99f35e947d 100644 --- a/apps/server/src/shared/domain/entity/external-source.embeddable.ts +++ b/apps/server/src/modules/system/entity/external-source.embeddable.ts @@ -1,5 +1,5 @@ import { Embeddable, ManyToOne, Property } from '@mikro-orm/core'; -import { SystemEntity } from '@modules/system/entity/system.entity'; +import { SystemEntity } from './system.entity'; export interface ExternalSourceEntityProps { externalId: string; diff --git a/apps/server/src/modules/system/entity/index.ts b/apps/server/src/modules/system/entity/index.ts index ad6f6138a38..53afe46042f 100644 --- a/apps/server/src/modules/system/entity/index.ts +++ b/apps/server/src/modules/system/entity/index.ts @@ -1,7 +1,8 @@ +export { ExternalSourceEmbeddable } from './external-source.embeddable'; export { - SystemEntity, - SystemEntityProps, LdapConfigEntity, OauthConfigEntity, OidcConfigEntity, + SystemEntity, + SystemEntityProps, } from './system.entity'; diff --git a/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts index ba3ae03afe3..8d829a8e177 100644 --- a/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts @@ -4,17 +4,12 @@ import { NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { SystemTypeEnum } from '@shared/domain/types'; -import { - cleanupCollections, - systemEntityFactory, - systemLdapConfigFactory, - systemOauthConfigFactory, - systemOidcConfigFactory, -} from '@shared/testing'; +import { cleanupCollections, systemEntityFactory } from '@shared/testing'; import { System, SYSTEM_REPO, SystemProps, SystemRepo, SystemType } from '../../domain'; import { SystemEntity } from '../../entity'; import { SystemEntityMapper } from './mapper'; import { SystemMikroOrmRepo } from './system.repo'; +import { systemLdapConfigFactory, systemOauthConfigFactory, systemOidcConfigFactory } from '../../testing'; describe(SystemMikroOrmRepo.name, () => { let module: TestingModule; diff --git a/apps/server/src/modules/system/service/system.service.spec.ts b/apps/server/src/modules/system/service/system.service.spec.ts index 5c835580837..382e3eea754 100644 --- a/apps/server/src/modules/system/service/system.service.spec.ts +++ b/apps/server/src/modules/system/service/system.service.spec.ts @@ -2,8 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { systemFactory } from '@shared/testing'; import { SYSTEM_REPO, SystemQuery, SystemRepo, SystemType } from '../domain'; +import { systemFactory } from '../testing'; import { SystemService } from './system.service'; describe(SystemService.name, () => { diff --git a/apps/server/src/modules/system/testing/index.ts b/apps/server/src/modules/system/testing/index.ts new file mode 100644 index 00000000000..b161d59d3a9 --- /dev/null +++ b/apps/server/src/modules/system/testing/index.ts @@ -0,0 +1,6 @@ +export { + systemFactory, + systemOauthConfigFactory, + systemLdapConfigFactory, + systemOidcConfigFactory, +} from './system.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts b/apps/server/src/modules/system/testing/system.factory.ts similarity index 94% rename from apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts rename to apps/server/src/modules/system/testing/system.factory.ts index 761345861c4..6150a592197 100644 --- a/apps/server/src/shared/testing/factory/domainobject/system/system.factory.ts +++ b/apps/server/src/modules/system/testing/system.factory.ts @@ -1,7 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { LdapConfig, OauthConfig, OidcConfig, System, SystemProps, SystemType } from '@modules/system/domain'; +import { DomainObjectFactory } from '@shared/testing/factory/domainobject/domain-object.factory'; import { DeepPartial, Factory } from 'fishery'; -import { DomainObjectFactory } from '../domain-object.factory'; +import { LdapConfig, OauthConfig, OidcConfig, System, SystemProps, SystemType } from '../domain'; export const systemOauthConfigFactory = Factory.define( () => diff --git a/apps/server/src/modules/system/uc/system.uc.spec.ts b/apps/server/src/modules/system/uc/system.uc.spec.ts index 088a219e90e..7032d3ba06c 100644 --- a/apps/server/src/modules/system/uc/system.uc.spec.ts +++ b/apps/server/src/modules/system/uc/system.uc.spec.ts @@ -1,14 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { SystemUc } from '@modules/system/uc/system.uc'; import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Permission } from '@shared/domain/interface'; -import { setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { setupEntities, userFactory } from '@shared/testing'; import { SystemDeletedEvent, SystemQuery, SystemType } from '../domain'; import { SystemService } from '../service'; +import { systemFactory } from '../testing'; +import { SystemUc } from './system.uc'; describe('SystemUc', () => { let module: TestingModule; diff --git a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts index e6268492d21..5286458b0eb 100644 --- a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts +++ b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts @@ -21,7 +21,7 @@ import { import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PaginationParams } from '@shared/controller'; -import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { Permission, RoleName, SortOrder } from '@shared/domain/interface'; import { SchoolFeature } from '@shared/domain/types'; import { @@ -36,6 +36,7 @@ import { } from '@shared/testing'; import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; import { accountFactory } from '@src/modules/account/testing'; +import { ImportUser, MatchCreator } from '../../entity'; describe('ImportUser Controller (API)', () => { let app: INestApplication; diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.ts b/apps/server/src/modules/user-import/controller/import-user.controller.ts index 2b2b13d225c..d1fb56b3c6b 100644 --- a/apps/server/src/modules/user-import/controller/import-user.controller.ts +++ b/apps/server/src/modules/user-import/controller/import-user.controller.ts @@ -12,8 +12,9 @@ import { } from '@nestjs/swagger'; import { RequestTimeout } from '@shared/common'; import { PaginationParams } from '@shared/controller'; -import { ImportUser, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; +import { ImportUser } from '../entity'; import { ImportUserMapper, UserMatchMapper } from '../mapper'; import { UserImportFetchUc, UserImportUc } from '../uc'; import { diff --git a/apps/server/src/modules/user-import/domain/interface/import-user-filter.interface.ts b/apps/server/src/modules/user-import/domain/interface/import-user-filter.interface.ts new file mode 100644 index 00000000000..6ba1754da5f --- /dev/null +++ b/apps/server/src/modules/user-import/domain/interface/import-user-filter.interface.ts @@ -0,0 +1,12 @@ +import type { ImportUserRoleName } from '../../entity'; +import { ImportUserMatchCreatorScope } from './import-user-match-creator-scope.enum'; + +export interface ImportUserFilter { + firstName?: string; + lastName?: string; + loginName?: string; + matches?: ImportUserMatchCreatorScope[]; + flagged?: boolean; + role?: ImportUserRoleName; + classes?: string; +} diff --git a/apps/server/src/modules/user-import/domain/interface/import-user-match-creator-scope.enum.ts b/apps/server/src/modules/user-import/domain/interface/import-user-match-creator-scope.enum.ts new file mode 100644 index 00000000000..8f2c53f579b --- /dev/null +++ b/apps/server/src/modules/user-import/domain/interface/import-user-match-creator-scope.enum.ts @@ -0,0 +1,5 @@ +export enum ImportUserMatchCreatorScope { + AUTO = 'auto', + MANUAL = 'admin', + NONE = 'none', +} diff --git a/apps/server/src/modules/user-import/domain/interface/import-user-name-match-filter.interface.ts b/apps/server/src/modules/user-import/domain/interface/import-user-name-match-filter.interface.ts new file mode 100644 index 00000000000..690569b0d0a --- /dev/null +++ b/apps/server/src/modules/user-import/domain/interface/import-user-name-match-filter.interface.ts @@ -0,0 +1,6 @@ +export interface ImportUserNameMatchFilter { + /** + * Match filter for lastName or firstName + */ + name?: string; +} diff --git a/apps/server/src/modules/user-import/domain/interface/index.ts b/apps/server/src/modules/user-import/domain/interface/index.ts new file mode 100644 index 00000000000..86ad39fbdf1 --- /dev/null +++ b/apps/server/src/modules/user-import/domain/interface/index.ts @@ -0,0 +1,3 @@ +export * from './import-user-name-match-filter.interface'; +export * from './import-user-match-creator-scope.enum'; +export * from './import-user-filter.interface'; diff --git a/apps/server/src/shared/domain/entity/import-user.entity.spec.ts b/apps/server/src/modules/user-import/entity/import-user.entity.spec.ts similarity index 97% rename from apps/server/src/shared/domain/entity/import-user.entity.spec.ts rename to apps/server/src/modules/user-import/entity/import-user.entity.spec.ts index cdd22330733..66c55798cd9 100644 --- a/apps/server/src/shared/domain/entity/import-user.entity.spec.ts +++ b/apps/server/src/modules/user-import/entity/import-user.entity.spec.ts @@ -1,5 +1,5 @@ import { importUserFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; -import { MatchCreator } from '.'; +import { MatchCreator } from './import-user.entity'; describe('ImportUser entity', () => { beforeAll(async () => { diff --git a/apps/server/src/shared/domain/entity/import-user.entity.ts b/apps/server/src/modules/user-import/entity/import-user.entity.ts similarity index 87% rename from apps/server/src/shared/domain/entity/import-user.entity.ts rename to apps/server/src/modules/user-import/entity/import-user.entity.ts index 703b7f1bc0b..24d8ba33653 100644 --- a/apps/server/src/shared/domain/entity/import-user.entity.ts +++ b/apps/server/src/modules/user-import/entity/import-user.entity.ts @@ -1,11 +1,11 @@ import { Entity, Enum, IdentifiedReference, ManyToOne, Property, Unique, wrap } from '@mikro-orm/core'; import { SystemEntity } from '@modules/system/entity/system.entity'; -import { EntityWithSchool, RoleName } from '../interface'; -import { BaseEntityReference, BaseEntityWithTimestamps } from './base.entity'; -import { SchoolEntity } from './school.entity'; -import type { User } from './user.entity'; +import { BaseEntityReference, BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { SchoolEntity } from '@shared/domain/entity/school.entity'; +import type { User } from '@shared/domain/entity/user.entity'; +import { EntityWithSchool, RoleName } from '@shared/domain/interface'; -export type IImportUserRoleName = RoleName.ADMINISTRATOR | RoleName.TEACHER | RoleName.STUDENT; +export type ImportUserRoleName = RoleName.ADMINISTRATOR | RoleName.TEACHER | RoleName.STUDENT; export interface ImportUserProperties { // references @@ -18,7 +18,7 @@ export interface ImportUserProperties { firstName: string; lastName: string; email: string; // TODO VO - roleNames?: IImportUserRoleName[]; + roleNames?: ImportUserRoleName[]; classNames?: string[]; user?: User; matchedBy?: MatchCreator; @@ -91,7 +91,7 @@ export class ImportUser extends BaseEntityWithTimestamps implements EntityWithSc email: string; @Enum({ fieldName: 'roles' }) - roleNames: IImportUserRoleName[] = []; + roleNames: ImportUserRoleName[] = []; @Property() classNames: string[] = []; @@ -130,7 +130,7 @@ export class ImportUser extends BaseEntityWithTimestamps implements EntityWithSc this.matchedBy = undefined; } - static isImportUserRole(role: RoleName): role is IImportUserRoleName { + static isImportUserRole(role: RoleName): role is ImportUserRoleName { return role === RoleName.ADMINISTRATOR || role === RoleName.STUDENT || role === RoleName.TEACHER; } } diff --git a/apps/server/src/modules/user-import/entity/index.ts b/apps/server/src/modules/user-import/entity/index.ts new file mode 100644 index 00000000000..72b07e1012d --- /dev/null +++ b/apps/server/src/modules/user-import/entity/index.ts @@ -0,0 +1 @@ +export * from './import-user.entity'; diff --git a/apps/server/src/modules/user-import/index.ts b/apps/server/src/modules/user-import/index.ts index e149f9d70bf..56498d97697 100644 --- a/apps/server/src/modules/user-import/index.ts +++ b/apps/server/src/modules/user-import/index.ts @@ -1,3 +1,3 @@ -export { ImportUserModule } from './user-import.module'; -export { UserImportConfig } from './user-import-config'; export { UserImportService } from './service'; +export { UserImportConfig } from './user-import-config'; +export { ImportUserModule } from './user-import.module'; diff --git a/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts b/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts index 44dc7fc8295..a298006d329 100644 --- a/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts +++ b/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts @@ -1,7 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { MatchCreator } from '@shared/domain/entity'; import { RoleName, SortOrder } from '@shared/domain/interface'; -import { MatchCreatorScope } from '@shared/domain/types'; import { importUserFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { FilterImportUserParams, @@ -11,6 +9,8 @@ import { SortImportUserParams, UserMatchResponse, } from '../controller/dto'; +import { ImportUserMatchCreatorScope } from '../domain/interface'; +import { MatchCreator } from '../entity'; import { ImportUserMapper } from './import-user.mapper'; import { ImportUserMatchMapper } from './match.mapper'; import { RoleNameMapper } from './role-name.mapper'; @@ -147,9 +147,9 @@ describe('[ImportUserMapper]', () => { const query: FilterImportUserParams = { match: [FilterMatchType.MANUAL] }; const importUserMatchMapperSpy = jest .spyOn(ImportUserMatchMapper, 'mapImportUserMatchScopeToDomain') - .mockReturnValueOnce(MatchCreatorScope.MANUAL); + .mockReturnValueOnce(ImportUserMatchCreatorScope.MANUAL); const result = ImportUserMapper.mapImportUserFilterQueryToDomain(query); - expect(result.matches).toEqual([MatchCreatorScope.MANUAL]); + expect(result.matches).toEqual([ImportUserMatchCreatorScope.MANUAL]); expect(importUserMatchMapperSpy).toBeCalledWith(FilterMatchType.MANUAL); importUserMatchMapperSpy.mockRestore(); }); diff --git a/apps/server/src/modules/user-import/mapper/import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/import-user.mapper.ts index a36d6b0fa56..a24b56e5c50 100644 --- a/apps/server/src/modules/user-import/mapper/import-user.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/import-user.mapper.ts @@ -1,17 +1,15 @@ import { BadRequestException } from '@nestjs/common'; import { StringValidator } from '@shared/common'; -import { ImportUser } from '@shared/domain/entity'; import { SortOrderMap } from '@shared/domain/interface'; -import { IImportUserScope } from '@shared/domain/types'; import { FilterImportUserParams, ImportUserResponse, ImportUserSortOrder, SortImportUserParams, } from '../controller/dto'; - +import { ImportUserFilter } from '../domain/interface'; +import { ImportUser } from '../entity'; import { ImportUserMatchMapper } from './match.mapper'; - import { RoleNameMapper } from './role-name.mapper'; import { UserMatchMapper } from './user-match.mapper'; @@ -49,8 +47,8 @@ export class ImportUserMapper { return dto; } - static mapImportUserFilterQueryToDomain(query: FilterImportUserParams): IImportUserScope { - const dto: IImportUserScope = {}; + static mapImportUserFilterQueryToDomain(query: FilterImportUserParams): ImportUserFilter { + const dto: ImportUserFilter = {}; if (StringValidator.isNotEmptyString(query.firstName)) dto.firstName = query.firstName; if (StringValidator.isNotEmptyString(query.lastName)) dto.lastName = query.lastName; if (StringValidator.isNotEmptyString(query.loginName)) dto.loginName = query.loginName; diff --git a/apps/server/src/modules/user-import/mapper/match.mapper.spec.ts b/apps/server/src/modules/user-import/mapper/match.mapper.spec.ts index a4f2deccb0b..9923bed9de0 100644 --- a/apps/server/src/modules/user-import/mapper/match.mapper.spec.ts +++ b/apps/server/src/modules/user-import/mapper/match.mapper.spec.ts @@ -1,24 +1,24 @@ -import { MatchCreator } from '@shared/domain/entity'; -import { MatchCreatorScope } from '@shared/domain/types'; +import { MatchCreator } from '../entity'; import { FilterMatchType, MatchType } from '../controller/dto'; import { ImportUserMatchMapper } from './match.mapper'; +import { ImportUserMatchCreatorScope } from '../domain/interface'; describe('[ImportUserMatchMapper]', () => { describe('[mapImportUserMatchScopeToDomain] from query', () => { it('should map auto from query to domain', () => { const match = FilterMatchType.AUTO; const result = ImportUserMatchMapper.mapImportUserMatchScopeToDomain(match); - expect(result).toEqual(MatchCreatorScope.AUTO); + expect(result).toEqual(ImportUserMatchCreatorScope.AUTO); }); it('should map manual/admin from query to domain', () => { const match = FilterMatchType.MANUAL; const result = ImportUserMatchMapper.mapImportUserMatchScopeToDomain(match); - expect(result).toEqual(MatchCreatorScope.MANUAL); + expect(result).toEqual(ImportUserMatchCreatorScope.MANUAL); }); it('should map no match from query to domain', () => { const match = FilterMatchType.NONE; const result = ImportUserMatchMapper.mapImportUserMatchScopeToDomain(match); - expect(result).toEqual(MatchCreatorScope.NONE); + expect(result).toEqual(ImportUserMatchCreatorScope.NONE); }); it('should fail for other values', () => { const match = 'foo' as unknown as FilterMatchType; diff --git a/apps/server/src/modules/user-import/mapper/match.mapper.ts b/apps/server/src/modules/user-import/mapper/match.mapper.ts index e6c78717d28..afd86f86e87 100644 --- a/apps/server/src/modules/user-import/mapper/match.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/match.mapper.ts @@ -1,12 +1,12 @@ -import { MatchCreator } from '@shared/domain/entity'; -import { MatchCreatorScope } from '@shared/domain/types'; import { FilterMatchType, MatchType } from '../controller/dto'; +import { ImportUserMatchCreatorScope } from '../domain/interface'; +import { MatchCreator } from '../entity'; export class ImportUserMatchMapper { - static mapImportUserMatchScopeToDomain(match: FilterMatchType): MatchCreatorScope { - if (match === FilterMatchType.AUTO) return MatchCreatorScope.AUTO; - if (match === FilterMatchType.MANUAL) return MatchCreatorScope.MANUAL; - if (match === FilterMatchType.NONE) return MatchCreatorScope.NONE; + static mapImportUserMatchScopeToDomain(match: FilterMatchType): ImportUserMatchCreatorScope { + if (match === FilterMatchType.AUTO) return ImportUserMatchCreatorScope.AUTO; + if (match === FilterMatchType.MANUAL) return ImportUserMatchCreatorScope.MANUAL; + if (match === FilterMatchType.NONE) return ImportUserMatchCreatorScope.NONE; throw Error('invalid match from filter query'); } diff --git a/apps/server/src/modules/user-import/mapper/role-name.mapper.spec.ts b/apps/server/src/modules/user-import/mapper/role-name.mapper.spec.ts index 9275072dee5..42817c2c41d 100644 --- a/apps/server/src/modules/user-import/mapper/role-name.mapper.spec.ts +++ b/apps/server/src/modules/user-import/mapper/role-name.mapper.spec.ts @@ -1,6 +1,6 @@ -import { IImportUserRoleName } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { FilterRoleType, UserRole } from '../controller/dto'; +import { ImportUserRoleName } from '../entity'; import { RoleNameMapper } from './role-name.mapper'; describe('[RoleNameMapper]', () => { @@ -21,7 +21,7 @@ describe('[RoleNameMapper]', () => { expect(result).toEqual(UserRole.STUDENT); }); it('should fail for invalid input', () => { - const roleName = 'foo' as unknown as IImportUserRoleName; + const roleName = 'foo' as unknown as ImportUserRoleName; expect(() => RoleNameMapper.mapToResponse(roleName)).toThrowError('invalid role name from domain'); }); }); diff --git a/apps/server/src/modules/user-import/mapper/role-name.mapper.ts b/apps/server/src/modules/user-import/mapper/role-name.mapper.ts index 8a38d3cb120..5c8525129b9 100644 --- a/apps/server/src/modules/user-import/mapper/role-name.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/role-name.mapper.ts @@ -1,16 +1,16 @@ -import { IImportUserRoleName } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { FilterRoleType, UserRole } from '../controller/dto'; +import { ImportUserRoleName } from '../entity'; export class RoleNameMapper { - static mapToResponse(roleName: IImportUserRoleName): UserRole { + static mapToResponse(roleName: ImportUserRoleName): UserRole { if (roleName === RoleName.ADMINISTRATOR) return UserRole.ADMIN; if (roleName === RoleName.TEACHER) return UserRole.TEACHER; if (roleName === RoleName.STUDENT) return UserRole.STUDENT; throw Error('invalid role name from domain'); } - static mapToDomain(roleName: FilterRoleType): IImportUserRoleName { + static mapToDomain(roleName: FilterRoleType): ImportUserRoleName { if (roleName === FilterRoleType.ADMIN) return RoleName.ADMINISTRATOR; if (roleName === FilterRoleType.TEACHER) return RoleName.TEACHER; if (roleName === FilterRoleType.STUDENT) return RoleName.STUDENT; diff --git a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts index 85b2b26ee11..f5f655a222d 100644 --- a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts @@ -3,8 +3,9 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { SchulconnexResponseMapper } from '@modules/provisioning'; import { System } from '@modules/system'; import { SystemEntity } from '@modules/system/entity'; -import { ImportUser, SchoolEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; +import { ImportUser } from '../entity'; export class SchulconnexImportUserMapper { public static mapDataToUserImportEntities( diff --git a/apps/server/src/modules/user-import/mapper/user-match.mapper.spec.ts b/apps/server/src/modules/user-import/mapper/user-match.mapper.spec.ts index bd9fe337b19..df826a50f2e 100644 --- a/apps/server/src/modules/user-import/mapper/user-match.mapper.spec.ts +++ b/apps/server/src/modules/user-import/mapper/user-match.mapper.spec.ts @@ -1,10 +1,10 @@ -import { MatchCreator } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; import { MatchType, UserRole } from '../controller/dto'; import { FilterUserParams } from '../controller/dto/filter-user.params'; import { ImportUserMatchMapper } from './match.mapper'; import { UserMatchMapper } from './user-match.mapper'; +import { MatchCreator } from '../entity'; describe('[UserMatchMapper]', () => { beforeAll(async () => { diff --git a/apps/server/src/modules/user-import/mapper/user-match.mapper.ts b/apps/server/src/modules/user-import/mapper/user-match.mapper.ts index d62dd10300f..91caaf4f7fc 100644 --- a/apps/server/src/modules/user-import/mapper/user-match.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/user-match.mapper.ts @@ -1,13 +1,14 @@ import { StringValidator } from '@shared/common'; -import { MatchCreator, User } from '@shared/domain/entity'; -import { NameMatch } from '@shared/domain/types'; +import { User } from '@shared/domain/entity'; import { UserMatchResponse, UserRole } from '../controller/dto'; import { FilterUserParams } from '../controller/dto/filter-user.params'; +import { ImportUserNameMatchFilter } from '../domain/interface'; +import { MatchCreator } from '../entity'; import { ImportUserMatchMapper } from './match.mapper'; export class UserMatchMapper { - static mapToDomain(query: FilterUserParams): NameMatch { - const scope: NameMatch = {}; + static mapToDomain(query: FilterUserParams): ImportUserNameMatchFilter { + const scope: ImportUserNameMatchFilter = {}; if (query.name) { if (StringValidator.isNotEmptyString(query.name, true)) { scope.name = query.name; diff --git a/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts b/apps/server/src/modules/user-import/repo/import-user.repo.spec.ts similarity index 97% rename from apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts rename to apps/server/src/modules/user-import/repo/import-user.repo.spec.ts index 82971ecbbd9..163e62fe42c 100644 --- a/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts +++ b/apps/server/src/modules/user-import/repo/import-user.repo.spec.ts @@ -2,9 +2,8 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { MikroORM, NotFoundError } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { IImportUserRoleName, ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; -import { MatchCreatorScope } from '@shared/domain/types'; import { cleanupCollections, createCollections, @@ -12,7 +11,9 @@ import { schoolEntityFactory, userFactory, } from '@shared/testing'; -import { ImportUserRepo } from '.'; +import { ImportUserRoleName, ImportUser, MatchCreator } from '../entity'; +import { ImportUserMatchCreatorScope } from '../domain/interface'; +import { ImportUserRepo } from './import-user.repo'; describe('ImportUserRepo', () => { let module: TestingModule; @@ -506,7 +507,7 @@ describe('ImportUserRepo', () => { const school = schoolEntityFactory.build(); await em.persistAndFlush(school); await expect(async () => - repo.findImportUsers(school, { role: 'foo' as unknown as IImportUserRoleName }) + repo.findImportUsers(school, { role: 'foo' as unknown as ImportUserRoleName }) ).rejects.toThrowError('unexpected role name'); }); }); @@ -590,7 +591,7 @@ describe('ImportUserRepo', () => { }); const otherSkippedImportUser = importUserFactory.build({ school }); await em.persistAndFlush([school, importUser, skippedImportUser, otherSkippedImportUser]); - const [results, count] = await repo.findImportUsers(school, { matches: [MatchCreatorScope.MANUAL] }); + const [results, count] = await repo.findImportUsers(school, { matches: [ImportUserMatchCreatorScope.MANUAL] }); expect(results).toContain(importUser); // single match expect(results).not.toContain(skippedImportUser); // other match expect(results).not.toContain(otherSkippedImportUser); // no match @@ -607,7 +608,9 @@ describe('ImportUserRepo', () => { }); const otherSkippedImportUser = importUserFactory.build({ school }); await em.persistAndFlush([school, importUser, skippedImportUser, otherSkippedImportUser]); - const [results, count] = await repo.findImportUsers(importUser.school, { matches: [MatchCreatorScope.AUTO] }); + const [results, count] = await repo.findImportUsers(importUser.school, { + matches: [ImportUserMatchCreatorScope.AUTO], + }); expect(results).toContain(importUser); // single match expect(results).not.toContain(skippedImportUser); // other match expect(results).not.toContain(otherSkippedImportUser); // no match @@ -624,7 +627,7 @@ describe('ImportUserRepo', () => { const otherSkippedImportUser = importUserFactory.build({ school }); await em.persistAndFlush([school, importUser, otherImportUser, otherSkippedImportUser]); const [results, count] = await repo.findImportUsers(importUser.school, { - matches: [MatchCreatorScope.AUTO, MatchCreatorScope.MANUAL], + matches: [ImportUserMatchCreatorScope.AUTO, ImportUserMatchCreatorScope.MANUAL], }); expect(results).toContain(importUser); // manual match expect(results).toContain(otherImportUser); // auto match @@ -644,7 +647,7 @@ describe('ImportUserRepo', () => { .build({ school }); await em.persistAndFlush([school, importUser, matchedImportUser, otherMatchedImportUser]); const [results, count] = await repo.findImportUsers(importUser.school, { - matches: [MatchCreatorScope.NONE], + matches: [ImportUserMatchCreatorScope.NONE], }); expect(results).toContain(importUser); // no match expect(results).not.toContain(matchedImportUser); // auto match @@ -664,7 +667,11 @@ describe('ImportUserRepo', () => { .build({ school }); await em.persistAndFlush([school, importUser, matchedImportUser, otherMatchedImportUser]); const [results, count] = await repo.findImportUsers(importUser.school, { - matches: [MatchCreatorScope.NONE, MatchCreatorScope.AUTO, MatchCreatorScope.MANUAL], + matches: [ + ImportUserMatchCreatorScope.NONE, + ImportUserMatchCreatorScope.AUTO, + ImportUserMatchCreatorScope.MANUAL, + ], }); expect(results).toContain(importUser); // no match expect(results).toContain(matchedImportUser); // auto match @@ -675,7 +682,9 @@ describe('ImportUserRepo', () => { const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school }); await em.persistAndFlush([school, importUser]); - const [results] = await repo.findImportUsers(school, { matches: ['foo'] as unknown as [MatchCreatorScope] }); + const [results] = await repo.findImportUsers(school, { + matches: ['foo'] as unknown as [ImportUserMatchCreatorScope], + }); expect(results).toContain(importUser); }); }); diff --git a/apps/server/src/shared/repo/importuser/importuser.repo.ts b/apps/server/src/modules/user-import/repo/import-user.repo.ts similarity index 75% rename from apps/server/src/shared/repo/importuser/importuser.repo.ts rename to apps/server/src/modules/user-import/repo/import-user.repo.ts index e1447cb8790..1f170c048f7 100644 --- a/apps/server/src/shared/repo/importuser/importuser.repo.ts +++ b/apps/server/src/modules/user-import/repo/import-user.repo.ts @@ -2,11 +2,13 @@ import { FilterQuery, QueryOrderMap } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; import { ObjectId } from '@mikro-orm/mongodb'; -import { ImportUser, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; -import { Counted, EntityId, IImportUserScope } from '@shared/domain/types'; +import { Counted, EntityId } from '@shared/domain/types'; import { BaseRepo } from '@shared/repo/base.repo'; -import { ImportUserScope } from './importuser.scope'; +import { ImportUser } from '../entity'; +import { ImportUserScope } from './import-user.scope'; +import { ImportUserFilter } from '../domain/interface'; @Injectable() export class ImportUserRepo extends BaseRepo { @@ -35,18 +37,21 @@ export class ImportUserRepo extends BaseRepo { async findImportUsers( school: SchoolEntity, - filters: IImportUserScope = {}, + filters?: ImportUserFilter, options?: IFindOptions ): Promise> { const scope = new ImportUserScope(); scope.bySchool(school); - if (filters.firstName != null) scope.byFirstName(filters.firstName); - if (filters.lastName != null) scope.byLastName(filters.lastName); - if (filters.loginName != null) scope.byLoginName(filters.loginName); - if (filters.role != null) scope.byRole(filters.role); - if (filters.classes != null) scope.byClasses(filters.classes); - if (filters.matches != null) scope.byMatches(filters.matches); - if (filters.flagged === true) scope.isFlagged(true); + if (filters) { + if (filters.firstName) scope.byFirstName(filters.firstName); + if (filters.lastName) scope.byLastName(filters.lastName); + if (filters.loginName) scope.byLoginName(filters.loginName); + if (filters.role) scope.byRole(filters.role); + if (filters.classes) scope.byClasses(filters.classes); + if (filters.matches) scope.byMatches(filters.matches); + if (filters.flagged) scope.isFlagged(true); + } + const countedImportUsers = await this.findImportUsersAndCount(scope.query, options); return countedImportUsers; } diff --git a/apps/server/src/shared/repo/importuser/importuser.scope.ts b/apps/server/src/modules/user-import/repo/import-user.scope.ts similarity index 85% rename from apps/server/src/shared/repo/importuser/importuser.scope.ts rename to apps/server/src/modules/user-import/repo/import-user.scope.ts index 2f0efffa30e..08911f4851a 100644 --- a/apps/server/src/shared/repo/importuser/importuser.scope.ts +++ b/apps/server/src/modules/user-import/repo/import-user.scope.ts @@ -2,11 +2,12 @@ import { FilterQuery } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { StringValidator } from '@shared/common'; -import { ImportUser, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; -import { MatchCreatorScope } from '@shared/domain/types'; -import { MongoPatterns } from '../mongo.patterns'; -import { Scope } from '../scope'; +import { MongoPatterns } from '@shared/repo'; +import { Scope } from '@shared/repo/scope'; +import { ImportUserMatchCreatorScope } from '../domain/interface'; +import { ImportUser } from '../entity'; export class ImportUserScope extends Scope { bySchool(school: SchoolEntity): ImportUserScope { @@ -99,12 +100,12 @@ export class ImportUserScope extends Scope { return this; } - byMatches(matches: MatchCreatorScope[]) { + byMatches(matches: ImportUserMatchCreatorScope[]) { const queries = matches .map((match) => { - if (match === MatchCreatorScope.MANUAL) return { matchedBy: 'admin' }; - if (match === MatchCreatorScope.AUTO) return { matchedBy: 'auto' }; - if (match === MatchCreatorScope.NONE) return { matchedBy: null }; + if (match === ImportUserMatchCreatorScope.MANUAL) return { matchedBy: 'admin' }; + if (match === ImportUserMatchCreatorScope.AUTO) return { matchedBy: 'auto' }; + if (match === ImportUserMatchCreatorScope.NONE) return { matchedBy: null }; return null; }) .filter((match) => match != null); diff --git a/apps/server/src/modules/user-import/repo/index.ts b/apps/server/src/modules/user-import/repo/index.ts new file mode 100644 index 00000000000..4521ac9c3da --- /dev/null +++ b/apps/server/src/modules/user-import/repo/index.ts @@ -0,0 +1,2 @@ +export * from './import-user.repo'; +export * from './import-user.scope'; diff --git a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts index ddac798c365..3283f45a51b 100644 --- a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts +++ b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.spec.ts @@ -3,19 +3,20 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { SchulconnexResponse, schulconnexResponseFactory, SchulconnexRestClient } from '@infra/schulconnex-client'; import type { System } from '@modules/system'; import { SystemEntity } from '@modules/system/entity'; +import { systemFactory } from '@modules/system/testing'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject'; -import { ImportUser, SchoolEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { importUserFactory, schoolEntityFactory, setupEntities, systemEntityFactory, - systemFactory, userDoFactory, } from '@shared/testing'; +import { ImportUser } from '../entity'; import { UserImportSchoolExternalIdMissingLoggableException } from '../loggable'; import { SchulconnexFetchImportUsersService } from './schulconnex-fetch-import-users.service'; diff --git a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts index 26c4e0c395f..e972cc0fd13 100644 --- a/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts +++ b/apps/server/src/modules/user-import/service/schulconnex-fetch-import-users.service.ts @@ -4,7 +4,8 @@ import { System } from '@modules/system'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { UserDO } from '@shared/domain/domainobject'; -import { ImportUser, SchoolEntity } from '@shared/domain/entity'; +import { SchoolEntity } from '@shared/domain/entity'; +import { ImportUser } from '../entity'; import { UserImportSchoolExternalIdMissingLoggableException } from '../loggable'; import { SchulconnexImportUserMapper } from '../mapper'; diff --git a/apps/server/src/modules/user-import/service/user-import.service.spec.ts b/apps/server/src/modules/user-import/service/user-import.service.spec.ts index 36c46aa92ef..e4f0b759e79 100644 --- a/apps/server/src/modules/user-import/service/user-import.service.spec.ts +++ b/apps/server/src/modules/user-import/service/user-import.service.spec.ts @@ -3,25 +3,26 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { ObjectId } from '@mikro-orm/mongodb'; import { LegacySchoolService } from '@modules/legacy-school'; import { System, SystemService } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { UserService } from '@modules/user'; import { InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { SchoolFeature } from '@shared/domain/types'; -import { ImportUserRepo } from '@shared/repo'; import { importUserFactory, legacySchoolDoFactory, schoolEntityFactory, setupEntities, - systemFactory, userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { ImportUser, MatchCreator } from '../entity'; import { UserMigrationCanceledLoggable, UserMigrationIsNotEnabled } from '../loggable'; +import { ImportUserRepo } from '../repo'; import { UserImportConfig } from '../user-import-config'; import { UserImportService } from './user-import.service'; diff --git a/apps/server/src/modules/user-import/service/user-import.service.ts b/apps/server/src/modules/user-import/service/user-import.service.ts index d63fdb29c2d..ab9014acef0 100644 --- a/apps/server/src/modules/user-import/service/user-import.service.ts +++ b/apps/server/src/modules/user-import/service/user-import.service.ts @@ -4,12 +4,13 @@ import { UserService } from '@modules/user'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { SchoolFeature } from '@shared/domain/types'; -import { ImportUserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; import { UserMigrationCanceledLoggable, UserMigrationIsNotEnabled } from '../loggable'; import { UserImportConfig } from '../user-import-config'; +import { ImportUserRepo } from '../repo/import-user.repo'; +import { ImportUser, MatchCreator } from '../entity'; @Injectable() export class UserImportService { diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts index fb34bc9c7b9..5b6df1f8e97 100644 --- a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts @@ -3,20 +3,21 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationService } from '@modules/authorization'; import { System, SystemService } from '@modules/system'; import { SystemEntity } from '@modules/system/entity'; +import { systemFactory } from '@modules/system/testing'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { ImportUser, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { importUserFactory, setupEntities, systemEntityFactory, - systemFactory, userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; import { UserLoginMigrationService } from '../../user-login-migration'; +import { ImportUser } from '../entity'; import { UserLoginMigrationNotActiveLoggableException, UserMigrationIsNotEnabledLoggableException } from '../loggable'; import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts index 839cf83b56a..40ee1e9f8d6 100644 --- a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts @@ -4,9 +4,10 @@ import { UserLoginMigrationService } from '@modules/user-login-migration'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { ImportUser, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { ImportUser } from '../entity'; import { UserLoginMigrationNotActiveLoggableException, UserMigrationIsNotEnabledLoggableException } from '../loggable'; import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index ae421b3373c..af5c6d96fca 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -5,6 +5,7 @@ import { AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; import { System, SystemService } from '@modules/system'; import { SystemEntity } from '@modules/system/entity'; +import { systemFactory } from '@modules/system/testing'; import { UserService } from '@modules/user'; import { UserLoginMigrationNotActiveLoggableException } from '@modules/user-import/loggable/user-login-migration-not-active.loggable-exception'; import { UserLoginMigrationService, UserMigrationService } from '@modules/user-login-migration'; @@ -14,10 +15,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserAlreadyAssignedToImportUserError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { Counted, IImportUserScope, MatchCreatorScope, SchoolFeature } from '@shared/domain/types'; -import { ImportUserRepo, UserRepo } from '@shared/repo'; +import { Counted, SchoolFeature } from '@shared/domain/types'; +import { UserRepo } from '@shared/repo'; import { federalStateFactory, importUserFactory, @@ -25,13 +26,15 @@ import { schoolEntityFactory, setupEntities, systemEntityFactory, - systemFactory, userDoFactory, userFactory, userLoginMigrationDOFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { ImportUserFilter, ImportUserMatchCreatorScope } from '../domain/interface'; +import { ImportUser, MatchCreator } from '../entity'; import { SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable } from '../loggable'; +import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; import { @@ -593,7 +596,7 @@ describe('[ImportUserModule]', () => { await uc.saveAllUsersMatches(currentUser.id); - const filters = { matches: [MatchCreatorScope.MANUAL, MatchCreatorScope.AUTO] }; + const filters = { matches: [ImportUserMatchCreatorScope.MANUAL, ImportUserMatchCreatorScope.AUTO] }; expect(importUserRepoFindImportUsersSpy).toHaveBeenCalledWith(school, filters, {}); expect(importUserRepoDeleteImportUserSpy).toHaveBeenCalledTimes(2); expect(userRepoSaveWithoutFlushSpy).toHaveBeenCalledTimes(2); @@ -1273,7 +1276,7 @@ describe('[ImportUserModule]', () => { await uc.clearAllAutoMatches(currentUser.id); - const autoMatchFilter: IImportUserScope = { matches: [MatchCreatorScope.AUTO] }; + const autoMatchFilter: ImportUserFilter = { matches: [ImportUserMatchCreatorScope.AUTO] }; expect(importUserRepo.findImportUsers).toBeCalledWith(currentUser.school, autoMatchFilter); expect(userImportService.saveImportUsers).toBeCalledWith(importUsers); }); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index 02f9997a651..b33363583c4 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -10,10 +10,10 @@ import { ConfigService } from '@nestjs/config'; import { UserAlreadyAssignedToImportUserError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo, UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; -import { ImportUser, MatchCreator, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { IFindOptions, Permission } from '@shared/domain/interface'; -import { Counted, EntityId, IImportUserScope, MatchCreatorScope, NameMatch } from '@shared/domain/types'; -import { ImportUserRepo, UserRepo } from '@shared/repo'; +import { Counted, EntityId } from '@shared/domain/types'; +import { UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; import { MigrationMayBeCompleted, @@ -25,6 +25,9 @@ import { UserAlreadyMigratedLoggable, } from '../loggable'; +import { ImportUserMatchCreatorScope, ImportUserNameMatchFilter, ImportUserFilter } from '../domain/interface'; +import { ImportUser, MatchCreator } from '../entity'; +import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; import { @@ -66,7 +69,7 @@ export class UserImportUc { */ public async findAllImportUsers( currentUserId: EntityId, - query: IImportUserScope, + query: ImportUserFilter, options?: IFindOptions ): Promise> { const currentUser = await this.getCurrentUser(currentUserId, Permission.IMPORT_USER_VIEW); @@ -158,7 +161,7 @@ export class UserImportUc { */ public async findAllUnmatchedUsers( currentUserId: EntityId, - query: NameMatch, + query: ImportUserNameMatchFilter, options?: IFindOptions ): Promise> { const currentUser = await this.getCurrentUser(currentUserId, Permission.IMPORT_USER_VIEW); @@ -178,7 +181,9 @@ export class UserImportUc { this.userImportService.checkFeatureEnabled(school); - const filters: IImportUserScope = { matches: [MatchCreatorScope.MANUAL, MatchCreatorScope.AUTO] }; + const filters: ImportUserFilter = { + matches: [ImportUserMatchCreatorScope.MANUAL, ImportUserMatchCreatorScope.AUTO], + }; // TODO batch/paginated import? const options: IFindOptions = {}; // TODO Change ImportUserRepo to DO to fix this workaround @@ -339,7 +344,7 @@ export class UserImportUc { const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); this.userImportService.checkFeatureEnabled(school); - const filters: IImportUserScope = { matches: [MatchCreatorScope.AUTO] }; + const filters: ImportUserFilter = { matches: [ImportUserMatchCreatorScope.AUTO] }; const [autoMatchedUsers]: Counted = await this.importUserRepo.findImportUsers( currentUser.school, filters diff --git a/apps/server/src/modules/user-import/user-import.module.ts b/apps/server/src/modules/user-import/user-import.module.ts index b69b80b9fd1..55dadecb912 100644 --- a/apps/server/src/modules/user-import/user-import.module.ts +++ b/apps/server/src/modules/user-import/user-import.module.ts @@ -8,11 +8,12 @@ import { UserModule } from '@modules/user'; import { UserLoginMigrationModule } from '@modules/user-login-migration'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { ImportUserRepo, LegacySchoolRepo, UserRepo } from '@shared/repo'; +import { LegacySchoolRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { ImportUserController } from './controller/import-user.controller'; import { SchulconnexFetchImportUsersService, UserImportService } from './service'; import { UserImportFetchUc, UserImportUc } from './uc'; +import { ImportUserRepo } from './repo'; @Module({ imports: [ diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index b2373b55a4c..aba71e8d589 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -5,10 +5,11 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { OauthTokenResponse } from '@modules/oauth/service/dto'; import { ServerTestModule } from '@modules/server'; import { type SystemEntity } from '@modules/system/entity'; +import { ImportUser } from '@modules/user-import/entity'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { ImportUser, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts index cb3ecb70348..fa2dc42f40f 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts @@ -3,12 +3,13 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { LegacySchoolService } from '@modules/legacy-school'; import { System, SystemService } from '@modules/system'; +import { systemFactory } from '@modules/system/testing'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { UserLoginMigrationRepo } from '@shared/repo'; -import { legacySchoolDoFactory, systemFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; +import { legacySchoolDoFactory, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { IdenticalUserLoginMigrationSystemLoggableException, MoinSchuleSystemNotFoundLoggableException, diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index f45b68d865f..71734a29837 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -15,13 +15,13 @@ import { TldrawDrawing } from '@modules/tldraw/entities'; import { ContextExternalToolEntity } 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 { ColumnBoardNode } from './column-board-node.entity'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; import { DashboardGridElementModel, DashboardModelEntity } from './dashboard.model.entity'; import { CountyEmbeddable, FederalStateEntity } from './federal-state.entity'; -import { ImportUser } from './import-user.entity'; import { ColumnboardBoardElement, LegacyBoard, diff --git a/apps/server/src/shared/domain/entity/index.ts b/apps/server/src/shared/domain/entity/index.ts index 99349a0649e..0925cb5d2d9 100644 --- a/apps/server/src/shared/domain/entity/index.ts +++ b/apps/server/src/shared/domain/entity/index.ts @@ -6,7 +6,6 @@ export * from './coursegroup.entity'; export * from './dashboard.entity'; export * from './dashboard.model.entity'; export * from './federal-state.entity'; -export * from './import-user.entity'; export * from './legacy-board'; export * from './lesson.entity'; export * from './ltitool.entity'; @@ -22,5 +21,4 @@ export * from './team.entity'; export * from './user-login-migration.entity'; export * from './user.entity'; export * from './video-conference.entity'; -export * from './external-source.embeddable'; export * from './consent'; diff --git a/apps/server/src/shared/domain/types/importuser.types.ts b/apps/server/src/shared/domain/types/importuser.types.ts deleted file mode 100644 index de189aed43e..00000000000 --- a/apps/server/src/shared/domain/types/importuser.types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { IImportUserRoleName } from '../entity/import-user.entity'; - -export enum MatchCreatorScope { - AUTO = 'auto', - MANUAL = 'admin', - NONE = 'none', -} - -export interface IImportUserScope { - firstName?: string; - lastName?: string; - loginName?: string; - matches?: MatchCreatorScope[]; - flagged?: boolean; - role?: IImportUserRoleName; - classes?: string; -} - -export interface NameMatch { - /** - * Match filter for lastName or firstName - */ - name?: string; -} diff --git a/apps/server/src/shared/domain/types/index.ts b/apps/server/src/shared/domain/types/index.ts index dacc63de9ee..b1a500e865a 100644 --- a/apps/server/src/shared/domain/types/index.ts +++ b/apps/server/src/shared/domain/types/index.ts @@ -1,6 +1,5 @@ export * from './counted'; export * from './entity-id'; -export * from './importuser.types'; export * from './input-format.types'; export * from './learnroom.types'; export * from './news.types'; diff --git a/apps/server/src/shared/repo/importuser/index.ts b/apps/server/src/shared/repo/importuser/index.ts deleted file mode 100644 index dbdcdc29cd4..00000000000 --- a/apps/server/src/shared/repo/importuser/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './importuser.repo'; diff --git a/apps/server/src/shared/repo/index.ts b/apps/server/src/shared/repo/index.ts index 1c4c7b90f97..1cf160a0fb6 100644 --- a/apps/server/src/shared/repo/index.ts +++ b/apps/server/src/shared/repo/index.ts @@ -11,7 +11,6 @@ export * from './course'; export * from './coursegroup'; export * from './dashboard'; export * from './federalstate'; -export * from './importuser'; export * from './ltitool'; export * from './materials'; export * from './mongo.patterns'; diff --git a/apps/server/src/shared/repo/user/user.repo.ts b/apps/server/src/shared/repo/user/user.repo.ts index 957d4ea1129..126dbabd99a 100644 --- a/apps/server/src/shared/repo/user/user.repo.ts +++ b/apps/server/src/shared/repo/user/user.repo.ts @@ -1,8 +1,9 @@ import { ObjectId } from '@mikro-orm/mongodb'; +import { ImportUserNameMatchFilter } from '@modules/user-import/domain/interface'; import { Injectable } from '@nestjs/common'; import { Role, SchoolEntity, User } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; -import { Counted, EntityId, NameMatch } from '@shared/domain/types'; +import { Counted, EntityId } from '@shared/domain/types'; import { BaseRepo } from '@shared/repo/base.repo'; import { UserScope } from './user.scope'; @@ -49,7 +50,7 @@ export class UserRepo extends BaseRepo { async findForImportUser( school: SchoolEntity, - filters?: NameMatch, + filters?: ImportUserNameMatchFilter, options?: IFindOptions ): Promise> { const { pagination, order } = options || {}; diff --git a/apps/server/src/shared/testing/factory/domainobject/index.ts b/apps/server/src/shared/testing/factory/domainobject/index.ts index 806f417ce84..0fbfe2209fb 100644 --- a/apps/server/src/shared/testing/factory/domainobject/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/index.ts @@ -5,10 +5,4 @@ export * from './domain-object.factory'; export * from './user-login-migration-do.factory'; export * from './lti-tool.factory'; export * from './pseudonym.factory'; -export { - systemFactory, - systemOauthConfigFactory, - systemLdapConfigFactory, - systemOidcConfigFactory, -} from './system/system.factory'; export { schoolSystemOptionsFactory } from './school-system-options/school-system-options.factory'; diff --git a/apps/server/src/shared/testing/factory/group-entity.factory.ts b/apps/server/src/shared/testing/factory/group-entity.factory.ts index a78b68c7263..4bf8b925a82 100644 --- a/apps/server/src/shared/testing/factory/group-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/group-entity.factory.ts @@ -1,5 +1,5 @@ import { GroupEntity, GroupEntityProps, GroupEntityTypes, GroupValidPeriodEmbeddable } from '@modules/group/entity'; -import { ExternalSourceEmbeddable } from '@shared/domain/entity'; +import { ExternalSourceEmbeddable } from '@modules/system/entity'; import { RoleName } from '@shared/domain/interface'; import { BaseFactory } from './base.factory'; import { roleFactory } from './role.factory'; diff --git a/apps/server/src/shared/testing/factory/import-user.factory.ts b/apps/server/src/shared/testing/factory/import-user.factory.ts index eaaaeafe34e..884795b667e 100644 --- a/apps/server/src/shared/testing/factory/import-user.factory.ts +++ b/apps/server/src/shared/testing/factory/import-user.factory.ts @@ -1,4 +1,5 @@ -import { IImportUserRoleName, ImportUser, ImportUserProperties, MatchCreator, User } from '@shared/domain/entity'; +import { ImportUserRoleName, ImportUser, ImportUserProperties, MatchCreator } from '@modules/user-import/entity'; +import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { DeepPartial } from 'fishery'; import { v4 as uuidv4 } from 'uuid'; @@ -23,7 +24,7 @@ export const importUserFactory = ImportUserFactory.define(ImportUser, ({ sequenc firstName: `John${sequence}`, lastName: `Doe${sequence}`, email: `user-${sequence}@example.com`, - roleNames: [RoleName.STUDENT as IImportUserRoleName], + roleNames: [RoleName.STUDENT as ImportUserRoleName], classNames: ['firstClass'], flagged: false, }; From 787002a00201f3442a77cb2e13a3b302c1b45355 Mon Sep 17 00:00:00 2001 From: Uwe Ilgenstein Date: Fri, 6 Sep 2024 14:45:24 +0200 Subject: [PATCH 20/29] BC-8026 - Revert Hotfix from BC-8014 (#5227) This reverts commit 8030276511b68132f3438ab7ce470fdb15c01e44. --- .../controller/rooms.controller.spec.ts | 209 ------------------ .../learnroom/controller/rooms.controller.ts | 92 -------- .../modules/learnroom/learnroom-api.module.ts | 3 +- sonar-project.properties | 2 +- 4 files changed, 2 insertions(+), 304 deletions(-) delete mode 100644 apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts delete mode 100644 apps/server/src/modules/learnroom/controller/rooms.controller.ts diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts deleted file mode 100644 index 2be5c89b715..00000000000 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { createMock } from '@golevelup/ts-jest'; -import { CopyApiResponse, CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { Test, TestingModule } from '@nestjs/testing'; -import { EntityId } from '@shared/domain/types'; -import { currentUserFactory } from '@shared/testing'; -import { RoomBoardResponseMapper } from '../mapper/room-board-response.mapper'; -import { RoomBoardDTO } from '../types'; -import { CourseCopyUC } from '../uc/course-copy.uc'; -import { LessonCopyUC } from '../uc/lesson-copy.uc'; -import { CourseRoomsUc } from '../uc/course-rooms.uc'; -import { SingleColumnBoardResponse } from './dto'; -import { RoomsController } from './rooms.controller'; - -describe('rooms controller', () => { - let controller: RoomsController; - let mapper: RoomBoardResponseMapper; - let uc: CourseRoomsUc; - let courseCopyUc: CourseCopyUC; - let lessonCopyUc: LessonCopyUC; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [], - providers: [ - RoomsController, - { - provide: CourseRoomsUc, - useValue: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getBoard(roomId: EntityId, userId: EntityId): Promise { - throw new Error('please write mock for RoomsUc.getBoard'); - }, - updateVisibilityOfLegacyBoardElement( - roomId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars - elementId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars - userId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars - visibility: boolean // eslint-disable-line @typescript-eslint/no-unused-vars - ): Promise { - throw new Error('please write mock for RoomsUc.updateVisibilityOfBoardElement'); - }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - reorderBoardElements(roomId: EntityId, userId: EntityId, orderedList: EntityId[]): Promise { - throw new Error('please write mock for RoomsUc.reorderBoardElements'); - }, - }, - }, - { - provide: CourseCopyUC, - useValue: createMock(), - }, - { - provide: LessonCopyUC, - useValue: createMock(), - }, - { - provide: RoomBoardResponseMapper, - useValue: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - mapToResponse(board: RoomBoardDTO): SingleColumnBoardResponse { - throw new Error('please write mock for Boardmapper.mapToResponse'); - }, - }, - }, - ], - }).compile(); - controller = module.get(RoomsController); - mapper = module.get(RoomBoardResponseMapper); - uc = module.get(CourseRoomsUc); - courseCopyUc = module.get(CourseCopyUC); - lessonCopyUc = module.get(LessonCopyUC); - }); - - describe('getRoomBoard', () => { - describe('when simple room is fetched', () => { - const setup = () => { - const currentUser = currentUserFactory.build(); - - const ucResult = { - roomId: 'id', - title: 'title', - displayColor: '#FFFFFF', - elements: [], - isArchived: false, - isSynchronized: false, - } as RoomBoardDTO; - const ucSpy = jest.spyOn(uc, 'getBoard').mockImplementation(() => Promise.resolve(ucResult)); - - const mapperResult = new SingleColumnBoardResponse({ - roomId: 'id', - title: 'title', - displayColor: '#FFFFFF', - elements: [], - isArchived: false, - isSynchronized: false, - }); - const mapperSpy = jest.spyOn(mapper, 'mapToResponse').mockImplementation(() => mapperResult); - return { currentUser, ucResult, ucSpy, mapperResult, mapperSpy }; - }; - - it('should call uc with ids', async () => { - const { currentUser, ucSpy } = setup(); - - await controller.getRoomBoard({ roomId: 'roomId' }, currentUser); - - expect(ucSpy).toHaveBeenCalledWith('roomId', currentUser.userId); - }); - - it('should call mapper with uc result', async () => { - const { currentUser, ucResult, mapperSpy } = setup(); - - await controller.getRoomBoard({ roomId: 'roomId' }, currentUser); - - expect(mapperSpy).toHaveBeenCalledWith(ucResult); - }); - - it('should return mapped result', async () => { - const { currentUser, mapperResult } = setup(); - - const result = await controller.getRoomBoard({ roomId: 'roomId' }, currentUser); - - expect(result).toEqual(mapperResult); - }); - }); - }); - - describe('patchVisibility', () => { - it('should call uc', async () => { - const currentUser = currentUserFactory.build(); - const ucSpy = jest.spyOn(uc, 'updateVisibilityOfLegacyBoardElement').mockImplementation(() => Promise.resolve()); - await controller.patchElementVisibility( - { roomId: 'roomid', elementId: 'elementId' }, - { visibility: true }, - currentUser - ); - expect(ucSpy).toHaveBeenCalled(); - }); - }); - - describe('patchOrder', () => { - it('should call uc', async () => { - const currentUser = currentUserFactory.build(); - const ucSpy = jest.spyOn(uc, 'reorderBoardElements').mockImplementation(() => Promise.resolve()); - await controller.patchOrderingOfElements({ roomId: 'roomid' }, { elements: ['id', 'id', 'id'] }, currentUser); - expect(ucSpy).toHaveBeenCalledWith('roomid', currentUser.userId, ['id', 'id', 'id']); - }); - }); - - describe('copyCourse', () => { - describe('when course should be copied via API call', () => { - const setup = () => { - const currentUser = currentUserFactory.build(); - const ucResult = { - title: 'example title', - type: 'COURSE' as CopyElementType, - status: 'SUCCESS' as CopyStatusEnum, - elements: [], - } as CopyStatus; - const ucSpy = jest.spyOn(courseCopyUc, 'copyCourse').mockImplementation(() => Promise.resolve(ucResult)); - return { currentUser, ucSpy }; - }; - it('should call uc', async () => { - const { currentUser, ucSpy } = setup(); - - await controller.copyCourse(currentUser, { roomId: 'roomId' }); - expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'roomId'); - }); - - it('should return result of correct type', async () => { - const { currentUser } = setup(); - - const result = await controller.copyCourse(currentUser, { roomId: 'roomId' }); - expect(result).toBeInstanceOf(CopyApiResponse); - }); - }); - }); - - describe('copyLesson', () => { - describe('when lesson should be copied via API call', () => { - const setup = () => { - const currentUser = currentUserFactory.build(); - const ucResult = { - title: 'example title', - type: 'LESSON' as CopyElementType, - status: 'SUCCESS' as CopyStatusEnum, - elements: [], - } as CopyStatus; - const ucSpy = jest.spyOn(lessonCopyUc, 'copyLesson').mockImplementation(() => Promise.resolve(ucResult)); - return { currentUser, ucSpy }; - }; - - it('should call uc with parentId', async () => { - const { currentUser, ucSpy } = setup(); - - await controller.copyLesson(currentUser, { lessonId: 'lessonId' }, { courseId: 'id' }); - expect(ucSpy).toHaveBeenCalledWith(currentUser.userId, 'lessonId', { - courseId: 'id', - userId: currentUser.userId, - }); - }); - - it('should return result of correct type', async () => { - const { currentUser } = setup(); - - const result = await controller.copyLesson(currentUser, { lessonId: 'lessonId' }, {}); - expect(result).toBeInstanceOf(CopyApiResponse); - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.ts deleted file mode 100644 index 6c29c37cd7d..00000000000 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; -import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { RequestTimeout } from '@shared/common'; -import { RoomBoardResponseMapper } from '../mapper/room-board-response.mapper'; -import { CourseCopyUC } from '../uc/course-copy.uc'; -import { LessonCopyUC } from '../uc/lesson-copy.uc'; -import { CourseRoomsUc } from '../uc/course-rooms.uc'; -import { - LessonCopyApiParams, - LessonUrlParams, - PatchOrderParams, - PatchVisibilityParams, - CourseRoomElementUrlParams, - CourseRoomUrlParams, - SingleColumnBoardResponse, -} from './dto'; - -// TODO: remove this file, and remove it from sonar-project.properties - -@ApiTags('Rooms') -@JwtAuthentication() -@Controller('rooms') -export class RoomsController { - constructor( - private readonly roomsUc: CourseRoomsUc, - private readonly mapper: RoomBoardResponseMapper, - private readonly courseCopyUc: CourseCopyUC, - private readonly lessonCopyUc: LessonCopyUC - ) {} - - @Get(':roomId/board') - async getRoomBoard( - @Param() urlParams: CourseRoomUrlParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - const board = await this.roomsUc.getBoard(urlParams.roomId, currentUser.userId); - const mapped = this.mapper.mapToResponse(board); - return mapped; - } - - @Patch(':roomId/elements/:elementId/visibility') - async patchElementVisibility( - @Param() urlParams: CourseRoomElementUrlParams, - @Body() params: PatchVisibilityParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - await this.roomsUc.updateVisibilityOfLegacyBoardElement( - urlParams.roomId, - urlParams.elementId, - currentUser.userId, - params.visibility - ); - } - - @Patch(':roomId/board/order') - async patchOrderingOfElements( - @Param() urlParams: CourseRoomUrlParams, - @Body() params: PatchOrderParams, - @CurrentUser() currentUser: ICurrentUser - ): Promise { - await this.roomsUc.reorderBoardElements(urlParams.roomId, currentUser.userId, params.elements); - } - - @Post(':roomId/copy') - @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') - async copyCourse( - @CurrentUser() currentUser: ICurrentUser, - @Param() urlParams: CourseRoomUrlParams - ): Promise { - const copyStatus = await this.courseCopyUc.copyCourse(currentUser.userId, urlParams.roomId); - const dto = CopyMapper.mapToResponse(copyStatus); - return dto; - } - - @Post('lessons/:lessonId/copy') - @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') - async copyLesson( - @CurrentUser() currentUser: ICurrentUser, - @Param() urlParams: LessonUrlParams, - @Body() params: LessonCopyApiParams - ): Promise { - const copyStatus = await this.lessonCopyUc.copyLesson( - currentUser.userId, - urlParams.lessonId, - CopyMapper.mapLessonCopyToDomain(params, currentUser.userId) - ); - const dto = CopyMapper.mapToResponse(copyStatus); - return dto; - } -} diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index 3770fdda168..3c565127dab 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -30,7 +30,6 @@ import { LessonCopyUC, RoomBoardDTOFactory, } from './uc'; -import { RoomsController } from './controller/rooms.controller'; /** * @deprecated - the learnroom module is deprecated and will be removed in the future @@ -49,7 +48,7 @@ import { RoomsController } from './controller/rooms.controller'; UserModule, ClassModule, ], - controllers: [DashboardController, CourseController, CourseInfoController, CourseRoomsController, RoomsController], + controllers: [DashboardController, CourseController, CourseInfoController, CourseRoomsController], providers: [ DashboardUc, CourseUc, diff --git a/sonar-project.properties b/sonar-project.properties index 4de86ab56ca..476998e113b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.projectKey=hpi-schul-cloud_schulcloud-server sonar.sources=. sonar.tests=. sonar.test.inclusions=**/*.spec.ts -sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts, **/learnroom/controller/rooms.controller.ts +sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts,**/globalSetup.ts,**/globalTeardown.ts,**/etherpad-api-client/**/*.ts,**/authorization-api-client/**/*.ts, **/course-api-client/**/*.ts,**/board-api-client/**/*.ts sonar.cpd.exclusions=**/controller/dto/*.ts sonar.javascript.lcov.reportPaths=merged-lcov.info From a8af800a03e6b897f3204e8033eec4b0a3f817b6 Mon Sep 17 00:00:00 2001 From: Gordon Nicholas <160246213+GordonNicholasCap@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:52:09 +0200 Subject: [PATCH 21/29] N21-2120 force migration extension (#5215) --- .../api-test/user-login-migration.api.spec.ts | 602 ++++++++++++++++-- .../dto/request/force-migration.params.ts | 7 +- .../user-login-migration.controller.ts | 33 +- .../loggable/debug/index.ts | 1 + ...ion-correction-successful-loggable.spec.ts | 36 ++ ...igration-correction-successful-loggable.ts | 17 + .../user-login-migration/loggable/index.ts | 1 + ...-already-closed.loggable-exception.spec.ts | 36 ++ ...ernal-school-id.loggable-exception.spec.ts | 30 + ...d-external-school-id.loggable-exception.ts | 27 + .../service/school-migration.service.spec.ts | 75 +++ .../service/school-migration.service.ts | 13 + .../user-login-migration.service.spec.ts | 67 ++ .../service/user-login-migration.service.ts | 6 + .../service/user-migration.service.spec.ts | 115 +++- .../service/user-migration.service.ts | 14 + .../uc/user-login-migration.uc.spec.ts | 590 ++++++++++++++++- .../uc/user-login-migration.uc.ts | 95 ++- 18 files changed, 1675 insertions(+), 90 deletions(-) create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.ts diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index aba71e8d589..6f9b35499eb 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -28,6 +28,8 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { UUID } from 'bson'; import { Response } from 'supertest'; +import { DeepPartial } from 'fishery'; +import { UserLoginMigrationUc } from '../../uc'; import { ForceMigrationParams, Oauth2MigrationParams, UserLoginMigrationResponse } from '../dto'; jest.mock('jwks-rsa', () => () => { @@ -1406,97 +1408,561 @@ describe('UserLoginMigrationController (API)', () => { }); }); - describe('[GET] /user-login-migrations/force-migration', () => { - describe('when forcing a school to migrate', () => { - const setup = async () => { - const targetSystem: SystemEntity = systemEntityFactory - .withOauthConfig() - .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + describe('[POST] /user-login-migrations/force-migration', () => { + const expectSchoolMigrationUnchanged = ( + school: SchoolEntity, + externalId: string, + sourceSystem: SystemEntity, + targetSystem: SystemEntity + ) => { + const expectedSchoolPartial: DeepPartial = { + externalId, + }; + expect(school).toEqual(expect.objectContaining(expectedSchoolPartial)); - const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + const systems: SystemEntity[] = school?.systems.getItems(); + systems?.forEach((system) => { + expect([sourceSystem.id, targetSystem.id]).toContainEqual(system.id); + }); + }; - const school: SchoolEntity = schoolEntityFactory.buildWithId({ - systems: [sourceSystem], + const expectUserMigrated = (migratedUser: User, preMigratedUser: User, externalId: string) => { + const expectedUserPartial: DeepPartial = { + externalId, + }; + expect(migratedUser).toEqual(expect.objectContaining(expectedUserPartial)); + expect(migratedUser.lastLoginSystemChange).not.toEqual(preMigratedUser.lastLoginSystemChange); + expect(migratedUser.previousExternalId).toEqual(preMigratedUser.externalId); + }; + + const spyOnForceMigrations = () => { + jest.spyOn(UserLoginMigrationUc.prototype, 'forceMigration'); + jest.spyOn(UserLoginMigrationUc.prototype, 'forceExtendedMigration'); + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when "forceExtendedMode" is false', () => { + describe('when forcing a school to migrate', () => { + const setup = async () => { + const targetSystem: SystemEntity = systemEntityFactory + .withOauthConfig() + .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem], + }); + + const email = 'admin@test.com'; + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ + email, + school, + }); + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + superheroAccount, + superheroUser, + adminAccount, + adminUser, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = email; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = 'externalSchoolId'; + requestBody.forceExtendedMode = false; + + return { + requestBody, + loggedInClient, + sourceSystem, + targetSystem, + school, + adminUser, + superheroUser, + }; + }; + + it('should start the migration for the school and migrate the user and school', async () => { + const { requestBody, loggedInClient, school, sourceSystem, targetSystem, adminUser } = await setup(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.CREATED); + + const userLoginMigration = await em.findOneOrFail(UserLoginMigrationEntity, { school: school.id }); + expect(userLoginMigration.sourceSystem?.id).toEqual(sourceSystem.id); + expect(userLoginMigration.targetSystem.id).toEqual(targetSystem.id); + + const schoolEntity = await em.findOneOrFail(SchoolEntity, school.id); + expect(schoolEntity).toEqual( + expect.objectContaining({ + externalId: requestBody.externalSchoolId, + }) + ); + + const migratedUser = await em.findOneOrFail(User, adminUser.id); + expectUserMigrated(migratedUser, adminUser, requestBody.externalUserId); }); - const email = 'admin@test.com'; - const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ - email, - school, + it('should call the correct force migration function', async () => { + const { requestBody, loggedInClient, superheroUser } = await setup(); + spyOnForceMigrations(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.CREATED); + expect(UserLoginMigrationUc.prototype.forceExtendedMigration).not.toHaveBeenCalled(); + expect(UserLoginMigrationUc.prototype.forceMigration).toHaveBeenCalledWith( + superheroUser.id, + requestBody.email, + requestBody.externalUserId, + requestBody.externalSchoolId + ); }); - const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + }); - await em.persistAndFlush([ - sourceSystem, - targetSystem, - school, - superheroAccount, - superheroUser, - adminAccount, - adminUser, - ]); - em.clear(); + describe('when authentication of user failed', () => { + const setup = () => { + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = 'fail@test.com'; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = 'externalSchoolId'; + requestBody.forceExtendedMode = false; + + return { + requestBody, + }; + }; - const loggedInClient = await testApiClient.login(superheroAccount); + it('should throw an UnauthorizedException', async () => { + const { requestBody } = setup(); - const requestBody: ForceMigrationParams = new ForceMigrationParams(); - requestBody.email = email; - requestBody.externalUserId = 'externalUserId'; - requestBody.externalSchoolId = 'externalSchoolId'; + const response: Response = await testApiClient.post(`/force-migration`, requestBody); - return { - requestBody, - loggedInClient, - sourceSystem, - targetSystem, - school, - adminUser, + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); + + describe('when "forceExtendedMode" is true', () => { + describe('when forcing a school to migrate', () => { + const setup = async () => { + const targetSystem: SystemEntity = systemEntityFactory + .withOauthConfig() + .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem], + }); + + const email = 'admin@test.com'; + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ + email, + school, + }); + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + superheroAccount, + superheroUser, + adminAccount, + adminUser, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = email; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = 'externalSchoolId'; + requestBody.forceExtendedMode = true; + + return { + requestBody, + loggedInClient, + sourceSystem, + targetSystem, + school, + adminUser, + superheroUser, + }; }; - }; - it('should start the migration for the school and migrate the user and school', async () => { - const { requestBody, loggedInClient, school, sourceSystem, targetSystem, adminUser } = await setup(); + it('should start the migration for the school and migrate the user and school', async () => { + const { requestBody, loggedInClient, school, sourceSystem, targetSystem, adminUser } = await setup(); - const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); - expect(response.status).toEqual(HttpStatus.CREATED); + expect(response.status).toEqual(HttpStatus.CREATED); + + const userLoginMigration = await em.findOneOrFail(UserLoginMigrationEntity, { school: school.id }); + expect(userLoginMigration.sourceSystem?.id).toEqual(sourceSystem.id); + expect(userLoginMigration.targetSystem.id).toEqual(targetSystem.id); + + const schoolEntity = await em.findOneOrFail(SchoolEntity, school.id); + expect(schoolEntity).toEqual( + expect.objectContaining({ + externalId: requestBody.externalSchoolId, + }) + ); + + const migratedUser = await em.findOneOrFail(User, adminUser.id); + expectUserMigrated(migratedUser, adminUser, requestBody.externalUserId); + }); + + it('should call the correct force migration function', async () => { + const { requestBody, loggedInClient, superheroUser } = await setup(); + spyOnForceMigrations(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.CREATED); + expect(UserLoginMigrationUc.prototype.forceMigration).not.toHaveBeenCalled(); + expect(UserLoginMigrationUc.prototype.forceExtendedMigration).toHaveBeenCalledWith( + superheroUser.id, + requestBody.email, + requestBody.externalUserId, + requestBody.externalSchoolId + ); + }); + }); + + describe('when forcing a user in a migrated school to migrate', () => { + describe('when the provided external school id is valid', () => { + const setup = async () => { + const targetSystem: SystemEntity = systemEntityFactory + .withOauthConfig() + .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + + const externalSchoolId = 'externalSchoolId'; + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem, targetSystem], + externalId: externalSchoolId, + }); + + const email = 'student@test.com'; + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ + email, + school, + }); + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + }); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + superheroAccount, + superheroUser, + studentAccount, + studentUser, + userLoginMigration, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = email; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = externalSchoolId; + requestBody.forceExtendedMode = true; + + return { + requestBody, + loggedInClient, + sourceSystem, + targetSystem, + school, + studentUser, + }; + }; + + it('should migrate the user without changing the school migration', async () => { + const { requestBody, loggedInClient, school, sourceSystem, targetSystem, studentUser } = await setup(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.CREATED); + + const userLoginMigration = await em.findOneOrFail(UserLoginMigrationEntity, { + school: school.id, + }); + expect(userLoginMigration.targetSystem.id).toEqual(targetSystem.id); + + const migratedSchool = await em.findOneOrFail(SchoolEntity, school.id); + expectSchoolMigrationUnchanged(migratedSchool, requestBody.externalSchoolId, sourceSystem, targetSystem); + + const migratedUser = await em.findOneOrFail(User, studentUser.id); + expectUserMigrated(migratedUser, studentUser, requestBody.externalUserId); + }); + }); + + describe('when the provided external school id is invalid', () => { + const setup = async () => { + const targetSystem: SystemEntity = systemEntityFactory + .withOauthConfig() + .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + + const externalSchoolId = 'externalSchoolId'; + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem, targetSystem], + externalId: 'otherExternalSchoolId', + }); + + const email = 'student@test.com'; + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ + email, + school, + }); + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + }); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + superheroAccount, + superheroUser, + studentAccount, + studentUser, + userLoginMigration, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = email; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = externalSchoolId; + requestBody.forceExtendedMode = true; + + return { + requestBody, + loggedInClient, + sourceSystem, + targetSystem, + school, + studentUser, + }; + }; + + it('throw an UnprocessableEntityException', async () => { + const { requestBody, loggedInClient } = await setup(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); + }); - const userLoginMigration = await em.findOneOrFail(UserLoginMigrationEntity, { school: school.id }); - expect(userLoginMigration.sourceSystem?.id).toEqual(sourceSystem.id); - expect(userLoginMigration.targetSystem.id).toEqual(targetSystem.id); + describe('when forcing a correction on a migrated user', () => { + const setup = async () => { + const targetSystem: SystemEntity = systemEntityFactory + .withOauthConfig() + .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + + const externalSchoolId = 'externalSchoolId'; + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem, targetSystem], + externalId: externalSchoolId, + }); + + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + }); + + const email = 'teacher@test.com'; + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ + email, + school, + }); + teacherUser.externalId = 'badExternalUserId'; + const loginChange = new Date(userLoginMigration.startedAt); + loginChange.setDate(loginChange.getDate() + 1); + teacherUser.lastLoginSystemChange = loginChange; + + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + superheroAccount, + superheroUser, + teacherAccount, + teacherUser, + userLoginMigration, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = email; + requestBody.externalUserId = 'correctExternalUserId'; + requestBody.externalSchoolId = externalSchoolId; + requestBody.forceExtendedMode = true; + + return { + requestBody, + loggedInClient, + sourceSystem, + targetSystem, + school, + teacherUser, + }; + }; + + it('should correct the user without changing the migration', async () => { + const { requestBody, loggedInClient, school, sourceSystem, targetSystem, teacherUser } = await setup(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.CREATED); + + const userLoginMigration = await em.findOneOrFail(UserLoginMigrationEntity, { school: school.id }); + expect(userLoginMigration.targetSystem.id).toEqual(targetSystem.id); + + const migratedSchool = await em.findOneOrFail(SchoolEntity, school.id); + expectSchoolMigrationUnchanged(migratedSchool, requestBody.externalSchoolId, sourceSystem, targetSystem); - expect(await em.findOne(User, adminUser.id)).toEqual( - expect.objectContaining({ + const expectedUserPartial: DeepPartial = { externalId: requestBody.externalUserId, - }) - ); - - expect(await em.findOne(SchoolEntity, school.id)).toEqual( - expect.objectContaining({ - externalId: requestBody.externalSchoolId, - }) - ); + lastLoginSystemChange: teacherUser.lastLoginSystemChange, + previousExternalId: teacherUser.previousExternalId, + }; + const correctedUser = await em.findOneOrFail(User, teacherUser.id); + expect(correctedUser).toEqual(expect.objectContaining(expectedUserPartial)); + }); }); - }); - describe('when authentication of user failed', () => { - const setup = () => { - const requestBody: ForceMigrationParams = new ForceMigrationParams(); - requestBody.email = 'fail@test.com'; - requestBody.externalUserId = 'externalUserId'; - requestBody.externalSchoolId = 'externalSchoolId'; + describe('when forcing a user migration after the migration is closed or finished', () => { + const setup = async () => { + const targetSystem: SystemEntity = systemEntityFactory + .withOauthConfig() + .buildWithId({ alias: 'SANIS', provisioningStrategy: SystemProvisioningStrategy.SANIS }); + + const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); + + const externalSchoolId = 'externalSchoolId'; + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [sourceSystem, targetSystem], + externalId: externalSchoolId, + }); + + const now = new Date(); + const closedAt = new Date(now); + closedAt.setMonth(now.getMonth() - 1); + const finishedAt = new Date(closedAt); + finishedAt.setDate(finishedAt.getDate() + 7); + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + closedAt, + finishedAt, + }); + + const email = 'teacher@test.com'; + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ + email, + school, + }); + + const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero(); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + superheroAccount, + superheroUser, + teacherAccount, + teacherUser, + userLoginMigration, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = email; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = externalSchoolId; + requestBody.forceExtendedMode = true; + + return { + requestBody, + loggedInClient, + }; + }; - return { - requestBody, + it('should throw an UnprocessableEntityException', async () => { + const { requestBody, loggedInClient } = await setup(); + + const response: Response = await loggedInClient.post(`/force-migration`, requestBody); + + expect(response.status).toEqual(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); + + describe('when authentication of user failed', () => { + const setup = () => { + const requestBody: ForceMigrationParams = new ForceMigrationParams(); + requestBody.email = 'fail@test.com'; + requestBody.externalUserId = 'externalUserId'; + requestBody.externalSchoolId = 'externalSchoolId'; + requestBody.forceExtendedMode = true; + + return { + requestBody, + }; }; - }; - it('should throw an UnauthorizedException', async () => { - const { requestBody } = setup(); + it('should throw an UnauthorizedException', async () => { + const { requestBody } = setup(); - const response: Response = await testApiClient.post(`/force-migration`, requestBody); + const response: Response = await testApiClient.post(`/force-migration`, requestBody); - expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); }); }); }); diff --git a/apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts b/apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts index 416bb27ee47..abc779ef6be 100644 --- a/apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts +++ b/apps/server/src/modules/user-login-migration/controller/dto/request/force-migration.params.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator'; export class ForceMigrationParams { @IsEmail() @@ -15,4 +15,9 @@ export class ForceMigrationParams { @IsNotEmpty() @ApiProperty({ description: 'Target externalId to link it with an external school' }) externalSchoolId!: string; + + @IsBoolean() + @IsNotEmpty() + @ApiProperty({ description: 'Should extended mode be used' }) + forceExtendedMode!: boolean; } diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts index 75a2b0271b8..6a09acf14c4 100644 --- a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts @@ -235,21 +235,34 @@ export class UserLoginMigrationController { @ApiCreatedResponse({ description: 'The user and their school were successfully migrated' }) @ApiUnprocessableEntityResponse({ description: - 'There are multiple users with the email,' + - 'or the user is not an administrator,' + - 'or the school is already migrated,' + - 'or the external user id is already assigned', + 'There are multiple users with the email' + + 'or the external user id is already assigned' + + 'only for the extended mode:' + + 'or the school had closed or finished the migration' + + 'or the external school id does not match with the migrated school' + + 'only for the non-extended mode:' + + 'the user is not an administrator,' + + 'or the school is already migrated', }) @ApiNotFoundResponse({ description: 'There is no user with the email' }) public async forceMigration( @CurrentUser() currentUser: ICurrentUser, @Body() forceMigrationParams: ForceMigrationParams ): Promise { - await this.userLoginMigrationUc.forceMigration( - currentUser.userId, - forceMigrationParams.email, - forceMigrationParams.externalUserId, - forceMigrationParams.externalSchoolId - ); + if (forceMigrationParams.forceExtendedMode) { + await this.userLoginMigrationUc.forceExtendedMigration( + currentUser.userId, + forceMigrationParams.email, + forceMigrationParams.externalUserId, + forceMigrationParams.externalSchoolId + ); + } else { + await this.userLoginMigrationUc.forceMigration( + currentUser.userId, + forceMigrationParams.email, + forceMigrationParams.externalUserId, + forceMigrationParams.externalSchoolId + ); + } } } diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/index.ts b/apps/server/src/modules/user-login-migration/loggable/debug/index.ts index cf5d1274646..616720dcb5d 100644 --- a/apps/server/src/modules/user-login-migration/loggable/debug/index.ts +++ b/apps/server/src/modules/user-login-migration/loggable/debug/index.ts @@ -1,3 +1,4 @@ export * from './school-migration-successful.loggable'; export * from './user-migration-started.loggable'; export * from './user-migration-successful.loggable'; +export * from './user-migration-correction-successful-loggable'; diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.spec.ts new file mode 100644 index 00000000000..1fa3c4c37bf --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.spec.ts @@ -0,0 +1,36 @@ +import { UserMigrationCorrectionSuccessfulLoggable } from '@modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable'; +import { userLoginMigrationDOFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { LogMessage } from '@src/core/logger'; + +describe(UserMigrationCorrectionSuccessfulLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId(); + + const loggable = new UserMigrationCorrectionSuccessfulLoggable(userId, userLoginMigration); + + return { + userId, + userLoginMigration, + loggable, + }; + }; + + it('should return the correct log message', () => { + const { userId, userLoginMigration, loggable } = setup(); + + const message: LogMessage = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'A user has been successfully corrected.', + data: { + userId, + userLoginMigrationId: userLoginMigration.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.ts b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.ts new file mode 100644 index 00000000000..62aea398370 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/debug/user-migration-correction-successful-loggable.ts @@ -0,0 +1,17 @@ +import { EntityId } from '@shared/domain/types'; +import { UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { LogMessage } from '@src/core/logger'; + +export class UserMigrationCorrectionSuccessfulLoggable { + constructor(private readonly userId: EntityId, private readonly userLoginMigration: UserLoginMigrationDO) {} + + getLogMessage(): LogMessage { + return { + message: 'A user has been successfully corrected.', + data: { + userId: this.userId, + userLoginMigrationId: this.userLoginMigration.id, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/loggable/index.ts b/apps/server/src/modules/user-login-migration/loggable/index.ts index d67203098a0..40f12561336 100644 --- a/apps/server/src/modules/user-login-migration/loggable/index.ts +++ b/apps/server/src/modules/user-login-migration/loggable/index.ts @@ -17,4 +17,5 @@ export { UserMigrationRollbackSuccessfulLoggable } from './user-migration-rollba export { UserLoginMigrationSchoolAlreadyMigratedLoggableException } from './user-login-migration-school-already-migrated.loggable-exception'; export { UserLoginMigrationInvalidAdminLoggableException } from './user-login-migration-invalid-admin.loggable-exception'; export { UserLoginMigrationMultipleEmailUsersLoggableException } from './user-login-migration-multiple-email-users.loggable-exception'; +export { UserLoginMigrationInvalidExternalSchoolIdLoggableException } from './user-login-migration-invalid-external-school-id.loggable-exception'; export * from './debug'; diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception.spec.ts new file mode 100644 index 00000000000..71fb883f944 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception.spec.ts @@ -0,0 +1,36 @@ +import { UserLoginMigrationAlreadyClosedLoggableException } from '@modules/user-login-migration/loggable/user-login-migration-already-closed.loggable-exception'; +import { ObjectId } from '@mikro-orm/mongodb'; + +describe(UserLoginMigrationAlreadyClosedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const closedAt = new Date(); + + const userLoginMigrationId = new ObjectId().toHexString(); + + const exception = new UserLoginMigrationAlreadyClosedLoggableException(closedAt, userLoginMigrationId); + + return { + closedAt, + userLoginMigrationId, + exception, + }; + }; + + it('should return the correct log message', () => { + const { closedAt, userLoginMigrationId, exception } = setup(); + + const logMessage = exception.getLogMessage(); + + expect(logMessage).toEqual({ + type: 'USER_LOGIN_MIGRATION_ALREADY_CLOSED', + message: 'Migration of school cannot be started or changed, because it is already closed.', + stack: expect.any(String), + data: { + userLoginMigrationId, + closedAt: closedAt.toISOString(), + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.spec.ts new file mode 100644 index 00000000000..5160e0ef14d --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.spec.ts @@ -0,0 +1,30 @@ +import { UserLoginMigrationInvalidExternalSchoolIdLoggableException } from './user-login-migration-invalid-external-school-id.loggable-exception'; + +describe(UserLoginMigrationInvalidExternalSchoolIdLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const externalSchoolId = 'externalSchoolId'; + const exception = new UserLoginMigrationInvalidExternalSchoolIdLoggableException(externalSchoolId); + + return { + exception, + externalSchoolId, + }; + }; + + it('should return the correct log message', () => { + const { exception, externalSchoolId } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_LOGIN_MIGRATION_INVALID_EXTERNAL_SCHOOL_ID', + message: 'The given external school ID does not match with the migrated school', + stack: exception.stack, + data: { + externalSchoolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.ts new file mode 100644 index 00000000000..2a0e7d38844 --- /dev/null +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-invalid-external-school-id.loggable-exception.ts @@ -0,0 +1,27 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserLoginMigrationInvalidExternalSchoolIdLoggableException extends BusinessError implements Loggable { + constructor(private readonly externalSchoolId: string) { + super( + { + type: 'USER_LOGIN_MIGRATION_INVALID_EXTERNAL_SCHOOL_ID', + title: 'The given external school ID is invalid', + defaultMessage: 'The given external school ID does not match with the migrated school', + }, + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: this.type, + message: this.message, + stack: this.stack, + data: { + externalSchoolId: this.externalSchoolId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts index 3d1bb7d1598..56455c0b90e 100644 --- a/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/school-migration.service.spec.ts @@ -457,4 +457,79 @@ describe(SchoolMigrationService.name, () => { }); }); }); + + describe('hasSchoolMigratedInMigrationPhase', () => { + describe('when school has no systems', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + systems: undefined, + }); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build(); + + return { + school, + userLoginMigration, + }; + }; + + it('should return false', () => { + const { school, userLoginMigration } = setup(); + + const result: boolean = service.hasSchoolMigratedInMigrationPhase(school, userLoginMigration); + + expect(result).toEqual(false); + }); + }); + + describe('when school does not have the target system', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + systems: ['system-1'], + }); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + targetSystemId: 'system-100', + }); + + return { + school, + userLoginMigration, + }; + }; + + it('should return false', () => { + const { school, userLoginMigration } = setup(); + + const result: boolean = service.hasSchoolMigratedInMigrationPhase(school, userLoginMigration); + + expect(result).toEqual(false); + }); + }); + + describe('when the school has the target system', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + systems: ['system-1'], + }); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + targetSystemId: 'system-1', + }); + + return { + school, + userLoginMigration, + }; + }; + + it('should return true', () => { + const { school, userLoginMigration } = setup(); + + const result: boolean = service.hasSchoolMigratedInMigrationPhase(school, userLoginMigration); + + expect(result).toEqual(true); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-login-migration/service/school-migration.service.ts b/apps/server/src/modules/user-login-migration/service/school-migration.service.ts index 8c15c4313aa..a98ad8a2776 100644 --- a/apps/server/src/modules/user-login-migration/service/school-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/school-migration.service.ts @@ -93,6 +93,19 @@ export class SchoolMigrationService { return isExternalIdEquivalent; } + public hasSchoolMigratedInMigrationPhase( + schoolDO: LegacySchoolDo, + userLoginMigrationDO: UserLoginMigrationDO + ): boolean { + if (!schoolDO.systems) { + return false; + } + + const hasSchoolMigratedToTargetSystem = schoolDO.systems.includes(userLoginMigrationDO.targetSystemId); + + return hasSchoolMigratedToTargetSystem; + } + public async markUnmigratedUsersAsOutdated(userLoginMigration: UserLoginMigrationDO): Promise { const startTime: number = performance.now(); diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts index fa2dc42f40f..d1974552fbe 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.spec.ts @@ -759,4 +759,71 @@ describe(UserLoginMigrationService.name, () => { }); }); }); + + describe('hasMigrationClosed', () => { + describe('when migration is closed', () => { + const setup = () => { + const closedAt = new Date(); + closedAt.setMonth(closedAt.getMonth() - 1); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + closedAt, + }); + + return { + userLoginMigration, + }; + }; + + it('should return true', () => { + const { userLoginMigration } = setup(); + + const result: boolean = service.hasMigrationClosed(userLoginMigration); + + expect(result).toEqual(true); + }); + }); + + describe('when migration is not closed', () => { + const setup = () => { + const closedAt = undefined; + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + closedAt, + }); + + return { + userLoginMigration, + }; + }; + + it('should return false', () => { + const { userLoginMigration } = setup(); + + const result: boolean = service.hasMigrationClosed(userLoginMigration); + + expect(result).toEqual(false); + }); + }); + + describe('when "closedAt" exists but has not passed', () => { + const setup = () => { + const closedAt = new Date(); + closedAt.setMonth(closedAt.getMonth() + 1); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + closedAt, + }); + + return { + userLoginMigration, + }; + }; + + it('should return false', () => { + const { userLoginMigration } = setup(); + + const result: boolean = service.hasMigrationClosed(userLoginMigration); + + expect(result).toEqual(false); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts index 2ab7f749934..fceb40d807b 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts @@ -168,4 +168,10 @@ export class UserLoginMigrationService { public async deleteUserLoginMigration(userLoginMigration: UserLoginMigrationDO): Promise { await this.userLoginMigrationRepo.delete(userLoginMigration); } + + public hasMigrationClosed(userLoginMigration: UserLoginMigrationDO): boolean { + const now: Date = new Date(); + const hasClosed: boolean = !!userLoginMigration.closedAt && now > userLoginMigration.closedAt; + return hasClosed; + } } diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts index baabb4f8f82..747db5cc476 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts @@ -3,8 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { AccountService, Account } from '@modules/account'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; -import { UserDO } from '@shared/domain/domainobject'; -import { roleFactory, setupEntities, userDoFactory } from '@shared/testing'; +import { UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { roleFactory, setupEntities, userDoFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { UserMigrationDatabaseOperationFailedLoggableException, @@ -291,4 +291,115 @@ describe(UserMigrationService.name, () => { }); }); }); + + describe('hasUserMigratedInMigrationPhase', () => { + describe('when user has no external id', () => { + const setup = () => { + const user: UserDO = userDoFactory.build({ + lastLoginSystemChange: new Date(), + externalId: undefined, + }); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + closedAt: undefined, + }); + + return { + user, + userLoginMigration, + }; + }; + + it('should return false', () => { + const { user, userLoginMigration } = setup(); + + const result: boolean = service.hasUserMigratedInMigrationPhase(user, userLoginMigration); + + expect(result).toEqual(false); + }); + }); + + describe('when login system of the user was never changed', () => { + const setup = () => { + const user: UserDO = userDoFactory.build({ + lastLoginSystemChange: undefined, + externalId: 'externalId', + }); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + closedAt: undefined, + }); + + return { + user, + userLoginMigration, + }; + }; + + it('should return false', () => { + const { user, userLoginMigration } = setup(); + + const result: boolean = service.hasUserMigratedInMigrationPhase(user, userLoginMigration); + + expect(result).toEqual(false); + }); + }); + + describe('when the migration had been closed', () => { + const setup = () => { + const user: UserDO = userDoFactory.build({ + lastLoginSystemChange: new Date(), + externalId: 'externalId', + }); + + const closedAt = new Date(); + closedAt.setMonth(closedAt.getMonth() + 1); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + closedAt, + }); + + return { + user, + userLoginMigration, + }; + }; + + it('should return false', () => { + const { user, userLoginMigration } = setup(); + + const result: boolean = service.hasUserMigratedInMigrationPhase(user, userLoginMigration); + + expect(result).toEqual(false); + }); + }); + + describe('when the user had been migrated in the current active migration', () => { + const setup = () => { + const user: UserDO = userDoFactory.build({ + lastLoginSystemChange: new Date(), + externalId: 'externalId', + }); + + const startedAt = new Date(); + startedAt.setMonth(startedAt.getMonth() - 1); + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.build({ + startedAt, + closedAt: undefined, + }); + + return { + user, + userLoginMigration, + }; + }; + + it('should return true', () => { + const { user, userLoginMigration } = setup(); + + const result: boolean = service.hasUserMigratedInMigrationPhase(user, userLoginMigration); + + expect(result).toEqual(true); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts index 4cb1e066ae3..e69c9aab424 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; +import { UserLoginMigrationDO } from '@shared/domain/domainobject'; import { UserMigrationDatabaseOperationFailedLoggableException, UserLoginMigrationUserAlreadyMigratedLoggableException, @@ -35,6 +36,19 @@ export class UserMigrationService { } } + async updateExternalUserId(userId: string, newExternalUserId: string): Promise { + const userDO: UserDO = await this.userService.findById(userId); + userDO.externalId = newExternalUserId; + await this.userService.save(userDO); + } + + hasUserMigratedInMigrationPhase(userDO: UserDO, userLoginMigrationDO: UserLoginMigrationDO): boolean { + if (!userDO.externalId || !userDO.lastLoginSystemChange || userLoginMigrationDO.closedAt) { + return false; + } + return userDO.lastLoginSystemChange >= userLoginMigrationDO.startedAt; + } + private async doMigration( userDO: UserDO, externalUserId: string, diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts index 1903d351b87..6ce87f1d431 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts @@ -16,12 +16,13 @@ import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { LegacySchoolDo, Page, RoleReference, UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { LegacySchoolDo, Page, RoleReference, UserDO, UserLoginMigrationDO } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { legacySchoolDoFactory, + schoolEntityFactory, setupEntities, systemEntityFactory, userDoFactory, @@ -32,7 +33,9 @@ import { Logger } from '@src/core/logger'; import { ExternalSchoolNumberMissingLoggableException, InvalidUserLoginMigrationLoggableException, + UserLoginMigrationAlreadyClosedLoggableException, UserLoginMigrationInvalidAdminLoggableException, + UserLoginMigrationInvalidExternalSchoolIdLoggableException, UserLoginMigrationMultipleEmailUsersLoggableException, UserLoginMigrationSchoolAlreadyMigratedLoggableException, } from '../loggable'; @@ -908,4 +911,589 @@ describe(UserLoginMigrationUc.name, () => { }); }); }); + + describe('forceExtendedMigration', () => { + describe('when the school is not migrated with no active user login migration', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + schoolId: school.id, + }); + + const caller: User = userFactory.buildWithId({ + school: schoolEntityFactory.buildWithId({}, school.id), + }); + + const user: UserDO = userDoFactory.buildWithId({ + schoolId: school.id, + }); + + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(caller); + userService.findByEmail.mockResolvedValueOnce([user]); + userMigrationService.hasUserMigratedInMigrationPhase.mockReturnValueOnce(false); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + userLoginMigrationService.startMigration.mockResolvedValueOnce(userLoginMigration); + userLoginMigrationService.hasMigrationClosed.mockReturnValueOnce(false); + + schoolMigrationService.hasSchoolMigratedInMigrationPhase.mockReturnValueOnce(false); + + return { + caller, + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should check permission of the calling user', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(caller, [ + Permission.USER_LOGIN_MIGRATION_FORCE, + ]); + }); + + it('should start migration for the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userLoginMigrationService.startMigration).toHaveBeenCalledWith(user.schoolId); + }); + + it('should migrate the school', async () => { + const { caller, user, externalUserId, externalSchoolId, userLoginMigration, school } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( + school, + externalSchoolId, + userLoginMigration.targetSystemId + ); + }); + + it('should migrate the user', async () => { + const { caller, user, externalUserId, externalSchoolId, userLoginMigration } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.migrateUser).toHaveBeenCalledWith( + user.id, + externalUserId, + userLoginMigration.targetSystemId + ); + }); + }); + + describe('when the school is not migrated but with an active user login migration', () => { + const setupMigratedSchool = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + schoolId: school.id, + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + userLoginMigrationService.startMigration.mockResolvedValueOnce(userLoginMigration); + userLoginMigrationService.hasMigrationClosed.mockReturnValueOnce(false); + + schoolMigrationService.hasSchoolMigratedInMigrationPhase.mockReturnValueOnce(false); + + return { + school, + userLoginMigration, + }; + }; + + describe('when the user found by provided email had not been migrated', () => { + const setup = () => { + const { school, userLoginMigration } = setupMigratedSchool(); + + const caller: User = userFactory.buildWithId({ + school: schoolEntityFactory.buildWithId({}, school.id), + }); + + const user: UserDO = userDoFactory.buildWithId({ + schoolId: school.id, + }); + + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(caller); + userService.findByEmail.mockResolvedValueOnce([user]); + userMigrationService.hasUserMigratedInMigrationPhase.mockReturnValueOnce(false); + + return { + caller, + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should not start migration for the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userLoginMigrationService.startMigration).not.toHaveBeenCalled(); + }); + + it('should migrate the school', async () => { + const { caller, user, externalUserId, externalSchoolId, userLoginMigration, school } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( + school, + externalSchoolId, + userLoginMigration.targetSystemId + ); + }); + + it('should migrate the user', async () => { + const { caller, user, externalUserId, externalSchoolId, userLoginMigration } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.migrateUser).toHaveBeenCalledWith( + user.id, + externalUserId, + userLoginMigration.targetSystemId + ); + }); + }); + + describe('when the user found by provided email had been migrated', () => { + const setup = () => { + const { school, userLoginMigration } = setupMigratedSchool(); + + const caller = userFactory.buildWithId({ + school: schoolEntityFactory.buildWithId({}, school.id), + }); + + const user = userDoFactory.buildWithId({ + schoolId: school.id, + externalId: 'otherExternalId', + }); + + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(caller); + userService.findByEmail.mockResolvedValueOnce([user]); + userMigrationService.hasUserMigratedInMigrationPhase.mockReturnValueOnce(true); + + return { + caller, + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should not start migration for the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userLoginMigrationService.startMigration).not.toHaveBeenCalled(); + }); + + it('should migrate the school', async () => { + const { caller, user, externalUserId, externalSchoolId, userLoginMigration, school } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( + school, + externalSchoolId, + userLoginMigration.targetSystemId + ); + }); + + it('should not migrate the user', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.migrateUser).not.toHaveBeenCalled(); + }); + + it('should correct the migrated user', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.updateExternalUserId).toHaveBeenCalledWith(user.id, externalUserId); + }); + }); + }); + + describe('when the school is migrated', () => { + const setupMigratedSchool = (externalId: string) => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + externalId, + }); + + const userLoginMigration: UserLoginMigrationDO = userLoginMigrationDOFactory.buildWithId({ + schoolId: school.id, + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + userLoginMigrationService.startMigration.mockResolvedValueOnce(userLoginMigration); + userLoginMigrationService.hasMigrationClosed.mockReturnValueOnce(false); + + schoolMigrationService.hasSchoolMigratedInMigrationPhase.mockReturnValueOnce(true); + + return { + school, + userLoginMigration, + }; + }; + + describe('when the user found by provided email had not been migrated', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + const { school, userLoginMigration } = setupMigratedSchool(externalSchoolId); + + const caller = userFactory.buildWithId({ + school: schoolEntityFactory.buildWithId({}, school.id), + }); + + const user = userDoFactory.buildWithId({ + schoolId: school.id, + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(caller); + userService.findByEmail.mockResolvedValueOnce([user]); + userMigrationService.hasUserMigratedInMigrationPhase.mockReturnValueOnce(false); + + return { + caller, + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should check permission of the calling user', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(caller, [ + Permission.USER_LOGIN_MIGRATION_FORCE, + ]); + }); + + it('should not start migration for the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userLoginMigrationService.startMigration).not.toHaveBeenCalled(); + }); + + it('should not migrate the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); + }); + + it('should migrate the user', async () => { + const { caller, user, externalUserId, externalSchoolId, userLoginMigration } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.migrateUser).toHaveBeenCalledWith( + user.id, + externalUserId, + userLoginMigration.targetSystemId + ); + }); + }); + + describe('when the user found by provided email had been migrated', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + const { school, userLoginMigration } = setupMigratedSchool(externalSchoolId); + + const caller = userFactory.buildWithId({ + school: schoolEntityFactory.buildWithId({}, school.id), + }); + + const user = userDoFactory.buildWithId({ + schoolId: school.id, + externalId: 'otherExternalId', + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(caller); + userService.findByEmail.mockResolvedValueOnce([user]); + userMigrationService.hasUserMigratedInMigrationPhase.mockReturnValueOnce(true); + + return { + caller, + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should not start migration for the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userLoginMigrationService.startMigration).not.toHaveBeenCalled(); + }); + + it('should not migrate the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); + }); + + it('should not migrate the user', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.migrateUser).not.toHaveBeenCalled(); + }); + + it('should correct the migrated user', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + await uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + expect(userMigrationService.updateExternalUserId).toHaveBeenCalledWith(user.id, externalUserId); + }); + }); + + describe('when the provided external school id does not match', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + const { school, userLoginMigration } = setupMigratedSchool('otherExternalSchoolId'); + + const caller = userFactory.buildWithId({ + school: schoolEntityFactory.buildWithId({}, school.id), + }); + + const user = userDoFactory.buildWithId({ + schoolId: school.id, + externalId: 'otherExternalId', + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(caller); + userService.findByEmail.mockResolvedValueOnce([user]); + userMigrationService.hasUserMigratedInMigrationPhase.mockReturnValueOnce(true); + + return { + caller, + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should throw an exception and not migrate the school', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + const promise = uc.forceExtendedMigration(caller.id, user.email, externalUserId, externalSchoolId); + + await expect(promise).rejects.toThrow(UserLoginMigrationInvalidExternalSchoolIdLoggableException); + expect(userLoginMigrationService.startMigration).not.toHaveBeenCalled(); + expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when the school has a closed or finished migration', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + + const now = new Date(); + const later = new Date(); + later.setDate(now.getDate() + 1); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: school.id, + closedAt: now, + finishedAt: later, + }); + + const caller: User = userFactory.buildWithId({ + school: schoolEntityFactory.buildWithId({}, school.id), + }); + + const user: UserDO = userDoFactory.buildWithId({ + schoolId: school.id, + }); + + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + schoolService.getSchoolById.mockResolvedValueOnce(school); + + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + userLoginMigrationService.startMigration.mockResolvedValueOnce(userLoginMigration); + userLoginMigrationService.hasMigrationClosed.mockReturnValueOnce(true); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(caller); + userService.findByEmail.mockResolvedValueOnce([user]); + + return { + caller, + user, + externalUserId, + externalSchoolId, + userLoginMigration, + school, + }; + }; + + it('should throw an error for already closed migration', async () => { + const { caller, user, externalUserId, externalSchoolId } = setup(); + + const forceMigrationPromise = uc.forceExtendedMigration( + caller.id, + user.email, + externalUserId, + externalSchoolId + ); + + await expect(forceMigrationPromise).rejects.toThrow(UserLoginMigrationAlreadyClosedLoggableException); + }); + }); + + describe('when there is no user with the email', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([]); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceExtendedMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + NotFoundLoggableException + ); + }); + }); + + describe('when there are multiple users with the email', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: user.id, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.ADMINISTRATOR, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo, userDo]); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceExtendedMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + UserLoginMigrationMultipleEmailUsersLoggableException + ); + }); + }); + + describe('when there is no user id', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userDo = userDoFactory.build({ + id: undefined, + roles: [ + new RoleReference({ + id: new ObjectId().toHexString(), + name: RoleName.ADMINISTRATOR, + }), + ], + }); + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userService.findByEmail.mockResolvedValueOnce([userDo]); + + return { + user, + externalUserId, + externalSchoolId, + }; + }; + + it('should throw an error', async () => { + const { user, externalUserId, externalSchoolId } = setup(); + + await expect(uc.forceExtendedMigration(user.id, user.email, externalUserId, externalSchoolId)).rejects.toThrow( + NotFoundLoggableException + ); + }); + }); + }); }); diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts index 817c729a74d..2930d5c4f31 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.ts @@ -15,11 +15,14 @@ import { ExternalSchoolNumberMissingLoggableException, InvalidUserLoginMigrationLoggableException, SchoolMigrationSuccessfulLoggable, - UserLoginMigrationInvalidAdminLoggableException, + UserLoginMigrationAlreadyClosedLoggableException, UserLoginMigrationMultipleEmailUsersLoggableException, - UserLoginMigrationSchoolAlreadyMigratedLoggableException, UserMigrationStartedLoggable, UserMigrationSuccessfulLoggable, + UserMigrationCorrectionSuccessfulLoggable, + UserLoginMigrationInvalidExternalSchoolIdLoggableException, + UserLoginMigrationSchoolAlreadyMigratedLoggableException, + UserLoginMigrationInvalidAdminLoggableException, } from '../loggable'; import { SchoolMigrationService, UserLoginMigrationService, UserMigrationService } from '../service'; import { UserLoginMigrationQuery } from './dto'; @@ -142,12 +145,7 @@ export class UserLoginMigrationUc { const schoolAdminUsers: UserDO[] = await this.userService.findByEmail(email); - if (schoolAdminUsers.length === 0) { - throw new NotFoundLoggableException('User', { email }); - } - if (schoolAdminUsers.length > 1) { - throw new UserLoginMigrationMultipleEmailUsersLoggableException(email); - } + this.checkUserExists(schoolAdminUsers, email); const schoolAdminUser: UserDO = schoolAdminUsers[0]; // TODO Use new domain object to always have an id @@ -188,4 +186,85 @@ export class UserLoginMigrationUc { this.logger.info(new UserMigrationSuccessfulLoggable(schoolAdminUser.id, userLoginMigration)); } + + async forceExtendedMigration( + userId: EntityId, + email: string, + externalUserId: string, + externalSchoolId: string + ): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkAllPermissions(user, [Permission.USER_LOGIN_MIGRATION_FORCE]); + + const users: UserDO[] = await this.userService.findByEmail(email); + this.checkUserExists(users, email); + + const userToMigrate: UserDO = users[0]; + // TODO Use new domain object to always have an id + if (!userToMigrate.id) { + throw new NotFoundLoggableException('User', { email }); + } + + let activeUserLoginMigration: UserLoginMigrationDO | null = + await this.userLoginMigrationService.findMigrationBySchool(userToMigrate.schoolId); + + if (!activeUserLoginMigration) { + activeUserLoginMigration = await this.userLoginMigrationService.startMigration(userToMigrate.schoolId); + } + + if (this.userLoginMigrationService.hasMigrationClosed(activeUserLoginMigration)) { + throw new UserLoginMigrationAlreadyClosedLoggableException( + activeUserLoginMigration.closedAt, + activeUserLoginMigration.id + ); + } + + const school: LegacySchoolDo = await this.schoolService.getSchoolById(userToMigrate.schoolId); + + const hasSchoolMigratedInCurrentMigration: boolean = this.schoolMigrationService.hasSchoolMigratedInMigrationPhase( + school, + activeUserLoginMigration + ); + + if (!hasSchoolMigratedInCurrentMigration) { + await this.schoolMigrationService.migrateSchool( + school, + externalSchoolId, + activeUserLoginMigration.targetSystemId + ); + + this.logger.info(new SchoolMigrationSuccessfulLoggable(school, activeUserLoginMigration)); + } else if (school.externalId !== externalSchoolId) { + throw new UserLoginMigrationInvalidExternalSchoolIdLoggableException(externalSchoolId); + } + + const hasUserMigrated: boolean = this.userMigrationService.hasUserMigratedInMigrationPhase( + userToMigrate, + activeUserLoginMigration + ); + + if (hasUserMigrated) { + await this.userMigrationService.updateExternalUserId(userToMigrate.id, externalUserId); + + this.logger.info(new UserMigrationCorrectionSuccessfulLoggable(userToMigrate.id, activeUserLoginMigration)); + } else { + await this.userMigrationService.migrateUser( + userToMigrate.id, + externalUserId, + activeUserLoginMigration.targetSystemId + ); + + this.logger.info(new UserMigrationSuccessfulLoggable(userToMigrate.id, activeUserLoginMigration)); + } + } + + private checkUserExists(users: UserDO[], email: string): void { + if (users.length === 0) { + throw new NotFoundLoggableException('User', { email }); + } + + if (users.length > 1) { + throw new UserLoginMigrationMultipleEmailUsersLoggableException(email); + } + } } From 86384e6ad6ec76a81d8731ba1e484a19c2834f1f Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Fri, 6 Sep 2024 21:58:22 +0200 Subject: [PATCH 22/29] BC-7850 - rooms module (#5211) * create a new rooms module * change db schema -> add rooms collections * add GET /rooms API to list all rooms * hidden behind feature flag Note that authZ is not part of this PR and will come in the future --------- Co-authored-by: Omar Ezzat --- apps/server/src/modules/room/api/dto/index.ts | 1 + .../src/modules/room/api/dto/request/index.ts | 1 + .../api/dto/request/room-pagination.params.ts | 9 ++ .../modules/room/api/dto/response/index.ts | 3 + .../api/dto/response/room-list.response.ts | 13 ++ .../room/api/dto/response/room.response.ts | 35 +++++ apps/server/src/modules/room/api/index.ts | 4 + .../src/modules/room/api/mapper/index.ts | 1 + .../modules/room/api/mapper/room.mapper.ts | 25 ++++ .../src/modules/room/api/room.controller.ts | 38 ++++++ .../src/modules/room/api/room.uc.spec.ts | 78 +++++++++++ apps/server/src/modules/room/api/room.uc.ts | 29 +++++ .../modules/room/api/test/room.api.spec.ts | 123 ++++++++++++++++++ .../src/modules/room/domain/do/index.ts | 1 + .../modules/room/domain/do/room.do.spec.ts | 72 ++++++++++ .../src/modules/room/domain/do/room.do.ts | 64 +++++++++ apps/server/src/modules/room/domain/index.ts | 3 + .../modules/room/domain/interface/index.ts | 1 + .../room/domain/interface/room-filter.ts | 7 + .../src/modules/room/domain/service/index.ts | 1 + .../room/domain/service/room.service.ts | 16 +++ .../room/domain/service/room.services.spec.ts | 62 +++++++++ apps/server/src/modules/room/index.ts | 3 + .../src/modules/room/repo/entity/index.ts | 1 + .../room/repo/entity/room.entity.spec.ts | 49 +++++++ .../modules/room/repo/entity/room.entity.ts | 40 ++++++ apps/server/src/modules/room/repo/index.ts | 4 + .../room/repo/room-domain.mapper.spec.ts | 65 +++++++++ .../modules/room/repo/room-domain.mapper.ts | 26 ++++ .../src/modules/room/repo/room.repo.spec.ts | 67 ++++++++++ .../server/src/modules/room/repo/room.repo.ts | 37 ++++++ .../src/modules/room/repo/room.scope.ts | 4 + .../src/modules/room/room-api.module.ts | 13 ++ apps/server/src/modules/room/room.config.ts | 3 + apps/server/src/modules/room/room.module.ts | 11 ++ apps/server/src/modules/room/testing/index.ts | 2 + .../room/testing/room-entity.factory.ts | 11 ++ .../src/modules/room/testing/room.factory.ts | 17 +++ .../src/modules/server/server.config.ts | 2 + .../src/modules/server/server.module.ts | 2 + .../src/shared/domain/entity/all-entities.ts | 2 + backup/setup/roles.json | 38 +++--- 42 files changed, 965 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/modules/room/api/dto/index.ts create mode 100644 apps/server/src/modules/room/api/dto/request/index.ts create mode 100644 apps/server/src/modules/room/api/dto/request/room-pagination.params.ts create mode 100644 apps/server/src/modules/room/api/dto/response/index.ts create mode 100644 apps/server/src/modules/room/api/dto/response/room-list.response.ts create mode 100644 apps/server/src/modules/room/api/dto/response/room.response.ts create mode 100644 apps/server/src/modules/room/api/index.ts create mode 100644 apps/server/src/modules/room/api/mapper/index.ts create mode 100644 apps/server/src/modules/room/api/mapper/room.mapper.ts create mode 100644 apps/server/src/modules/room/api/room.controller.ts create mode 100644 apps/server/src/modules/room/api/room.uc.spec.ts create mode 100644 apps/server/src/modules/room/api/room.uc.ts create mode 100644 apps/server/src/modules/room/api/test/room.api.spec.ts create mode 100644 apps/server/src/modules/room/domain/do/index.ts create mode 100644 apps/server/src/modules/room/domain/do/room.do.spec.ts create mode 100644 apps/server/src/modules/room/domain/do/room.do.ts create mode 100644 apps/server/src/modules/room/domain/index.ts create mode 100644 apps/server/src/modules/room/domain/interface/index.ts create mode 100644 apps/server/src/modules/room/domain/interface/room-filter.ts create mode 100644 apps/server/src/modules/room/domain/service/index.ts create mode 100644 apps/server/src/modules/room/domain/service/room.service.ts create mode 100644 apps/server/src/modules/room/domain/service/room.services.spec.ts create mode 100644 apps/server/src/modules/room/index.ts create mode 100644 apps/server/src/modules/room/repo/entity/index.ts create mode 100644 apps/server/src/modules/room/repo/entity/room.entity.spec.ts create mode 100644 apps/server/src/modules/room/repo/entity/room.entity.ts create mode 100644 apps/server/src/modules/room/repo/index.ts create mode 100644 apps/server/src/modules/room/repo/room-domain.mapper.spec.ts create mode 100644 apps/server/src/modules/room/repo/room-domain.mapper.ts create mode 100644 apps/server/src/modules/room/repo/room.repo.spec.ts create mode 100644 apps/server/src/modules/room/repo/room.repo.ts create mode 100644 apps/server/src/modules/room/repo/room.scope.ts create mode 100644 apps/server/src/modules/room/room-api.module.ts create mode 100644 apps/server/src/modules/room/room.config.ts create mode 100644 apps/server/src/modules/room/room.module.ts create mode 100644 apps/server/src/modules/room/testing/index.ts create mode 100644 apps/server/src/modules/room/testing/room-entity.factory.ts create mode 100644 apps/server/src/modules/room/testing/room.factory.ts diff --git a/apps/server/src/modules/room/api/dto/index.ts b/apps/server/src/modules/room/api/dto/index.ts new file mode 100644 index 00000000000..dbc1ea0f59a --- /dev/null +++ b/apps/server/src/modules/room/api/dto/index.ts @@ -0,0 +1 @@ +export * from './response'; diff --git a/apps/server/src/modules/room/api/dto/request/index.ts b/apps/server/src/modules/room/api/dto/request/index.ts new file mode 100644 index 00000000000..a439df9b95e --- /dev/null +++ b/apps/server/src/modules/room/api/dto/request/index.ts @@ -0,0 +1 @@ +export * from './room-pagination.params'; diff --git a/apps/server/src/modules/room/api/dto/request/room-pagination.params.ts b/apps/server/src/modules/room/api/dto/request/room-pagination.params.ts new file mode 100644 index 00000000000..cfcb914cc63 --- /dev/null +++ b/apps/server/src/modules/room/api/dto/request/room-pagination.params.ts @@ -0,0 +1,9 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { PaginationParams } from '@shared/controller'; +import { IsInt } from 'class-validator'; + +export class RoomPaginationParams extends PaginationParams { + @IsInt() + @ApiPropertyOptional({ description: 'Page limit, defaults to 10.' }) + override limit?: number = 1000; +} diff --git a/apps/server/src/modules/room/api/dto/response/index.ts b/apps/server/src/modules/room/api/dto/response/index.ts new file mode 100644 index 00000000000..31ddda6d7f0 --- /dev/null +++ b/apps/server/src/modules/room/api/dto/response/index.ts @@ -0,0 +1,3 @@ +export * from './room.response'; +export * from './room-list.response'; +export * from '../request/room-pagination.params'; diff --git a/apps/server/src/modules/room/api/dto/response/room-list.response.ts b/apps/server/src/modules/room/api/dto/response/room-list.response.ts new file mode 100644 index 00000000000..d5bde7a5987 --- /dev/null +++ b/apps/server/src/modules/room/api/dto/response/room-list.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; +import { RoomResponse } from './room.response'; + +export class RoomListResponse extends PaginationResponse { + constructor(data: RoomResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [RoomResponse] }) + data: RoomResponse[]; +} diff --git a/apps/server/src/modules/room/api/dto/response/room.response.ts b/apps/server/src/modules/room/api/dto/response/room.response.ts new file mode 100644 index 00000000000..cc577f21cbf --- /dev/null +++ b/apps/server/src/modules/room/api/dto/response/room.response.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RoomResponse { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + color: string; + + @ApiPropertyOptional({ type: Date }) + startDate?: Date; + + @ApiPropertyOptional({ type: Date }) + untilDate?: Date; + + @ApiPropertyOptional({ type: Date }) + createdAt?: Date; + + @ApiPropertyOptional({ type: Date }) + updatedAt?: Date; + + constructor(room: RoomResponse) { + this.id = room.id; + this.name = room.name; + this.color = room.color; + + this.startDate = room.startDate; + this.untilDate = room.untilDate; + this.createdAt = room.createdAt; + this.updatedAt = room.updatedAt; + } +} diff --git a/apps/server/src/modules/room/api/index.ts b/apps/server/src/modules/room/api/index.ts new file mode 100644 index 00000000000..dd7800c0e50 --- /dev/null +++ b/apps/server/src/modules/room/api/index.ts @@ -0,0 +1,4 @@ +export * from './dto'; +export * from './mapper'; +export * from './room.controller'; +export * from './room.uc'; diff --git a/apps/server/src/modules/room/api/mapper/index.ts b/apps/server/src/modules/room/api/mapper/index.ts new file mode 100644 index 00000000000..4f626e8e9c9 --- /dev/null +++ b/apps/server/src/modules/room/api/mapper/index.ts @@ -0,0 +1 @@ +export * from './room.mapper'; diff --git a/apps/server/src/modules/room/api/mapper/room.mapper.ts b/apps/server/src/modules/room/api/mapper/room.mapper.ts new file mode 100644 index 00000000000..5c33c020638 --- /dev/null +++ b/apps/server/src/modules/room/api/mapper/room.mapper.ts @@ -0,0 +1,25 @@ +import { Page } from '@shared/domain/domainobject'; +import { RoomPaginationParams } from '../dto/request/room-pagination.params'; +import { RoomResponse, RoomListResponse } from '../dto'; +import { Room } from '../../domain/do/room.do'; + +export class RoomMapper { + static mapToRoomResponse(room: Room): RoomResponse { + const response = new RoomResponse({ + id: room.id, + name: room.name, + color: room.color, + startDate: room.startDate, + untilDate: room.untilDate, + }); + + return response; + } + + static mapToRoomListResponse(rooms: Page, pagination: RoomPaginationParams): RoomListResponse { + const roomResponseData: RoomResponse[] = rooms.data.map((room): RoomResponse => this.mapToRoomResponse(room)); + const response = new RoomListResponse(roomResponseData, rooms.total, pagination.skip, pagination.limit); + + return response; + } +} diff --git a/apps/server/src/modules/room/api/room.controller.ts b/apps/server/src/modules/room/api/room.controller.ts new file mode 100644 index 00000000000..513cf8d282a --- /dev/null +++ b/apps/server/src/modules/room/api/room.controller.ts @@ -0,0 +1,38 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, ForbiddenException, Get, HttpStatus, Query, UnauthorizedException } from '@nestjs/common'; +import { ApiValidationError } from '@shared/common'; +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { ErrorResponse } from '@src/core/error/dto'; +import { IFindOptions } from '@shared/domain/interface'; +import { RoomUc } from './room.uc'; +import { Room } from '../domain'; +import { RoomListResponse } from './dto/response/room-list.response'; +import { RoomMapper } from './mapper/room.mapper'; +import { RoomPaginationParams } from './dto/request/room-pagination.params'; + +@ApiTags('Room') +@JwtAuthentication() +@Controller('rooms') +export class RoomController { + constructor(private readonly roomUc: RoomUc) {} + + @Get() + @ApiOperation({ summary: 'Get a list of rooms.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Returns a list of rooms.', type: RoomListResponse }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError }) + @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + async getRooms( + @CurrentUser() currentUser: ICurrentUser, + @Query() pagination: RoomPaginationParams + ): Promise { + const findOptions: IFindOptions = { pagination }; + + const rooms = await this.roomUc.getRooms(currentUser.userId, findOptions); + + const response = RoomMapper.mapToRoomListResponse(rooms, pagination); + + 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 new file mode 100644 index 00000000000..8d84d037763 --- /dev/null +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -0,0 +1,78 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { Page } from '@shared/domain/domainobject'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { IFindOptions } from '@shared/domain/interface'; +import { RoomUc } from './room.uc'; +import { RoomService, Room } from '../domain'; +import { roomFactory } from '../testing'; + +describe('RoomUc', () => { + let module: TestingModule; + let uc: RoomUc; + let configService: DeepMocked; + let roomService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RoomUc, + { + provide: ConfigService, + useValue: createMock(), + }, + { + provide: RoomService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(RoomUc); + configService = module.get(ConfigService); + roomService = module.get(RoomService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('getRooms', () => { + const setup = () => { + configService.get.mockReturnValue(true); + const rooms: Room[] = roomFactory.buildList(2); + const paginatedRooms: Page = new Page(rooms, rooms.length); + roomService.getRooms.mockResolvedValue(paginatedRooms); + const findOptions: IFindOptions = {}; + + return { + findOptions, + paginatedRooms, + }; + }; + it('should throw FeatureDisabledLoggableException when feature is disabled', async () => { + configService.get.mockReturnValue(false); + + await expect(uc.getRooms('userId', {})).rejects.toThrow(FeatureDisabledLoggableException); + }); + + it('should call roomService.getRooms with findOptions', async () => { + const { findOptions } = setup(); + + await uc.getRooms('userId', findOptions); + expect(roomService.getRooms).toHaveBeenCalledWith(findOptions); + }); + + it('should return rooms when feature is enabled', async () => { + const { paginatedRooms } = setup(); + const result = await uc.getRooms('userId', {}); + + expect(result).toEqual(paginatedRooms); + }); + }); +}); diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts new file mode 100644 index 00000000000..a84d43af42d --- /dev/null +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -0,0 +1,29 @@ +import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; +import { Page } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +// import { User } from '@shared/domain/entity'; +import { IFindOptions } from '@shared/domain/interface'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { Room, RoomService } from '../domain'; +import { RoomConfig } from '../room.config'; + +@Injectable() +export class RoomUc { + constructor( + private readonly configService: ConfigService, + private readonly roomService: RoomService + ) {} + + public async getRooms(userId: EntityId, findOptions: IFindOptions): Promise> { + if (!this.configService.get('FEATURE_ROOMS_ENABLED')) { + throw new FeatureDisabledLoggableException('FEATURE_ROOMS_ENABLED'); + } + + // TODO check authorization + // const user: User = await this.authorizationService.getUserWithPermissions(userId); + + const rooms = await this.roomService.getRooms(findOptions); + return rooms; + } +} diff --git a/apps/server/src/modules/room/api/test/room.api.spec.ts b/apps/server/src/modules/room/api/test/room.api.spec.ts new file mode 100644 index 00000000000..5e088931e16 --- /dev/null +++ b/apps/server/src/modules/room/api/test/room.api.spec.ts @@ -0,0 +1,123 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { serverConfig, type ServerConfig, ServerTestModule } from '@src/modules/server'; +import { roomEntityFactory } from '../../testing/room-entity.factory'; +import { RoomListResponse } from '../dto'; + +describe('Room Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + let config: ServerConfig; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'rooms'); + + config = serverConfig(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + config.FEATURE_ROOMS_ENABLED = true; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /rooms', () => { + describe('when the user is not authenticated', () => { + it('should return a 401 error', async () => { + const response = await testApiClient.get(); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the feature is disabled', () => { + const setup = async () => { + config.FEATURE_ROOMS_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return a 403 error', async () => { + const { loggedInClient } = await setup(); + const response = await loggedInClient.get(); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when the user has the required permissions', () => { + const setup = async () => { + const rooms = roomEntityFactory.buildListWithId(2); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([...rooms, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + const data = rooms.map((room) => { + return { + id: room.id, + name: room.name, + color: room.color, + startDate: room.startDate?.toISOString(), + untilDate: room.untilDate?.toISOString(), + }; + }); + const expectedResponse = { + data, + limit: 1000, + skip: 0, + total: rooms.length, + }; + + return { loggedInClient, expectedResponse }; + }; + + it('should return a list of rooms', async () => { + const { loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get(); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body as RoomListResponse).toEqual(expectedResponse); + }); + + it('should return a list of rooms with pagination', async () => { + const { loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get().query({ skip: 1, limit: 1 }); + expect(response.status).toBe(HttpStatus.OK); + expect(response.body as RoomListResponse).toEqual({ + data: expectedResponse.data.slice(1), + limit: 1, + skip: 1, + total: 2, + }); + }); + + it('should return an alphabetically sorted list of rooms', async () => { + const { loggedInClient } = await setup(); + const response = await loggedInClient.get(); + const rooms = response.body as RoomListResponse; + expect(rooms.data).toEqual(rooms.data.sort((a, b) => a.name.localeCompare(b.name))); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room/domain/do/index.ts b/apps/server/src/modules/room/domain/do/index.ts new file mode 100644 index 00000000000..7a6c09fb998 --- /dev/null +++ b/apps/server/src/modules/room/domain/do/index.ts @@ -0,0 +1 @@ +export * from './room.do'; 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 new file mode 100644 index 00000000000..452e039b201 --- /dev/null +++ b/apps/server/src/modules/room/domain/do/room.do.spec.ts @@ -0,0 +1,72 @@ +import { EntityId } from '@shared/domain/types'; +import { Room, RoomProps } from './room.do'; +import { roomFactory } from '../../testing'; + +describe('Room', () => { + let room: Room; + const roomId: EntityId = 'roomId'; + const roomProps: RoomProps = { + id: roomId, + name: 'Conference Room', + color: 'blue', + startDate: new Date('2024-01-01'), + untilDate: new Date('2024-12-31'), + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + beforeEach(() => { + room = new Room(roomProps); + }); + + it('should props without domainObject', () => { + const mockDomainObject = roomFactory.build(); + // this tests the hotfix for the mikro-orm issue + // eslint-disable-next-line @typescript-eslint/dot-notation + room['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 } = room.getProps(); + + expect(domainObject).toEqual(undefined); + expect(props).toEqual(roomProps); + }); + + it('should get and set name', () => { + expect(room.name).toBe('Conference Room'); + room.name = 'Meeting Room'; + expect(room.name).toBe('Meeting Room'); + }); + + it('should get and set color', () => { + expect(room.color).toBe('blue'); + room.color = 'red'; + expect(room.color).toBe('red'); + }); + + it('should get and set startDate', () => { + expect(room.startDate).toEqual(new Date('2024-01-01')); + const newStartDate = new Date('2024-02-01'); + room.startDate = newStartDate; + expect(room.startDate).toEqual(newStartDate); + }); + + it('should get and set untilDate', () => { + expect(room.untilDate).toEqual(new Date('2024-12-31')); + const newUntilDate = new Date('2024-11-30'); + room.untilDate = newUntilDate; + expect(room.untilDate).toEqual(newUntilDate); + }); + + it('should get createdAt', () => { + const expectedCreatedAt = new Date('2024-01-01'); + expect(room.createdAt).toEqual(expectedCreatedAt); + }); + + it('should get updatedAt', () => { + const expectedUpdatedAt = new Date('2024-01-01'); + expect(room.updatedAt).toEqual(expectedUpdatedAt); + }); +}); diff --git a/apps/server/src/modules/room/domain/do/room.do.ts b/apps/server/src/modules/room/domain/do/room.do.ts new file mode 100644 index 00000000000..bcab318ac15 --- /dev/null +++ b/apps/server/src/modules/room/domain/do/room.do.ts @@ -0,0 +1,64 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; + +export interface RoomProps extends AuthorizableObject { + id: EntityId; + name: string; + color: string; + startDate?: Date; + untilDate?: Date; + createdAt: Date; + updatedAt: Date; +} + +export class Room extends DomainObject { + public getProps(): RoomProps { + // 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 + // @ts-ignore + const { domainObject, ...copyProps } = this.props; + + return copyProps; + } + + public get name(): string { + return this.props.name; + } + + public set name(value: string) { + this.props.name = value; + } + + public get color(): string { + return this.props.color; + } + + public set color(value: string) { + this.props.color = value; + } + + public get startDate(): Date | undefined { + return this.props.startDate; + } + + public set startDate(value: Date) { + this.props.startDate = value; + } + + public get untilDate(): Date | undefined { + return this.props.untilDate; + } + + public set untilDate(value: Date) { + this.props.untilDate = value; + } + + public get createdAt(): Date { + return this.props.createdAt; + } + + public get updatedAt(): Date { + return this.props.updatedAt; + } +} diff --git a/apps/server/src/modules/room/domain/index.ts b/apps/server/src/modules/room/domain/index.ts new file mode 100644 index 00000000000..439f84a2dfa --- /dev/null +++ b/apps/server/src/modules/room/domain/index.ts @@ -0,0 +1,3 @@ +export * from './do'; +export * from './interface'; +export * from './service'; diff --git a/apps/server/src/modules/room/domain/interface/index.ts b/apps/server/src/modules/room/domain/interface/index.ts new file mode 100644 index 00000000000..a924e3f2b03 --- /dev/null +++ b/apps/server/src/modules/room/domain/interface/index.ts @@ -0,0 +1 @@ +export * from './room-filter'; diff --git a/apps/server/src/modules/room/domain/interface/room-filter.ts b/apps/server/src/modules/room/domain/interface/room-filter.ts new file mode 100644 index 00000000000..742b4a2968b --- /dev/null +++ b/apps/server/src/modules/room/domain/interface/room-filter.ts @@ -0,0 +1,7 @@ +import { EntityId } from '@shared/domain/types'; + +export interface RoomFilter { + userId?: EntityId; + name?: string; + // TODO filter by date +} diff --git a/apps/server/src/modules/room/domain/service/index.ts b/apps/server/src/modules/room/domain/service/index.ts new file mode 100644 index 00000000000..4f45e9a3ec6 --- /dev/null +++ b/apps/server/src/modules/room/domain/service/index.ts @@ -0,0 +1 @@ +export * from './room.service'; diff --git a/apps/server/src/modules/room/domain/service/room.service.ts b/apps/server/src/modules/room/domain/service/room.service.ts new file mode 100644 index 00000000000..afaecde6cc4 --- /dev/null +++ b/apps/server/src/modules/room/domain/service/room.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; +import { Room } from '../do'; +import { RoomRepo } from '../../repo'; + +@Injectable() +export class RoomService { + constructor(private readonly roomRepo: RoomRepo) {} + + public async getRooms(findOptions: IFindOptions): Promise> { + const rooms: Page = await this.roomRepo.findRooms(findOptions); + + return rooms; + } +} diff --git a/apps/server/src/modules/room/domain/service/room.services.spec.ts b/apps/server/src/modules/room/domain/service/room.services.spec.ts new file mode 100644 index 00000000000..71f4b0fa18f --- /dev/null +++ b/apps/server/src/modules/room/domain/service/room.services.spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Page } from '@shared/domain/domainobject'; +import { RoomService } from './room.service'; +import { RoomRepo } from '../../repo'; +import { Room } from '../do'; +import { roomFactory } from '../../testing'; + +describe('RoomService', () => { + let module: TestingModule; + let service: RoomService; + let roomRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RoomService, + { + provide: RoomRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RoomService); + roomRepo = module.get(RoomRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('getRooms', () => { + const setup = () => { + const rooms: Room[] = roomFactory.buildList(2); + const paginatedRooms: Page = new Page(rooms, rooms.length); + roomRepo.findRooms.mockResolvedValue(paginatedRooms); + + return { + paginatedRooms, + }; + }; + it('should call repo to get rooms', async () => { + setup(); + + await service.getRooms({}); + + expect(roomRepo.findRooms).toHaveBeenCalledWith({}); + }); + it('should return rooms', async () => { + const { paginatedRooms } = setup(); + + const result = await service.getRooms({}); + + expect(result).toEqual(paginatedRooms); + }); + }); +}); diff --git a/apps/server/src/modules/room/index.ts b/apps/server/src/modules/room/index.ts new file mode 100644 index 00000000000..81ffcd1df70 --- /dev/null +++ b/apps/server/src/modules/room/index.ts @@ -0,0 +1,3 @@ +export * from './domain'; +export { RoomConfig } from './room.config'; +export * from './room.module'; diff --git a/apps/server/src/modules/room/repo/entity/index.ts b/apps/server/src/modules/room/repo/entity/index.ts new file mode 100644 index 00000000000..08c3f69f3cb --- /dev/null +++ b/apps/server/src/modules/room/repo/entity/index.ts @@ -0,0 +1 @@ +export * from './room.entity'; diff --git a/apps/server/src/modules/room/repo/entity/room.entity.spec.ts b/apps/server/src/modules/room/repo/entity/room.entity.spec.ts new file mode 100644 index 00000000000..fa8f302da74 --- /dev/null +++ b/apps/server/src/modules/room/repo/entity/room.entity.spec.ts @@ -0,0 +1,49 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { RoomEntity, RoomEntityProps } from './room.entity'; + +describe('RoomEntity', () => { + const setup = () => { + const roomProps: RoomEntityProps = { + name: 'Test Room', + color: '#FF0000', + startDate: new Date('2023-01-01'), + untilDate: new Date('2023-12-31'), + }; + + return { roomProps }; + }; + + describe('constructor', () => { + it('should create a RoomEntity instance with provided properties', () => { + const { roomProps } = setup(); + const room = new RoomEntity(roomProps); + + expect(room).toBeInstanceOf(RoomEntity); + expect(room.name).toBe(roomProps.name); + expect(room.color).toBe(roomProps.color); + expect(room.startDate).toEqual(roomProps.startDate); + expect(room.untilDate).toEqual(roomProps.untilDate); + }); + + it('should create a RoomEntity instance with an id if provided', () => { + const { roomProps } = setup(); + const id = new ObjectId().toHexString(); + const roomWithId = new RoomEntity({ ...roomProps, id }); + + expect(roomWithId.id).toBe(id); + }); + + it('should create a RoomEntity instance without optional properties', () => { + const minimalProps: RoomEntityProps = { + name: 'Minimal Room', + color: '#00FF00', + }; + const minimalRoom = new RoomEntity(minimalProps); + + expect(minimalRoom.name).toBe(minimalProps.name); + expect(minimalRoom.color).toBe(minimalProps.color); + expect(minimalRoom.startDate).toBeUndefined(); + expect(minimalRoom.untilDate).toBeUndefined(); + }); + }); +}); diff --git a/apps/server/src/modules/room/repo/entity/room.entity.ts b/apps/server/src/modules/room/repo/entity/room.entity.ts new file mode 100644 index 00000000000..529b9bda14e --- /dev/null +++ b/apps/server/src/modules/room/repo/entity/room.entity.ts @@ -0,0 +1,40 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { Room, RoomProps } from '../../domain/do/room.do'; + +export interface RoomEntityProps { + id?: string; + name: string; + color: string; + startDate?: Date; + untilDate?: Date; +} + +@Entity({ tableName: 'rooms' }) +export class RoomEntity extends BaseEntityWithTimestamps implements RoomProps { + @Property() + name: string; + + @Property() + color: string; + + @Property({ nullable: true }) + startDate?: Date; + + @Property({ nullable: true }) + untilDate?: Date; + + @Property({ persist: false }) + domainObject: Room | undefined; + + constructor(props: RoomEntityProps) { + super(); + if (props.id) { + this.id = props.id; + } + this.name = props.name; + this.color = props.color; + this.startDate = props.startDate; + this.untilDate = props.untilDate; + } +} diff --git a/apps/server/src/modules/room/repo/index.ts b/apps/server/src/modules/room/repo/index.ts new file mode 100644 index 00000000000..f4cea128886 --- /dev/null +++ b/apps/server/src/modules/room/repo/index.ts @@ -0,0 +1,4 @@ +export * from './entity'; +export * from './room.repo'; +export * from './room.scope'; +export * from './room-domain.mapper'; 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 new file mode 100644 index 00000000000..be9cac2c829 --- /dev/null +++ b/apps/server/src/modules/room/repo/room-domain.mapper.spec.ts @@ -0,0 +1,65 @@ +import { Room } from '../domain/do/room.do'; +import { RoomEntity } from './entity'; +import { RoomDomainMapper } from './room-domain.mapper'; + +describe('RoomDomainMapper', () => { + describe('mapEntityToDo', () => { + it('should correctly map RoomEntity to Room domain object', () => { + const roomEntity = { + id: '1', + name: 'Test Room', + color: '#FF0000', + startDate: new Date('2023-01-01'), + untilDate: new Date('2023-12-31'), + } as RoomEntity; + + const result = RoomDomainMapper.mapEntityToDo(roomEntity); + + expect(result).toBeInstanceOf(Room); + expect(result.getProps()).toEqual({ + id: '1', + name: 'Test Room', + color: '#FF0000', + startDate: new Date('2023-01-01'), + untilDate: new Date('2023-12-31'), + }); + }); + + it('should return existing domainObject if present, regardless of entity properties', () => { + const existingRoom = new Room({ + id: '1', + name: 'Existing Room', + color: '#00FF00', + startDate: new Date('2023-01-01'), + untilDate: new Date('2023-12-31'), + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + }); + + const roomEntity = { + id: '2', + name: 'Test Room', + color: '#FF0000', + startDate: new Date('2023-02-01'), + untilDate: new Date('2023-11-30'), + domainObject: existingRoom, + } as RoomEntity; + + const result = RoomDomainMapper.mapEntityToDo(roomEntity); + + expect(result).toBe(existingRoom); + expect(result).toBeInstanceOf(Room); + expect(result.getProps()).toEqual({ + id: '1', + name: 'Existing Room', + color: '#00FF00', + startDate: new Date('2023-01-01'), + untilDate: new Date('2023-12-31'), + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + }); + expect(result.getProps().id).toBe('1'); + expect(result.getProps().id).not.toBe(roomEntity.id); + }); + }); +}); diff --git a/apps/server/src/modules/room/repo/room-domain.mapper.ts b/apps/server/src/modules/room/repo/room-domain.mapper.ts new file mode 100644 index 00000000000..e9e9916ffd0 --- /dev/null +++ b/apps/server/src/modules/room/repo/room-domain.mapper.ts @@ -0,0 +1,26 @@ +import { Room } from '../domain/do/room.do'; +import { RoomEntity } from './entity'; + +export class RoomDomainMapper { + static mapEntityToDo(roomEntity: RoomEntity): Room { + // check identity map reference + if (roomEntity.domainObject) { + return roomEntity.domainObject; + } + + const room: Room = new Room({ + id: roomEntity.id, + name: roomEntity.name, + color: roomEntity.color, + startDate: roomEntity.startDate, + untilDate: roomEntity.untilDate, + createdAt: roomEntity.createdAt, + updatedAt: roomEntity.updatedAt, + }); + + // attach to identity map + roomEntity.domainObject = room; + + return room; + } +} diff --git a/apps/server/src/modules/room/repo/room.repo.spec.ts b/apps/server/src/modules/room/repo/room.repo.spec.ts new file mode 100644 index 00000000000..43c4de212f9 --- /dev/null +++ b/apps/server/src/modules/room/repo/room.repo.spec.ts @@ -0,0 +1,67 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Page } from '@shared/domain/domainobject'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { Room } from '../domain/do/room.do'; +import { RoomDomainMapper } from './room-domain.mapper'; +import { RoomRepo } from './room.repo'; +import { roomEntityFactory } from '../testing'; +import { RoomEntity } from './entity/room.entity'; + +describe('RoomRepo', () => { + let module: TestingModule; + let repo: RoomRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [RoomRepo], + }).compile(); + + repo = module.get(RoomRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('findRooms', () => { + const setup = async () => { + const roomEntities = roomEntityFactory.buildWithId(); + await em.persistAndFlush([roomEntities]); + em.clear(); + + const room = RoomDomainMapper.mapEntityToDo(roomEntities); + const page = new Page([room], 1); + + return { roomEntities, room, page }; + }; + + it('should return rooms domain object', async () => { + const { room } = await setup(); + const result = await repo.findRooms({}); + + expect(result.data[0]).toEqual(room); + }); + + it('should return paginated Roms', async () => { + const { page } = await setup(); + const result = await repo.findRooms({ pagination: { skip: 0, limit: 10 } }); + + expect(result).toEqual(page); + }); + }); + + describe('entityName', () => { + it('should return RoomEntity', () => { + expect(repo.entityName).toBe(RoomEntity); + }); + }); +}); diff --git a/apps/server/src/modules/room/repo/room.repo.ts b/apps/server/src/modules/room/repo/room.repo.ts new file mode 100644 index 00000000000..23e1d7067bd --- /dev/null +++ b/apps/server/src/modules/room/repo/room.repo.ts @@ -0,0 +1,37 @@ +import { EntityName, QueryOrder } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; +import { Room } from '../domain/do/room.do'; +import { RoomEntity } from './entity/room.entity'; +import { RoomDomainMapper } from './room-domain.mapper'; +import { RoomScope } from './room.scope'; + +@Injectable() +export class RoomRepo { + constructor(private readonly em: EntityManager) {} + + get entityName(): EntityName { + return RoomEntity; + } + + public async findRooms(findOptions: IFindOptions): Promise> { + const scope = new RoomScope(); + scope.allowEmptyQuery(true); + + const options = { + offset: findOptions?.pagination?.skip, + limit: findOptions?.pagination?.limit, + orderBy: { name: QueryOrder.ASC }, + }; + + const [entities, total] = await this.em.findAndCount(RoomEntity, scope.query, options); + + const domainObjects: Room[] = entities.map((entity) => RoomDomainMapper.mapEntityToDo(entity)); + + const page = new Page(domainObjects, total); + + return page; + } +} diff --git a/apps/server/src/modules/room/repo/room.scope.ts b/apps/server/src/modules/room/repo/room.scope.ts new file mode 100644 index 00000000000..86f317f8d6f --- /dev/null +++ b/apps/server/src/modules/room/repo/room.scope.ts @@ -0,0 +1,4 @@ +import { Scope } from '@shared/repo/scope'; +import { RoomEntity } from './entity'; + +export class RoomScope extends Scope {} diff --git a/apps/server/src/modules/room/room-api.module.ts b/apps/server/src/modules/room/room-api.module.ts new file mode 100644 index 00000000000..4fc716728b7 --- /dev/null +++ b/apps/server/src/modules/room/room-api.module.ts @@ -0,0 +1,13 @@ +import { AuthorizationModule } from '@modules/authorization'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { RoomController } from './api/room.controller'; +import { RoomModule } from './room.module'; +import { RoomUc } from './api/room.uc'; + +@Module({ + imports: [RoomModule, AuthorizationModule, LoggerModule], + controllers: [RoomController], + providers: [RoomUc], +}) +export class RoomApiModule {} diff --git a/apps/server/src/modules/room/room.config.ts b/apps/server/src/modules/room/room.config.ts new file mode 100644 index 00000000000..cdd7c0a3f08 --- /dev/null +++ b/apps/server/src/modules/room/room.config.ts @@ -0,0 +1,3 @@ +export interface RoomConfig { + FEATURE_ROOMS_ENABLED: boolean; +} diff --git a/apps/server/src/modules/room/room.module.ts b/apps/server/src/modules/room/room.module.ts new file mode 100644 index 00000000000..8c31af94b89 --- /dev/null +++ b/apps/server/src/modules/room/room.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { RoomRepo } from './repo'; +import { RoomService } from './domain/service'; + +@Module({ + imports: [CqrsModule], + providers: [RoomRepo, RoomService], + exports: [RoomService], +}) +export class RoomModule {} diff --git a/apps/server/src/modules/room/testing/index.ts b/apps/server/src/modules/room/testing/index.ts new file mode 100644 index 00000000000..784a3aaf322 --- /dev/null +++ b/apps/server/src/modules/room/testing/index.ts @@ -0,0 +1,2 @@ +export * from './room.factory'; +export * from './room-entity.factory'; diff --git a/apps/server/src/modules/room/testing/room-entity.factory.ts b/apps/server/src/modules/room/testing/room-entity.factory.ts new file mode 100644 index 00000000000..289912ccb7f --- /dev/null +++ b/apps/server/src/modules/room/testing/room-entity.factory.ts @@ -0,0 +1,11 @@ +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { RoomEntity, RoomEntityProps } from '../repo/entity/room.entity'; + +export const roomEntityFactory = BaseFactory.define(RoomEntity, ({ sequence }) => { + return { + name: `room #${sequence}`, + color: ['blue', 'red', 'green', 'yellow'][Math.floor(Math.random() * 4)], + startDate: new Date(), + untilDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }; +}); diff --git a/apps/server/src/modules/room/testing/room.factory.ts b/apps/server/src/modules/room/testing/room.factory.ts new file mode 100644 index 00000000000..24b0eb4abe8 --- /dev/null +++ b/apps/server/src/modules/room/testing/room.factory.ts @@ -0,0 +1,17 @@ +import { BaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Room, RoomProps } from '../domain/do/room.do'; + +export const roomFactory = BaseFactory.define(Room, ({ sequence }) => { + const props: RoomProps = { + id: new ObjectId().toHexString(), + name: `room #${sequence}`, + color: ['blue', 'red', 'green', 'yellow'][Math.floor(Math.random() * 4)], + startDate: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + untilDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }; + + return props; +}); diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 8d392ce1cb2..d5f9e70b872 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -15,6 +15,7 @@ import { SynchronizationConfig } from '@modules/idp-console'; import type { LearnroomConfig } from '@modules/learnroom'; import type { LessonConfig } from '@modules/lesson'; import { ProvisioningConfig } from '@modules/provisioning'; +import { RoomConfig } from '@modules/room'; import type { SchoolConfig } from '@modules/school'; import type { SharingConfig } from '@modules/sharing'; import { getTldrawClientConfig, type TldrawClientConfig } from '@modules/tldraw-client'; @@ -62,6 +63,7 @@ export interface ServerConfig DeletionConfig, CollaborativeTextEditorConfig, ProvisioningConfig, + RoomConfig, UserImportConfig, VideoConferenceConfig, BbbConfig, diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index f82d6366fad..f95bae5b048 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -26,6 +26,7 @@ import { OauthProviderApiModule } from '@modules/oauth-provider/oauth-provider-a import { OauthApiModule } from '@modules/oauth/oauth-api.module'; import { PseudonymApiModule } from '@modules/pseudonym/pseudonym-api.module'; import { RocketChatModule } from '@modules/rocketchat'; +import { RoomApiModule } from '@modules/room/room-api.module'; import { SchoolApiModule } from '@modules/school/school-api.module'; import { SharingApiModule } from '@modules/sharing/sharing.module'; import { SystemApiModule } from '@modules/system/system-api.module'; @@ -96,6 +97,7 @@ const serverModules = [ CollaborativeTextEditorApiModule, AlertModule, UserLicenseModule, + RoomApiModule, ]; export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 71734a29837..20e6bf3f638 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -9,6 +9,7 @@ import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym/entity'; import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; +import { RoomEntity } from '@modules/room/repo/entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { SystemEntity } from '@modules/system/entity/system.entity'; import { TldrawDrawing } from '@modules/tldraw/entities'; @@ -73,6 +74,7 @@ export const ALL_ENTITIES = [ ExternalToolPseudonymEntity, RocketChatUserEntity, Role, + RoomEntity, SchoolEntity, SchoolExternalToolEntity, SchoolNews, diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 8de703b913b..94454494dba 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -23,25 +23,28 @@ "COMMENTS_VIEW", "CONTENT_NON_OER_VIEW", "CONTENT_VIEW", - "COURSE_VIEW", + "CONTEXT_TOOL_USER", "COURSEGROUP_CREATE", "COURSEGROUP_EDIT", + "COURSE_VIEW", "DASHBOARD_VIEW", "FEDERALSTATE_VIEW", - "FILE_CREATE", - "FILE_DELETE", - "FILE_MOVE", "FILESTORAGE_CREATE", "FILESTORAGE_EDIT", "FILESTORAGE_REMOVE", "FILESTORAGE_VIEW", + "FILE_CREATE", + "FILE_DELETE", + "FILE_MOVE", "FOLDER_CREATE", "FOLDER_DELETE", + "GROUP_VIEW", "HELPDESK_CREATE", "HOMEWORK_VIEW", "LERNSTORE_VIEW", "LINK_CREATE", "NEWS_VIEW", + "NEXTCLOUD_USER", "NOTIFICATION_CREATE", "NOTIFICATION_EDIT", "NOTIFICATION_VIEW", @@ -56,10 +59,7 @@ "SUBMISSIONS_VIEW", "TEAM_VIEW", "TOOL_VIEW", - "TOPIC_VIEW", - "NEXTCLOUD_USER", - "CONTEXT_TOOL_USER", - "GROUP_VIEW" + "TOPIC_VIEW" ], "__v": 0 }, @@ -229,20 +229,29 @@ "CLASS_EDIT", "CLASS_LIST", "CLASS_REMOVE", + "CONTEXT_TOOL_ADMIN", "COURSE_CREATE", "COURSE_EDIT", "COURSE_REMOVE", "ENTERTHECLOUD_START", + "GROUP_LIST", + "HOMEWORK_CREATE", + "HOMEWORK_EDIT", + "JOIN_MEETING", "LESSONS_VIEW", "NEWS_CREATE", "NEWS_EDIT", "REQUEST_CONSENTS", "SCHOOL_NEWS_EDIT", + "START_MEETING", "STUDENT_EDIT", "STUDENT_LIST", "STUDENT_SKIP_REGISTRATION", "SUBMISSIONS_SCHOOL_VIEW", + "TASK_DASHBOARD_TEACHER_VIEW_V3", "TEACHER_LIST", + "TEAM_CREATE", + "TEAM_EDIT", "TEAM_INVITE_EXTERNAL", "TEAM_INVITE_EXTERNAL", "TOOL_CREATE", @@ -252,17 +261,8 @@ "TOPIC_EDIT", "USERGROUP_CREATE", "USERGROUP_EDIT", - "USER_CREATE", - "TASK_DASHBOARD_TEACHER_VIEW_V3", - "TEAM_CREATE", - "TEAM_EDIT", - "START_MEETING", - "HOMEWORK_CREATE", - "HOMEWORK_EDIT", - "CONTEXT_TOOL_ADMIN", - "JOIN_MEETING", - "GROUP_LIST", - "USER_CHANGE_OWN_NAME" + "USER_CHANGE_OWN_NAME", + "USER_CREATE" ], "__v": 2 }, From ceb0407f63b4db7785ac66b6d7cfdb699d90ef87 Mon Sep 17 00:00:00 2001 From: Fshmit <122355627+Fshmit@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:14:10 +0200 Subject: [PATCH 23/29] EW-998 created a new sync Module (#5222) * EW-998 created base module for tsp --- apps/server/src/console/console.module.ts | 2 + .../infra/sync/console/sync.console.spec.ts | 49 +++++++++++ .../src/infra/sync/console/sync.console.ts | 18 ++++ .../errors/invalid-target.loggable.spec.ts | 28 ++++++ .../sync/errors/invalid-target.loggable.ts | 16 ++++ .../infra/sync/service/sync.service.spec.ts | 86 +++++++++++++++++++ .../src/infra/sync/service/sync.service.ts | 30 +++++++ .../src/infra/sync/strategy/sync-strategy.ts | 7 ++ .../src/infra/sync/sync-strategy.types.ts | 3 + apps/server/src/infra/sync/sync.module.ts | 20 +++++ .../infra/sync/tsp/tsp-sync.strategy.spec.ts | 42 +++++++++ .../src/infra/sync/tsp/tsp-sync.strategy.ts | 15 ++++ apps/server/src/infra/sync/uc/sync.uc.spec.ts | 47 ++++++++++ apps/server/src/infra/sync/uc/sync.uc.ts | 11 +++ .../src/modules/server/server.config.ts | 2 + config/default.schema.json | 5 ++ package.json | 1 + 17 files changed, 382 insertions(+) create mode 100644 apps/server/src/infra/sync/console/sync.console.spec.ts create mode 100644 apps/server/src/infra/sync/console/sync.console.ts create mode 100644 apps/server/src/infra/sync/errors/invalid-target.loggable.spec.ts create mode 100644 apps/server/src/infra/sync/errors/invalid-target.loggable.ts create mode 100644 apps/server/src/infra/sync/service/sync.service.spec.ts create mode 100644 apps/server/src/infra/sync/service/sync.service.ts create mode 100644 apps/server/src/infra/sync/strategy/sync-strategy.ts create mode 100644 apps/server/src/infra/sync/sync-strategy.types.ts create mode 100644 apps/server/src/infra/sync/sync.module.ts create mode 100644 apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts create mode 100644 apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts create mode 100644 apps/server/src/infra/sync/uc/sync.uc.spec.ts create mode 100644 apps/server/src/infra/sync/uc/sync.uc.ts diff --git a/apps/server/src/console/console.module.ts b/apps/server/src/console/console.module.ts index fab847827d7..b20a94c8aec 100644 --- a/apps/server/src/console/console.module.ts +++ b/apps/server/src/console/console.module.ts @@ -9,6 +9,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; import { ConsoleModule } from 'nestjs-console'; +import { SyncModule } from '@infra/sync/sync.module'; import { ServerConsole } from './server.console'; import { mikroOrmCliConfig } from '../config/mikro-orm-cli.config'; @@ -21,6 +22,7 @@ import { mikroOrmCliConfig } from '../config/mikro-orm-cli.config'; ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), ...((Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean) ? [KeycloakModule] : []), MikroOrmModule.forRoot(mikroOrmCliConfig), + SyncModule, ], providers: [ /** add console services as providers */ diff --git a/apps/server/src/infra/sync/console/sync.console.spec.ts b/apps/server/src/infra/sync/console/sync.console.spec.ts new file mode 100644 index 00000000000..2688308f826 --- /dev/null +++ b/apps/server/src/infra/sync/console/sync.console.spec.ts @@ -0,0 +1,49 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test } from '@nestjs/testing'; +import { Logger } from '@src/core/logger'; +import { SyncUc } from '../uc/sync.uc'; +import { SyncConsole } from './sync.console'; + +describe(SyncConsole.name, () => { + let syncConsole: SyncConsole; + let syncUc: SyncUc; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + providers: [ + SyncConsole, + { + provide: SyncUc, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + syncConsole = module.get(SyncConsole); + syncUc = module.get(SyncUc); + }); + + describe('when sync console is initialized', () => { + it('should be defined', () => { + expect(syncConsole).toBeDefined(); + }); + }); + + describe('startSync', () => { + const setup = () => { + const target = 'tsp'; + return { target }; + }; + + it('should call startSync method of syncUc', async () => { + const { target } = setup(); + await syncConsole.startSync(target); + + expect(syncUc.startSync).toHaveBeenCalledWith(target); + }); + }); +}); diff --git a/apps/server/src/infra/sync/console/sync.console.ts b/apps/server/src/infra/sync/console/sync.console.ts new file mode 100644 index 00000000000..fd40c5a9a5a --- /dev/null +++ b/apps/server/src/infra/sync/console/sync.console.ts @@ -0,0 +1,18 @@ +import { Logger } from '@src/core/logger'; +import { Command, Console } from 'nestjs-console'; +import { SyncUc } from '../uc/sync.uc'; + +@Console({ command: 'sync', description: 'Prefixes all synchronization related console commands.' }) +export class SyncConsole { + constructor(private readonly syncUc: SyncUc, private readonly logger: Logger) { + this.logger.setContext(SyncConsole.name); + } + + @Command({ + command: 'run ', + description: 'Starts the synchronization process.', + }) + public async startSync(target: string): Promise { + await this.syncUc.startSync(target); + } +} diff --git a/apps/server/src/infra/sync/errors/invalid-target.loggable.spec.ts b/apps/server/src/infra/sync/errors/invalid-target.loggable.spec.ts new file mode 100644 index 00000000000..d26c68e5b83 --- /dev/null +++ b/apps/server/src/infra/sync/errors/invalid-target.loggable.spec.ts @@ -0,0 +1,28 @@ +import { SyncStrategyTarget } from '../sync-strategy.types'; +import { InvalidTargetLoggable } from './invalid-target.loggable'; + +describe(InvalidTargetLoggable.name, () => { + let loggable: InvalidTargetLoggable; + + beforeAll(() => { + loggable = new InvalidTargetLoggable('invalid-target'); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message with the entered target and available targets', () => { + expect(loggable.getLogMessage()).toEqual({ + message: 'Either synchronization is not activated or the target entered is invalid', + data: { + enteredTarget: 'invalid-target', + avaliableTargets: SyncStrategyTarget, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/errors/invalid-target.loggable.ts b/apps/server/src/infra/sync/errors/invalid-target.loggable.ts new file mode 100644 index 00000000000..53cb7d1a104 --- /dev/null +++ b/apps/server/src/infra/sync/errors/invalid-target.loggable.ts @@ -0,0 +1,16 @@ +import { ErrorLogMessage, LogMessage, Loggable, ValidationErrorLogMessage } from '@src/core/logger'; +import { SyncStrategyTarget } from '../sync-strategy.types'; + +export class InvalidTargetLoggable implements Loggable { + constructor(private readonly target: string) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Either synchronization is not activated or the target entered is invalid', + data: { + enteredTarget: this.target, + avaliableTargets: SyncStrategyTarget, + }, + }; + } +} diff --git a/apps/server/src/infra/sync/service/sync.service.spec.ts b/apps/server/src/infra/sync/service/sync.service.spec.ts new file mode 100644 index 00000000000..3c069beb4a8 --- /dev/null +++ b/apps/server/src/infra/sync/service/sync.service.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { faker } from '@faker-js/faker'; +import { Logger } from '@src/core/logger'; +import { SyncService } from './sync.service'; +import { TspSyncStrategy } from '../tsp/tsp-sync.strategy'; +import { SyncStrategyTarget } from '../sync-strategy.types'; +import { InvalidTargetLoggable } from '../errors/invalid-target.loggable'; + +describe(SyncService.name, () => { + let module: TestingModule; + let service: SyncService; + let tspSyncStrategy: TspSyncStrategy; + let logger: Logger; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SyncService, + { + provide: TspSyncStrategy, + useValue: createMock({ + getType(): SyncStrategyTarget { + return SyncStrategyTarget.TSP; + }, + sync(): Promise { + return Promise.resolve(); + }, + }), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SyncService); + tspSyncStrategy = module.get(TspSyncStrategy); + logger = module.get(Logger); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('when sync service is initialized', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + describe('startSync', () => { + describe('when provided target is invalid or the sync is deactivated', () => { + const setup = () => { + const invalidTarget = faker.lorem.word(); + const output = new InvalidTargetLoggable(invalidTarget); + + return { output, invalidTarget }; + }; + + it('should throw an invalid provided target error', async () => { + const { output, invalidTarget } = setup(); + await service.startSync(invalidTarget); + + expect(logger.info).toHaveBeenCalledWith(output); + }); + }); + + describe('when provided target is valid and synchronization is activated', () => { + const setup = () => { + const validSystem = 'tsp'; + Reflect.set(service, 'strategies', new Map([[SyncStrategyTarget.TSP, tspSyncStrategy]])); + + return { validSystem }; + }; + + it('should call sync method of the provided target', async () => { + const { validSystem } = setup(); + await service.startSync(validSystem); + + expect(tspSyncStrategy.sync).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/service/sync.service.ts b/apps/server/src/infra/sync/service/sync.service.ts new file mode 100644 index 00000000000..528de238c50 --- /dev/null +++ b/apps/server/src/infra/sync/service/sync.service.ts @@ -0,0 +1,30 @@ +import { Injectable, Optional } from '@nestjs/common'; +import { Logger } from '@src/core/logger'; +import { TspSyncStrategy } from '../tsp/tsp-sync.strategy'; +import { SyncStrategy } from '../strategy/sync-strategy'; +import { SyncStrategyTarget } from '../sync-strategy.types'; +import { InvalidTargetLoggable } from '../errors/invalid-target.loggable'; + +@Injectable() +export class SyncService { + private strategies: Map = new Map(); + + constructor(private readonly logger: Logger, @Optional() private readonly tspSyncStrategy?: TspSyncStrategy) { + this.logger.setContext(SyncService.name); + this.registerStrategy(tspSyncStrategy); + } + + protected registerStrategy(strategy?: SyncStrategy) { + if (strategy) { + this.strategies.set(strategy.getType(), strategy); + } + } + + public async startSync(target: string): Promise { + const targetStrategy = target as SyncStrategyTarget; + if (!this.strategies.has(targetStrategy)) { + this.logger.info(new InvalidTargetLoggable(target)); + } + await this.strategies.get(targetStrategy)?.sync(); + } +} diff --git a/apps/server/src/infra/sync/strategy/sync-strategy.ts b/apps/server/src/infra/sync/strategy/sync-strategy.ts new file mode 100644 index 00000000000..c50673ec486 --- /dev/null +++ b/apps/server/src/infra/sync/strategy/sync-strategy.ts @@ -0,0 +1,7 @@ +import { SyncStrategyTarget } from '../sync-strategy.types'; + +export abstract class SyncStrategy { + abstract getType(): SyncStrategyTarget; + + abstract sync(): Promise; +} diff --git a/apps/server/src/infra/sync/sync-strategy.types.ts b/apps/server/src/infra/sync/sync-strategy.types.ts new file mode 100644 index 00000000000..c178c4f3d7c --- /dev/null +++ b/apps/server/src/infra/sync/sync-strategy.types.ts @@ -0,0 +1,3 @@ +export enum SyncStrategyTarget { + TSP = 'tsp', +} diff --git a/apps/server/src/infra/sync/sync.module.ts b/apps/server/src/infra/sync/sync.module.ts new file mode 100644 index 00000000000..2516e4b13df --- /dev/null +++ b/apps/server/src/infra/sync/sync.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { ConsoleWriterModule } from '@infra/console'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { SyncConsole } from './console/sync.console'; +import { SyncUc } from './uc/sync.uc'; +import { SyncService } from './service/sync.service'; +import { TspSyncStrategy } from './tsp/tsp-sync.strategy'; + +@Module({ + imports: [LoggerModule, ConsoleWriterModule], + providers: [ + SyncConsole, + SyncUc, + SyncService, + ...((Configuration.get('FEATURE_TSP_SYNC_ENABLED') as boolean) ? [TspSyncStrategy] : []), + ], + exports: [SyncConsole], +}) +export class SyncModule {} diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts new file mode 100644 index 00000000000..fce598102eb --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts @@ -0,0 +1,42 @@ +import { TestingModule, Test } from '@nestjs/testing'; +import { SyncStrategyTarget } from '../sync-strategy.types'; +import { TspSyncStrategy } from './tsp-sync.strategy'; + +describe(TspSyncStrategy.name, () => { + let module: TestingModule; + let strategy: TspSyncStrategy; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [TspSyncStrategy], + }).compile(); + + strategy = module.get(TspSyncStrategy); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('when tsp sync strategy is initialized', () => { + it('should be defined', () => { + expect(strategy).toBeDefined(); + }); + }); + + describe('getType', () => { + describe('when tsp sync strategy is initialized', () => { + it('should return tsp', () => { + expect(strategy.getType()).toBe(SyncStrategyTarget.TSP); + }); + }); + }); + + describe('sync', () => { + it('should return a promise', () => { + const result = strategy.sync(); + + expect(result).toBeInstanceOf(Promise); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts new file mode 100644 index 00000000000..5f7eb3a44c5 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { SyncStrategy } from '../strategy/sync-strategy'; +import { SyncStrategyTarget } from '../sync-strategy.types'; + +@Injectable() +export class TspSyncStrategy extends SyncStrategy { + getType(): SyncStrategyTarget { + return SyncStrategyTarget.TSP; + } + + sync(): Promise { + // implementation + return Promise.resolve(); + } +} diff --git a/apps/server/src/infra/sync/uc/sync.uc.spec.ts b/apps/server/src/infra/sync/uc/sync.uc.spec.ts new file mode 100644 index 00000000000..741b0438876 --- /dev/null +++ b/apps/server/src/infra/sync/uc/sync.uc.spec.ts @@ -0,0 +1,47 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { SyncUc } from './sync.uc'; +import { SyncService } from '../service/sync.service'; + +describe(SyncUc.name, () => { + let module: TestingModule; + let uc: SyncUc; + let service: SyncService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SyncUc, + { + provide: SyncService, + useValue: createMock({}), + }, + ], + }).compile(); + uc = module.get(SyncUc); + service = module.get(SyncService); + }); + + describe('when sync uc is initialized', () => { + it('should be defined', () => { + expect(uc).toBeDefined(); + }); + }); + + describe('startSync', () => { + describe('when calling startSync', () => { + const setup = () => { + const validTarget = 'tsp'; + + return { validTarget }; + }; + + it('should call sync method', async () => { + const { validTarget } = setup(); + await uc.startSync(validTarget); + + expect(service.startSync).toHaveBeenCalledWith(validTarget); + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/uc/sync.uc.ts b/apps/server/src/infra/sync/uc/sync.uc.ts new file mode 100644 index 00000000000..12f379ae2d8 --- /dev/null +++ b/apps/server/src/infra/sync/uc/sync.uc.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { SyncService } from '../service/sync.service'; + +@Injectable() +export class SyncUc { + constructor(private readonly syncService: SyncService) {} + + public async startSync(target: string): Promise { + await this.syncService.startSync(target); + } +} diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index d5f9e70b872..3e458602b53 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -127,6 +127,7 @@ export interface ServerConfig SCHULCONNEX_CLIENT__CLIENT_SECRET: string | undefined; FEATURE_AI_TUTOR_ENABLED: boolean; FEATURE_ROOMS_ENABLED: boolean; + FEATURE_TSP_SYNC_ENABLED: boolean; } const config: ServerConfig = { @@ -199,6 +200,7 @@ const config: ServerConfig = { FEATURE_IDENTITY_MANAGEMENT_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') as boolean, + FEATURE_TSP_SYNC_ENABLED: Configuration.get('FEATURE_TSP_SYNC_ENABLED') as boolean, STUDENT_TEAM_CREATION: Configuration.get('STUDENT_TEAM_CREATION') as string, SYNCHRONIZATION_CHUNK: Configuration.get('SYNCHRONIZATION_CHUNK') as number, // parse [:],[:]... and discard description diff --git a/config/default.schema.json b/config/default.schema.json index 5ba007e6743..e47636a247d 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -170,6 +170,11 @@ "default": false, "description": "Feature toggle for TSP features." }, + "FEATURE_TSP_SYNC_ENABLED": { + "type": "boolean", + "default": false, + "description": "Feature toggle for TSP sync." + }, "BLOCK_DISPOSABLE_EMAIL_DOMAINS": { "type": "boolean", "default": true, diff --git a/package.json b/package.json index 626af7e0be0..c71342f0d6d 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "nest:start:common-cartridge": "node dist/apps/server/apps/common-cartridge.app", "nest:start:common-cartridge:dev": "nest start common-cartridge --watch --", "nest:start:common-cartridge:debug": "nest start common-cartridge --debug --watch --", + "nest:start:sync":"npm run nest:start:console -- sync run", "nest:test": "npm run nest:test:cov && npm run nest:lint", "nest:test:all": "jest \"^((?!(\\.load)\\.spec\\.ts).)*\"", "nest:test:unit": "jest \"^((?!(\\.api|\\.load)\\.spec\\.ts).)*\\.spec\\.ts$\"", From b12067669c959b112d7ac0f10674bc235fe8644a Mon Sep 17 00:00:00 2001 From: Thomas Feldtkeller Date: Mon, 9 Sep 2024 11:39:08 +0200 Subject: [PATCH 24/29] BC-7553 - authorization injection service (#5216) Implementation of an "Authorization Injection Service", which is able to accept authorization rules and reference loaders during the startup of the app, with the goal to avoid dependency cycles. * introduce injection service * rule manager and reference loader use injection service to determine list of rules and loaders respectively * board rule moved into board module --------- Co-authored-by: hoeppner-dataport --- .../authorization-reference.module.ts | 13 +- .../authorization/authorization.module.ts | 13 +- .../authorization/domain/rules/index.ts | 1 - .../authorization-injection.service.spec.ts | 39 ++++ .../authorization-injection.service.ts | 25 +++ .../authorization/domain/service/index.ts | 1 + .../domain/service/reference.loader.spec.ts | 155 ++++++++------- .../domain/service/reference.loader.ts | 81 +++----- .../domain/service/rule-manager.spec.ts | 185 ++++++++++-------- .../domain/service/rule-manager.ts | 80 ++++---- .../type/authorization-loader-service.ts | 1 - .../server/src/modules/authorization/index.ts | 1 + .../authorisation}/board-node.rule.spec.ts | 20 +- .../authorisation}/board-node.rule.ts | 16 +- apps/server/src/modules/board/board.module.ts | 4 + .../board-node-authorizable.service.spec.ts | 13 ++ .../board-node-authorizable.service.ts | 13 +- .../server/src/modules/teams/service/index.ts | 1 + .../service/team-authorisable.service.spec.ts | 45 +++++ .../service/team-authorisable.service.ts | 13 ++ apps/server/src/modules/teams/teams.module.ts | 6 +- 21 files changed, 441 insertions(+), 285 deletions(-) create mode 100644 apps/server/src/modules/authorization/domain/service/authorization-injection.service.spec.ts create mode 100644 apps/server/src/modules/authorization/domain/service/authorization-injection.service.ts rename apps/server/src/modules/{authorization/domain/rules => board/authorisation}/board-node.rule.spec.ts (97%) rename apps/server/src/modules/{authorization/domain/rules => board/authorisation}/board-node.rule.ts (94%) create mode 100644 apps/server/src/modules/teams/service/team-authorisable.service.spec.ts create mode 100644 apps/server/src/modules/teams/service/team-authorisable.service.ts diff --git a/apps/server/src/modules/authorization/authorization-reference.module.ts b/apps/server/src/modules/authorization/authorization-reference.module.ts index a5d59753c58..08a73a33aaf 100644 --- a/apps/server/src/modules/authorization/authorization-reference.module.ts +++ b/apps/server/src/modules/authorization/authorization-reference.module.ts @@ -1,4 +1,3 @@ -import { BoardModule } from '@modules/board'; import { InstanceModule } from '@modules/instance'; import { LessonModule } from '@modules/lesson'; import { ToolModule } from '@modules/tool'; @@ -10,12 +9,12 @@ import { SchoolExternalToolRepo, SubmissionRepo, TaskRepo, - TeamsRepo, UserRepo, } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from './authorization.module'; import { AuthorizationHelper, AuthorizationReferenceService, ReferenceLoader } from './domain'; +import { TeamsModule } from '../teams'; /** * This module is part of an intermediate state. In the future it should be replaced by an AuthorizationApiModule. @@ -24,14 +23,7 @@ import { AuthorizationHelper, AuthorizationReferenceService, ReferenceLoader } f */ @Module({ // TODO: remove forwardRef - imports: [ - AuthorizationModule, - LessonModule, - forwardRef(() => ToolModule), - forwardRef(() => BoardModule), - LoggerModule, - InstanceModule, - ], + imports: [AuthorizationModule, LessonModule, TeamsModule, forwardRef(() => ToolModule), LoggerModule, InstanceModule], providers: [ AuthorizationHelper, ReferenceLoader, @@ -40,7 +32,6 @@ import { AuthorizationHelper, AuthorizationReferenceService, ReferenceLoader } f CourseGroupRepo, TaskRepo, LegacySchoolRepo, - TeamsRepo, SubmissionRepo, SchoolExternalToolRepo, AuthorizationReferenceService, diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index ac9f3261b9c..df626b37b2f 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -2,9 +2,8 @@ import { FeathersModule } from '@infra/feathers'; import { Module } from '@nestjs/common'; import { UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { AuthorizationHelper, AuthorizationService, RuleManager } from './domain'; +import { AuthorizationHelper, AuthorizationService, RuleManager, AuthorizationInjectionService } from './domain'; import { - BoardNodeRule, ContextExternalToolRule, CourseGroupRule, CourseRule, @@ -29,13 +28,13 @@ import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; imports: [FeathersModule, LoggerModule], providers: [ FeathersAuthorizationService, + AuthorizationInjectionService, FeathersAuthProvider, AuthorizationService, UserRepo, RuleManager, AuthorizationHelper, // rules - BoardNodeRule, ContextExternalToolRule, CourseGroupRule, CourseRule, @@ -54,6 +53,12 @@ import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; ExternalToolRule, InstanceRule, ], - exports: [FeathersAuthorizationService, AuthorizationService, SystemRule], + exports: [ + FeathersAuthorizationService, + AuthorizationService, + SystemRule, + AuthorizationInjectionService, + AuthorizationHelper, + ], }) export class AuthorizationModule {} diff --git a/apps/server/src/modules/authorization/domain/rules/index.ts b/apps/server/src/modules/authorization/domain/rules/index.ts index 4c85a3247ad..437d8d2d6df 100644 --- a/apps/server/src/modules/authorization/domain/rules/index.ts +++ b/apps/server/src/modules/authorization/domain/rules/index.ts @@ -2,7 +2,6 @@ * Rules are currently placed in authorization module to avoid dependency cycles. * In future they must be moved to the feature modules and register it in registration service. */ -export * from './board-node.rule'; export * from './context-external-tool.rule'; export * from './course-group.rule'; export * from './course.rule'; diff --git a/apps/server/src/modules/authorization/domain/service/authorization-injection.service.spec.ts b/apps/server/src/modules/authorization/domain/service/authorization-injection.service.spec.ts new file mode 100644 index 00000000000..67c748566cc --- /dev/null +++ b/apps/server/src/modules/authorization/domain/service/authorization-injection.service.spec.ts @@ -0,0 +1,39 @@ +import { AuthorizableReferenceType, AuthorizationLoaderService, Rule } from '../type'; +import { AuthorizationInjectionService } from './authorization-injection.service'; + +function createRuleMock(isApplicable: boolean, hasPermission: boolean): Rule { + return { + isApplicable: jest.fn().mockReturnValue(isApplicable), + hasPermission: jest.fn().mockReturnValue(hasPermission), + }; +} + +function createReferenceLoaderMock(): AuthorizationLoaderService { + return { + findById: jest.fn().mockResolvedValue({}), + }; +} + +describe(AuthorizationInjectionService.name, () => { + describe('injectAuthorizationRule', () => { + it('should add rule to authorizationRules', () => { + const service = new AuthorizationInjectionService(); + const ruleMock: Rule = createRuleMock(true, true); + + service.injectAuthorizationRule(ruleMock); + + expect(service.getAuthorizationRules()).toContain(ruleMock); + }); + }); + + describe('injectReferenceLoader', () => { + it('should add reference loader to referenceLoaders', () => { + const service = new AuthorizationInjectionService(); + const referenceLoaderMock = createReferenceLoaderMock(); + + service.injectReferenceLoader(AuthorizableReferenceType.BoardNode, referenceLoaderMock); + + expect(service.getReferenceLoader(AuthorizableReferenceType.BoardNode)).toBe(referenceLoaderMock); + }); + }); +}); diff --git a/apps/server/src/modules/authorization/domain/service/authorization-injection.service.ts b/apps/server/src/modules/authorization/domain/service/authorization-injection.service.ts new file mode 100644 index 00000000000..df366caabae --- /dev/null +++ b/apps/server/src/modules/authorization/domain/service/authorization-injection.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { AuthorizableReferenceType, AuthorizationLoaderService, Rule } from '../type'; + +@Injectable() +export class AuthorizationInjectionService { + private readonly authorizationRules: Rule[] = []; + + private readonly referenceLoaders: Map = new Map(); + + injectAuthorizationRule(rule: Rule) { + this.authorizationRules.push(rule); + } + + getAuthorizationRules(): Rule[] { + return this.authorizationRules; + } + + injectReferenceLoader(referenceType: AuthorizableReferenceType, referenceLoader: AuthorizationLoaderService) { + this.referenceLoaders.set(referenceType, referenceLoader); + } + + getReferenceLoader(referenceType: AuthorizableReferenceType): AuthorizationLoaderService | undefined { + return this.referenceLoaders.get(referenceType); + } +} diff --git a/apps/server/src/modules/authorization/domain/service/index.ts b/apps/server/src/modules/authorization/domain/service/index.ts index 4175cc4b7a7..a6a242cd682 100644 --- a/apps/server/src/modules/authorization/domain/service/index.ts +++ b/apps/server/src/modules/authorization/domain/service/index.ts @@ -3,3 +3,4 @@ export * from './authorization.helper'; export * from './rule-manager'; export * from './authorization-reference.service'; export * from './reference.loader'; +export * from './authorization-injection.service'; diff --git a/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts b/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts index 38f6e3eeaac..5a7dd6516f4 100644 --- a/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts @@ -13,26 +13,26 @@ import { SchoolExternalToolRepo, SubmissionRepo, TaskRepo, - TeamsRepo, UserRepo, } from '@shared/repo'; import { setupEntities, userFactory } from '@shared/testing'; -import { BoardNodeAuthorizableService } from '@src/modules/board'; +import { TeamAuthorisableService } from '@src/modules/teams'; import { AuthorizableReferenceType } from '../type'; import { ReferenceLoader } from './reference.loader'; +import { AuthorizationInjectionService } from './authorization-injection.service'; describe('reference.loader', () => { let service: ReferenceLoader; + let injectionService: DeepMocked; let userRepo: DeepMocked; let courseRepo: DeepMocked; let courseGroupRepo: DeepMocked; let taskRepo: DeepMocked; let schoolRepo: DeepMocked; let lessonService: DeepMocked; - let teamsRepo: DeepMocked; + let teamsAuthorisableService: DeepMocked; let submissionRepo: DeepMocked; let schoolExternalToolRepo: DeepMocked; - let boardNodeAuthorizableService: DeepMocked; let contextExternalToolAuthorizableService: DeepMocked; let externalToolAuthorizableService: DeepMocked; let instanceService: DeepMocked; @@ -44,6 +44,10 @@ describe('reference.loader', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ReferenceLoader, + { + provide: AuthorizationInjectionService, + useValue: createMock(), + }, { provide: UserRepo, useValue: createMock(), @@ -69,8 +73,8 @@ describe('reference.loader', () => { useValue: createMock(), }, { - provide: TeamsRepo, - useValue: createMock(), + provide: TeamAuthorisableService, + useValue: createMock(), }, { provide: SubmissionRepo, @@ -80,10 +84,6 @@ describe('reference.loader', () => { provide: SchoolExternalToolRepo, useValue: createMock(), }, - { - provide: BoardNodeAuthorizableService, - useValue: createMock(), - }, { provide: ContextExternalToolAuthorizableService, useValue: createMock(), @@ -100,22 +100,26 @@ describe('reference.loader', () => { }).compile(); service = await module.get(ReferenceLoader); + injectionService = await module.get(AuthorizationInjectionService); userRepo = await module.get(UserRepo); courseRepo = await module.get(CourseRepo); courseGroupRepo = await module.get(CourseGroupRepo); taskRepo = await module.get(TaskRepo); schoolRepo = await module.get(LegacySchoolRepo); lessonService = await module.get(LessonService); - teamsRepo = await module.get(TeamsRepo); + teamsAuthorisableService = await module.get(TeamAuthorisableService); submissionRepo = await module.get(SubmissionRepo); schoolExternalToolRepo = await module.get(SchoolExternalToolRepo); - boardNodeAuthorizableService = await module.get(BoardNodeAuthorizableService); contextExternalToolAuthorizableService = await module.get(ContextExternalToolAuthorizableService); externalToolAuthorizableService = await module.get(ExternalToolAuthorizableService); instanceService = await module.get(InstanceService); }); afterEach(() => { + injectionService.getReferenceLoader.mockReset(); + }); + + afterAll(() => { jest.resetAllMocks(); }); @@ -124,97 +128,106 @@ describe('reference.loader', () => { }); describe('loadEntity', () => { - it('should call taskRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.Task, entityId); + it('should call findById on reference loader', async () => { + const referenceLoader = { + findById: jest.fn(), + }; - expect(taskRepo.findById).toBeCalledWith(entityId); - }); + injectionService.getReferenceLoader.mockReturnValue(referenceLoader); - it('should call courseRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.Course, entityId); + await service.loadAuthorizableObject(AuthorizableReferenceType.User, entityId); - expect(courseRepo.findById).toBeCalledWith(entityId); + expect(referenceLoader.findById).toBeCalledWith(entityId); }); - it('should call courseGroupRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.CourseGroup, entityId); + it('should return authorizable object', async () => { + const expected = userFactory.build(); + const referenceLoader = { + findById: jest.fn().mockResolvedValue(expected), + }; - expect(courseGroupRepo.findById).toBeCalledWith(entityId); - }); + injectionService.getReferenceLoader.mockReturnValue(referenceLoader); - it('should call schoolRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.School, entityId); + const result = await service.loadAuthorizableObject(AuthorizableReferenceType.User, entityId); - expect(schoolRepo.findById).toBeCalledWith(entityId); + expect(result).toEqual(expected); }); - it('should call userRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.User, entityId); - - expect(userRepo.findById).toBeCalledWith(entityId); + it('should throw on unknown authorization entity type', () => { + void expect(async () => + service.loadAuthorizableObject('NotAllowedEntityType' as AuthorizableReferenceType, entityId) + ).rejects.toThrow(NotImplementedException); }); + }); - it('should call lessonRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.Lesson, entityId); - - expect(lessonService.findById).toBeCalledWith(entityId); + describe('currently, the reference loader has to inject the loaders into the injection service. In the future, this part should be moved into the modules.', () => { + it('should inject user repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith(AuthorizableReferenceType.User, userRepo); }); - it('should call teamsRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.Team, entityId); - - expect(teamsRepo.findById).toBeCalledWith(entityId, true); + it('should inject course repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith(AuthorizableReferenceType.Course, courseRepo); }); - it('should call contextExternalToolService.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.ContextExternalToolEntity, entityId); - - expect(contextExternalToolAuthorizableService.findById).toBeCalledWith(entityId); + it('should inject course group repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith( + AuthorizableReferenceType.CourseGroup, + courseGroupRepo + ); }); - it('should call submissionRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.Submission, entityId); - - expect(submissionRepo.findById).toBeCalledWith(entityId); + it('should inject task repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith(AuthorizableReferenceType.Task, taskRepo); }); - it('should call schoolExternalToolRepo.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.SchoolExternalToolEntity, entityId); - - expect(schoolExternalToolRepo.findById).toBeCalledWith(entityId); + it('should inject school repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith(AuthorizableReferenceType.School, schoolRepo); }); - it('should call externalToolAuthorizableService.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.ExternalTool, entityId); - - expect(externalToolAuthorizableService.findById).toBeCalledWith(entityId); + it('should inject lesson service', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith(AuthorizableReferenceType.Lesson, lessonService); }); - it('should call findNodeService.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.BoardNode, entityId); - - expect(boardNodeAuthorizableService.findById).toBeCalledWith(entityId); + it('should inject teams repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith( + AuthorizableReferenceType.Team, + teamsAuthorisableService + ); }); - it('should call instanceService.findById', async () => { - await service.loadAuthorizableObject(AuthorizableReferenceType.Instance, entityId); - - expect(instanceService.findById).toBeCalledWith(entityId); + it('should inject submission repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith( + AuthorizableReferenceType.Submission, + submissionRepo + ); }); - it('should return authorizable object', async () => { - const user = userFactory.build(); - userRepo.findById.mockResolvedValue(user); + it('should inject school external tool repo', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith( + AuthorizableReferenceType.SchoolExternalToolEntity, + schoolExternalToolRepo + ); + }); - const result = await service.loadAuthorizableObject(AuthorizableReferenceType.User, entityId); + it('should inject context external tool authorizable service', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith( + AuthorizableReferenceType.ContextExternalToolEntity, + contextExternalToolAuthorizableService + ); + }); - expect(result).toBe(user); + it('should inject external tool authorizable service', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith( + AuthorizableReferenceType.ExternalTool, + externalToolAuthorizableService + ); }); - it('should throw on unknown authorization entity type', () => { - void expect(async () => - service.loadAuthorizableObject('NotAllowedEntityType' as AuthorizableReferenceType, entityId) - ).rejects.toThrow(NotImplementedException); + it('should inject instance service', () => { + expect(injectionService.injectReferenceLoader).toBeCalledWith( + AuthorizableReferenceType.Instance, + instanceService + ); }); }); }); diff --git a/apps/server/src/modules/authorization/domain/service/reference.loader.ts b/apps/server/src/modules/authorization/domain/service/reference.loader.ts index bd45203e954..43aecf0f1e3 100644 --- a/apps/server/src/modules/authorization/domain/service/reference.loader.ts +++ b/apps/server/src/modules/authorization/domain/service/reference.loader.ts @@ -1,8 +1,8 @@ // TODO fix modules circular dependency // eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { BoardNodeAuthorizableService } from '@modules/board/service'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports import { ContextExternalToolAuthorizableService } from '@modules/tool/context-external-tool/service'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { TeamAuthorisableService } from '@src/modules/teams/service/team-authorisable.service'; import { ExternalToolAuthorizableService } from '@modules/tool/external-tool/service'; import { LessonService } from '@modules/lesson'; import { Injectable, NotImplementedException } from '@nestjs/common'; @@ -16,36 +16,14 @@ import { SchoolExternalToolRepo, SubmissionRepo, TaskRepo, - TeamsRepo, UserRepo, } from '@shared/repo'; import { InstanceService } from '../../../instance'; -import { AuthorizableReferenceType } from '../type'; - -type RepoType = - | BoardNodeAuthorizableService - | ContextExternalToolAuthorizableService - | CourseGroupRepo - | CourseRepo - | LegacySchoolRepo - | LessonService - | SchoolExternalToolRepo - | SubmissionRepo - | TaskRepo - | TeamsRepo - | UserRepo - | ExternalToolAuthorizableService - | InstanceService; - -interface RepoLoader { - repo: RepoType; - populate?: boolean; -} +import { AuthorizableReferenceType, AuthorizationLoaderService } from '../type'; +import { AuthorizationInjectionService } from './authorization-injection.service'; @Injectable() export class ReferenceLoader { - private repos: Map = new Map(); - constructor( private readonly userRepo: UserRepo, private readonly courseRepo: CourseRepo, @@ -53,33 +31,34 @@ export class ReferenceLoader { private readonly taskRepo: TaskRepo, private readonly schoolRepo: LegacySchoolRepo, private readonly lessonService: LessonService, - private readonly teamsRepo: TeamsRepo, + private readonly teamAuthorisableService: TeamAuthorisableService, private readonly submissionRepo: SubmissionRepo, private readonly schoolExternalToolRepo: SchoolExternalToolRepo, - private readonly boardNodeAuthorizableService: BoardNodeAuthorizableService, private readonly contextExternalToolAuthorizableService: ContextExternalToolAuthorizableService, private readonly externalToolAuthorizableService: ExternalToolAuthorizableService, - private readonly instanceService: InstanceService + private readonly instanceService: InstanceService, + private readonly authorizationInjectionService: AuthorizationInjectionService ) { - this.repos.set(AuthorizableReferenceType.Task, { repo: this.taskRepo }); - this.repos.set(AuthorizableReferenceType.Course, { repo: this.courseRepo }); - this.repos.set(AuthorizableReferenceType.CourseGroup, { repo: this.courseGroupRepo }); - this.repos.set(AuthorizableReferenceType.User, { repo: this.userRepo }); - this.repos.set(AuthorizableReferenceType.School, { repo: this.schoolRepo }); - this.repos.set(AuthorizableReferenceType.Lesson, { repo: this.lessonService }); - this.repos.set(AuthorizableReferenceType.Team, { repo: this.teamsRepo, populate: true }); - this.repos.set(AuthorizableReferenceType.Submission, { repo: this.submissionRepo }); - this.repos.set(AuthorizableReferenceType.SchoolExternalToolEntity, { repo: this.schoolExternalToolRepo }); - this.repos.set(AuthorizableReferenceType.BoardNode, { repo: this.boardNodeAuthorizableService }); - this.repos.set(AuthorizableReferenceType.ContextExternalToolEntity, { - repo: this.contextExternalToolAuthorizableService, - }); - this.repos.set(AuthorizableReferenceType.ExternalTool, { repo: this.externalToolAuthorizableService }); - this.repos.set(AuthorizableReferenceType.Instance, { repo: this.instanceService }); + const service = this.authorizationInjectionService; + service.injectReferenceLoader(AuthorizableReferenceType.Task, this.taskRepo); + service.injectReferenceLoader(AuthorizableReferenceType.Course, this.courseRepo); + service.injectReferenceLoader(AuthorizableReferenceType.CourseGroup, this.courseGroupRepo); + service.injectReferenceLoader(AuthorizableReferenceType.User, this.userRepo); + service.injectReferenceLoader(AuthorizableReferenceType.School, this.schoolRepo); + service.injectReferenceLoader(AuthorizableReferenceType.Lesson, this.lessonService); + service.injectReferenceLoader(AuthorizableReferenceType.Team, this.teamAuthorisableService); + service.injectReferenceLoader(AuthorizableReferenceType.Submission, this.submissionRepo); + service.injectReferenceLoader(AuthorizableReferenceType.SchoolExternalToolEntity, this.schoolExternalToolRepo); + service.injectReferenceLoader( + AuthorizableReferenceType.ContextExternalToolEntity, + this.contextExternalToolAuthorizableService + ); + service.injectReferenceLoader(AuthorizableReferenceType.ExternalTool, this.externalToolAuthorizableService); + service.injectReferenceLoader(AuthorizableReferenceType.Instance, this.instanceService); } - private resolveRepo(type: AuthorizableReferenceType): RepoLoader { - const repo = this.repos.get(type); + private resolveLoader(type: AuthorizableReferenceType): AuthorizationLoaderService { + const repo = this.authorizationInjectionService.getReferenceLoader(type); if (repo) { return repo; } @@ -90,14 +69,8 @@ export class ReferenceLoader { objectName: AuthorizableReferenceType, objectId: EntityId ): Promise { - const repoLoader: RepoLoader = this.resolveRepo(objectName); - - let object: AuthorizableObject | BaseDO; - if (repoLoader.populate) { - object = await repoLoader.repo.findById(objectId, true); - } else { - object = await repoLoader.repo.findById(objectId); - } + const referenceLoader: AuthorizationLoaderService = this.resolveLoader(objectName); + const object = await referenceLoader.findById(objectId); return object; } diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts index 1856634692a..9f4da3b753b 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts @@ -6,7 +6,6 @@ import { courseFactory, setupEntities, userFactory } from '@shared/testing'; import { RuleManager } from '.'; import { AuthorizationContextBuilder } from '../mapper'; import { - BoardNodeRule, ContextExternalToolRule, CourseGroupRule, CourseRule, @@ -25,9 +24,11 @@ import { UserRule, } from '../rules'; import { ExternalToolRule } from '../rules/external-tool.rule'; +import { AuthorizationInjectionService } from './authorization-injection.service'; describe('RuleManager', () => { let service: RuleManager; + let injectionService: DeepMocked; let courseRule: DeepMocked; let courseGroupRule: DeepMocked; let lessonRule: DeepMocked; @@ -37,7 +38,6 @@ describe('RuleManager', () => { let teamRule: DeepMocked; let submissionRule: DeepMocked; let schoolExternalToolRule: DeepMocked; - let boardNodeRule: DeepMocked; let contextExternalToolRule: DeepMocked; let userLoginMigrationRule: DeepMocked; let schoolRule: DeepMocked; @@ -53,6 +53,7 @@ describe('RuleManager', () => { const module = await Test.createTestingModule({ providers: [ RuleManager, + { provide: AuthorizationInjectionService, useValue: createMock() }, { provide: CourseRule, useValue: createMock() }, { provide: CourseGroupRule, useValue: createMock() }, { provide: GroupRule, useValue: createMock() }, @@ -63,7 +64,6 @@ describe('RuleManager', () => { { provide: TeamRule, useValue: createMock() }, { provide: SubmissionRule, useValue: createMock() }, { provide: SchoolExternalToolRule, useValue: createMock() }, - { provide: BoardNodeRule, useValue: createMock() }, { provide: ContextExternalToolRule, useValue: createMock() }, { provide: UserLoginMigrationRule, useValue: createMock() }, { provide: SchoolRule, useValue: createMock() }, @@ -75,6 +75,7 @@ describe('RuleManager', () => { }).compile(); service = await module.get(RuleManager); + injectionService = module.get(AuthorizationInjectionService); courseRule = await module.get(CourseRule); courseGroupRule = await module.get(CourseGroupRule); lessonRule = await module.get(LessonRule); @@ -84,7 +85,6 @@ describe('RuleManager', () => { teamRule = await module.get(TeamRule); submissionRule = await module.get(SubmissionRule); schoolExternalToolRule = await module.get(SchoolExternalToolRule); - boardNodeRule = await module.get(BoardNodeRule); contextExternalToolRule = await module.get(ContextExternalToolRule); userLoginMigrationRule = await module.get(UserLoginMigrationRule); schoolRule = await module.get(SchoolRule); @@ -96,6 +96,10 @@ describe('RuleManager', () => { }); afterEach(() => { + injectionService.getAuthorizationRules.mockReset(); + }); + + afterAll(() => { jest.resetAllMocks(); }); @@ -104,66 +108,42 @@ describe('RuleManager', () => { }); describe('selectRule', () => { - // We only test for one rule here, because all rules behave the same. - describe('when CourseRule is applicable', () => { + const buildRule = (isApplicable: boolean) => { + return { isApplicable: jest.fn().mockReturnValue(isApplicable), hasPermission: jest.fn() }; + }; + + const buildApplicableRule = () => buildRule(true); + const buildNotApplicableRule = () => buildRule(false); + + describe('when one Rule is applicable', () => { const setup = () => { const user = userFactory.build(); const object = courseFactory.build(); const context = AuthorizationContextBuilder.read([]); - courseRule.isApplicable.mockReturnValueOnce(true); - courseGroupRule.isApplicable.mockReturnValueOnce(false); - lessonRule.isApplicable.mockReturnValueOnce(false); - legacySchoolRule.isApplicable.mockReturnValueOnce(false); - userRule.isApplicable.mockReturnValueOnce(false); - taskRule.isApplicable.mockReturnValueOnce(false); - teamRule.isApplicable.mockReturnValueOnce(false); - submissionRule.isApplicable.mockReturnValueOnce(false); - schoolExternalToolRule.isApplicable.mockReturnValueOnce(false); - boardNodeRule.isApplicable.mockReturnValueOnce(false); - contextExternalToolRule.isApplicable.mockReturnValueOnce(false); - userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); - schoolRule.isApplicable.mockReturnValueOnce(false); - groupRule.isApplicable.mockReturnValueOnce(false); - systemRule.isApplicable.mockReturnValueOnce(false); - schoolSystemOptionsRule.isApplicable.mockReturnValueOnce(false); - externalToolRule.isApplicable.mockReturnValueOnce(false); - instanceRule.isApplicable.mockReturnValueOnce(false); + const applicableRule = buildApplicableRule(); + const notApplicableRule = buildNotApplicableRule(); - return { user, object, context }; + injectionService.getAuthorizationRules.mockReturnValueOnce([applicableRule, notApplicableRule]); + + return { user, object, context, applicableRule, notApplicableRule }; }; it('should call isApplicable on all rules', () => { - const { user, object, context } = setup(); + const { user, object, context, applicableRule, notApplicableRule } = setup(); service.selectRule(user, object, context); - expect(courseRule.isApplicable).toBeCalled(); - expect(courseGroupRule.isApplicable).toBeCalled(); - expect(lessonRule.isApplicable).toBeCalled(); - expect(legacySchoolRule.isApplicable).toBeCalled(); - expect(userRule.isApplicable).toBeCalled(); - expect(taskRule.isApplicable).toBeCalled(); - expect(teamRule.isApplicable).toBeCalled(); - expect(submissionRule.isApplicable).toBeCalled(); - expect(schoolExternalToolRule.isApplicable).toBeCalled(); - expect(boardNodeRule.isApplicable).toBeCalled(); - expect(contextExternalToolRule.isApplicable).toBeCalled(); - expect(userLoginMigrationRule.isApplicable).toBeCalled(); - expect(schoolRule.isApplicable).toBeCalled(); - expect(groupRule.isApplicable).toBeCalled(); - expect(systemRule.isApplicable).toBeCalled(); - expect(schoolSystemOptionsRule.isApplicable).toBeCalled(); - expect(externalToolRule.isApplicable).toBeCalled(); - expect(instanceRule.isApplicable).toBeCalled(); + expect(applicableRule.isApplicable).toBeCalled(); + expect(notApplicableRule.isApplicable).toBeCalled(); }); - it('should return CourseRule', () => { - const { user, object, context } = setup(); + it('should return Applicable Rule', () => { + const { user, object, context, applicableRule } = setup(); const result = service.selectRule(user, object, context); - expect(result).toBe(courseRule); + expect(result).toEqual(applicableRule); }); }); @@ -173,24 +153,10 @@ describe('RuleManager', () => { const object = courseFactory.build(); const context = AuthorizationContextBuilder.read([]); - courseRule.isApplicable.mockReturnValueOnce(false); - courseGroupRule.isApplicable.mockReturnValueOnce(false); - lessonRule.isApplicable.mockReturnValueOnce(false); - legacySchoolRule.isApplicable.mockReturnValueOnce(false); - userRule.isApplicable.mockReturnValueOnce(false); - taskRule.isApplicable.mockReturnValueOnce(false); - teamRule.isApplicable.mockReturnValueOnce(false); - submissionRule.isApplicable.mockReturnValueOnce(false); - schoolExternalToolRule.isApplicable.mockReturnValueOnce(false); - boardNodeRule.isApplicable.mockReturnValueOnce(false); - contextExternalToolRule.isApplicable.mockReturnValueOnce(false); - userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); - schoolRule.isApplicable.mockReturnValueOnce(false); - groupRule.isApplicable.mockReturnValueOnce(false); - systemRule.isApplicable.mockReturnValueOnce(false); - schoolSystemOptionsRule.isApplicable.mockReturnValueOnce(false); - externalToolRule.isApplicable.mockReturnValueOnce(false); - instanceRule.isApplicable.mockReturnValueOnce(false); + injectionService.getAuthorizationRules.mockReturnValueOnce([ + buildNotApplicableRule(), + buildNotApplicableRule(), + ]); return { user, object, context }; }; @@ -208,24 +174,7 @@ describe('RuleManager', () => { const object = courseFactory.build(); const context = AuthorizationContextBuilder.read([]); - courseRule.isApplicable.mockReturnValueOnce(true); - courseGroupRule.isApplicable.mockReturnValueOnce(true); - lessonRule.isApplicable.mockReturnValueOnce(false); - legacySchoolRule.isApplicable.mockReturnValueOnce(false); - userRule.isApplicable.mockReturnValueOnce(false); - taskRule.isApplicable.mockReturnValueOnce(false); - teamRule.isApplicable.mockReturnValueOnce(false); - submissionRule.isApplicable.mockReturnValueOnce(false); - schoolExternalToolRule.isApplicable.mockReturnValueOnce(false); - boardNodeRule.isApplicable.mockReturnValueOnce(false); - contextExternalToolRule.isApplicable.mockReturnValueOnce(false); - userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); - schoolRule.isApplicable.mockReturnValueOnce(false); - groupRule.isApplicable.mockReturnValueOnce(false); - systemRule.isApplicable.mockReturnValueOnce(false); - schoolSystemOptionsRule.isApplicable.mockReturnValueOnce(false); - externalToolRule.isApplicable.mockReturnValueOnce(false); - instanceRule.isApplicable.mockReturnValueOnce(false); + injectionService.getAuthorizationRules.mockReturnValueOnce([buildApplicableRule(), buildApplicableRule()]); return { user, object, context }; }; @@ -237,4 +186,74 @@ describe('RuleManager', () => { }); }); }); + + describe('currently, most of the Rules are injected into the AuthorizationInjectionService by the RuleManager. In the future, these should go into the modules instead', () => { + it('should inject CourseRule', () => { + expect(injectionService.injectAuthorizationRule).toHaveBeenCalledWith(courseRule); + }); + + it('should inject CourseGroupRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(courseGroupRule); + }); + + it('should inject LessonRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(lessonRule); + }); + + it('should inject LegacySchoolRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(legacySchoolRule); + }); + + it('should inject UserRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(userRule); + }); + + it('should inject TaskRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(taskRule); + }); + + it('should inject TeamRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(teamRule); + }); + + it('should inject SubmissionRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(submissionRule); + }); + + it('should inject SchoolExternalToolRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(schoolExternalToolRule); + }); + + it('should inject ContextExternalToolRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(contextExternalToolRule); + }); + + it('should inject UserLoginMigrationRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(userLoginMigrationRule); + }); + + it('should inject SchoolRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(schoolRule); + }); + + it('should inject GroupRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(groupRule); + }); + + it('should inject SystemRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(systemRule); + }); + + it('should inject SchoolSystemOptionsRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(schoolSystemOptionsRule); + }); + + it('should inject ExternalToolRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(externalToolRule); + }); + + it('should inject InstanceRule', () => { + expect(injectionService.injectAuthorizationRule).toBeCalledWith(instanceRule); + }); + }); }); diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.ts index 921caca0721..25738d268ab 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.ts @@ -3,7 +3,6 @@ import { AuthorizableObject } from '@shared/domain/domain-object'; // fix import import { BaseDO } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; import { - BoardNodeRule, ContextExternalToolRule, CourseGroupRule, CourseRule, @@ -23,55 +22,52 @@ import { } from '../rules'; import { ExternalToolRule } from '../rules/external-tool.rule'; import type { AuthorizationContext, Rule } from '../type'; +import { AuthorizationInjectionService } from './authorization-injection.service'; @Injectable() export class RuleManager { - private readonly rules: Rule[]; - constructor( - private readonly boardNodeRule: BoardNodeRule, - private readonly contextExternalToolRule: ContextExternalToolRule, - private readonly courseGroupRule: CourseGroupRule, - private readonly courseRule: CourseRule, - private readonly groupRule: GroupRule, - private readonly legaySchoolRule: LegacySchoolRule, - private readonly lessonRule: LessonRule, - private readonly schoolExternalToolRule: SchoolExternalToolRule, - private readonly schoolRule: SchoolRule, - private readonly schoolSystemOptionsRule: SchoolSystemOptionsRule, - private readonly submissionRule: SubmissionRule, - private readonly systemRule: SystemRule, - private readonly taskRule: TaskRule, - private readonly teamRule: TeamRule, - private readonly userLoginMigrationRule: UserLoginMigrationRule, - private readonly userRule: UserRule, - private readonly externalToolRule: ExternalToolRule, - private readonly instanceRule: InstanceRule + contextExternalToolRule: ContextExternalToolRule, + courseGroupRule: CourseGroupRule, + courseRule: CourseRule, + groupRule: GroupRule, + legaySchoolRule: LegacySchoolRule, + lessonRule: LessonRule, + schoolExternalToolRule: SchoolExternalToolRule, + schoolRule: SchoolRule, + schoolSystemOptionsRule: SchoolSystemOptionsRule, + submissionRule: SubmissionRule, + systemRule: SystemRule, + taskRule: TaskRule, + teamRule: TeamRule, + userLoginMigrationRule: UserLoginMigrationRule, + userRule: UserRule, + externalToolRule: ExternalToolRule, + instanceRule: InstanceRule, + private readonly authorizationInjectionService: AuthorizationInjectionService ) { - this.rules = [ - this.boardNodeRule, - this.contextExternalToolRule, - this.courseGroupRule, - this.courseRule, - this.groupRule, - this.legaySchoolRule, - this.lessonRule, - this.schoolExternalToolRule, - this.schoolRule, - this.schoolSystemOptionsRule, - this.submissionRule, - this.systemRule, - this.taskRule, - this.teamRule, - this.userLoginMigrationRule, - this.userRule, - this.externalToolRule, - this.instanceRule, - ]; + this.authorizationInjectionService.injectAuthorizationRule(contextExternalToolRule); + this.authorizationInjectionService.injectAuthorizationRule(courseGroupRule); + this.authorizationInjectionService.injectAuthorizationRule(courseRule); + this.authorizationInjectionService.injectAuthorizationRule(groupRule); + this.authorizationInjectionService.injectAuthorizationRule(legaySchoolRule); + this.authorizationInjectionService.injectAuthorizationRule(lessonRule); + this.authorizationInjectionService.injectAuthorizationRule(schoolExternalToolRule); + this.authorizationInjectionService.injectAuthorizationRule(schoolRule); + this.authorizationInjectionService.injectAuthorizationRule(schoolSystemOptionsRule); + this.authorizationInjectionService.injectAuthorizationRule(submissionRule); + this.authorizationInjectionService.injectAuthorizationRule(systemRule); + this.authorizationInjectionService.injectAuthorizationRule(taskRule); + this.authorizationInjectionService.injectAuthorizationRule(teamRule); + this.authorizationInjectionService.injectAuthorizationRule(userLoginMigrationRule); + this.authorizationInjectionService.injectAuthorizationRule(userRule); + this.authorizationInjectionService.injectAuthorizationRule(externalToolRule); + this.authorizationInjectionService.injectAuthorizationRule(instanceRule); } public selectRule(user: User, object: AuthorizableObject | BaseDO, context: AuthorizationContext): Rule { - const selectedRules = this.rules.filter((rule) => rule.isApplicable(user, object, context)); + const rules = [...this.authorizationInjectionService.getAuthorizationRules()]; + const selectedRules = rules.filter((rule) => rule.isApplicable(user, object, context)); const rule = this.matchSingleRule(selectedRules); return rule; diff --git a/apps/server/src/modules/authorization/domain/type/authorization-loader-service.ts b/apps/server/src/modules/authorization/domain/type/authorization-loader-service.ts index e369fba20b8..35ec0ab2d94 100644 --- a/apps/server/src/modules/authorization/domain/type/authorization-loader-service.ts +++ b/apps/server/src/modules/authorization/domain/type/authorization-loader-service.ts @@ -5,7 +5,6 @@ import { EntityId } from '@shared/domain/types'; export interface AuthorizationLoaderService { findById(id: EntityId): Promise; } - export interface AuthorizationLoaderServiceGeneric extends AuthorizationLoaderService { findById(id: EntityId): Promise; diff --git a/apps/server/src/modules/authorization/index.ts b/apps/server/src/modules/authorization/index.ts index b90a586ff54..11e184dbd03 100644 --- a/apps/server/src/modules/authorization/index.ts +++ b/apps/server/src/modules/authorization/index.ts @@ -11,6 +11,7 @@ export { AuthorizationReferenceService, AuthorizationService, ForbiddenLoggableException, + AuthorizationInjectionService, Rule, // For the use in feathers SystemRule, diff --git a/apps/server/src/modules/authorization/domain/rules/board-node.rule.spec.ts b/apps/server/src/modules/board/authorisation/board-node.rule.spec.ts similarity index 97% rename from apps/server/src/modules/authorization/domain/rules/board-node.rule.spec.ts rename to apps/server/src/modules/board/authorisation/board-node.rule.spec.ts index 8a59b5e5e2b..faad700109c 100644 --- a/apps/server/src/modules/authorization/domain/rules/board-node.rule.spec.ts +++ b/apps/server/src/modules/board/authorisation/board-node.rule.spec.ts @@ -3,29 +3,31 @@ import { BoardNodeAuthorizable, BoardRoles } from '@modules/board'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { - columnBoardFactory, - drawingElementFactory, - fileElementFactory, - submissionItemFactory, -} from '@src/modules/board/testing'; -import { AuthorizationHelper } from '../service/authorization.helper'; -import { Action } from '../type'; +import { AuthorizationHelper, AuthorizationInjectionService, Action } from '@src/modules/authorization'; import { BoardNodeRule } from './board-node.rule'; +import { columnBoardFactory, drawingElementFactory, fileElementFactory, submissionItemFactory } from '../testing'; describe(BoardNodeRule.name, () => { let service: BoardNodeRule; let authorizationHelper: AuthorizationHelper; + let injectionService: AuthorizationInjectionService; beforeAll(async () => { await setupEntities(); const module: TestingModule = await Test.createTestingModule({ - providers: [BoardNodeRule, AuthorizationHelper], + providers: [BoardNodeRule, AuthorizationHelper, AuthorizationInjectionService], }).compile(); service = await module.get(BoardNodeRule); authorizationHelper = await module.get(AuthorizationHelper); + injectionService = await module.get(AuthorizationInjectionService); + }); + + describe('injection', () => { + it('should inject itself into authorisation module', () => { + expect(injectionService.getAuthorizationRules()).toContain(service); + }); }); describe('isApplicable', () => { diff --git a/apps/server/src/modules/authorization/domain/rules/board-node.rule.ts b/apps/server/src/modules/board/authorisation/board-node.rule.ts similarity index 94% rename from apps/server/src/modules/authorization/domain/rules/board-node.rule.ts rename to apps/server/src/modules/board/authorisation/board-node.rule.ts index d743d40bea7..ea5c91d7d5f 100644 --- a/apps/server/src/modules/authorization/domain/rules/board-node.rule.ts +++ b/apps/server/src/modules/board/authorisation/board-node.rule.ts @@ -12,12 +12,22 @@ import { Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity/user.entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { AuthorizationHelper } from '../service/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '../type'; +import { + AuthorizationHelper, + Action, + AuthorizationContext, + Rule, + AuthorizationInjectionService, +} from '@modules/authorization'; @Injectable() export class BoardNodeRule implements Rule { - constructor(private readonly authorizationHelper: AuthorizationHelper) {} + constructor( + private readonly authorizationHelper: AuthorizationHelper, + authorisationInjectionService: AuthorizationInjectionService + ) { + authorisationInjectionService.injectAuthorizationRule(this); + } public isApplicable(user: User, object: unknown): boolean { const isMatched = object instanceof BoardNodeAuthorizable; diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 798366fb0b7..86f8e2671b3 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -30,6 +30,8 @@ import { ColumnBoardTitleService, ContentElementUpdateService, } from './service/internal'; +import { BoardNodeRule } from './authorisation/board-node.rule'; +import { AuthorizationModule } from '../authorization'; @Module({ imports: [ @@ -42,9 +44,11 @@ import { TldrawClientModule, CqrsModule, CollaborativeTextEditorModule, + AuthorizationModule, ], providers: [ // TODO: move BoardDoAuthorizableService, BoardDoRepo, BoardDoService, BoardNodeRepo in separate module and move mediaboard related services in mediaboard module + BoardNodeRule, BoardContextService, BoardNodeAuthorizableService, BoardNodeRepo, diff --git a/apps/server/src/modules/board/service/board-node-authorizable.service.spec.ts b/apps/server/src/modules/board/service/board-node-authorizable.service.spec.ts index 86242decfd5..59d736dd1ac 100644 --- a/apps/server/src/modules/board/service/board-node-authorizable.service.spec.ts +++ b/apps/server/src/modules/board/service/board-node-authorizable.service.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; +import { AuthorizableReferenceType, AuthorizationInjectionService } from '@src/modules/authorization'; import { columnBoardFactory, columnFactory } from '../testing'; import { BoardNodeAuthorizable, BoardRoles, UserWithBoardRoles } from '../domain'; import { BoardNodeRepo } from '../repo'; @@ -11,6 +12,7 @@ import { BoardNodeService } from './board-node.service'; describe(BoardNodeAuthorizableService.name, () => { let module: TestingModule; let service: BoardNodeAuthorizableService; + let injectionService: DeepMocked; let boardNodeRepo: DeepMocked; let boardNodeService: DeepMocked; let boardContextService: DeepMocked; @@ -31,9 +33,14 @@ describe(BoardNodeAuthorizableService.name, () => { provide: BoardContextService, useValue: createMock(), }, + { + provide: AuthorizationInjectionService, + useValue: createMock(), + }, ], }).compile(); + injectionService = module.get(AuthorizationInjectionService); service = module.get(BoardNodeAuthorizableService); boardNodeRepo = module.get(BoardNodeRepo); boardNodeService = module.get(BoardNodeService); @@ -50,6 +57,12 @@ describe(BoardNodeAuthorizableService.name, () => { await module.close(); }); + describe('injection', () => { + it('should inject itself into authorisation module', () => { + expect(injectionService.injectReferenceLoader).toHaveBeenCalledWith(AuthorizableReferenceType.BoardNode, service); + }); + }); + describe('findById', () => { describe('when finding a board domainobject', () => { const setup = () => { diff --git a/apps/server/src/modules/board/service/board-node-authorizable.service.ts b/apps/server/src/modules/board/service/board-node-authorizable.service.ts index b813761163b..4de17520369 100644 --- a/apps/server/src/modules/board/service/board-node-authorizable.service.ts +++ b/apps/server/src/modules/board/service/board-node-authorizable.service.ts @@ -1,6 +1,10 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { type EntityId } from '@shared/domain/types'; -import { type AuthorizationLoaderService } from '@modules/authorization'; +import { + type AuthorizationLoaderService, + AuthorizationInjectionService, + AuthorizableReferenceType, +} from '@modules/authorization'; import { AnyBoardNode, BoardNodeAuthorizable, UserWithBoardRoles } from '../domain'; import { BoardNodeRepo } from '../repo'; import { BoardContextService } from './internal/board-context.service'; @@ -11,8 +15,11 @@ export class BoardNodeAuthorizableService implements AuthorizationLoaderService constructor( @Inject(forwardRef(() => BoardNodeRepo)) private readonly boardNodeRepo: BoardNodeRepo, private readonly boardNodeService: BoardNodeService, - private readonly boardContextService: BoardContextService - ) {} + private readonly boardContextService: BoardContextService, + injectionService: AuthorizationInjectionService + ) { + injectionService.injectReferenceLoader(AuthorizableReferenceType.BoardNode, this); + } /** * @deprecated diff --git a/apps/server/src/modules/teams/service/index.ts b/apps/server/src/modules/teams/service/index.ts index 24fa8967195..5a1fda9ffe8 100644 --- a/apps/server/src/modules/teams/service/index.ts +++ b/apps/server/src/modules/teams/service/index.ts @@ -1 +1,2 @@ export * from './team.service'; +export * from './team-authorisable.service'; diff --git a/apps/server/src/modules/teams/service/team-authorisable.service.spec.ts b/apps/server/src/modules/teams/service/team-authorisable.service.spec.ts new file mode 100644 index 00000000000..3321d6ce818 --- /dev/null +++ b/apps/server/src/modules/teams/service/team-authorisable.service.spec.ts @@ -0,0 +1,45 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { TeamsRepo } from '@shared/repo'; +import { setupEntities, teamFactory } from '@shared/testing'; +import { TeamAuthorisableService } from './team-authorisable.service'; + +describe('team authorisable service', () => { + let module: TestingModule; + let service: TeamAuthorisableService; + + let teamsRepo: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + TeamAuthorisableService, + { + provide: TeamsRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(TeamAuthorisableService); + teamsRepo = module.get(TeamsRepo); + }); + + it('should return entity', async () => { + const team = teamFactory.buildWithId(); + teamsRepo.findById.mockResolvedValue(team); + + const result = await service.findById(team.id); + expect(result).toEqual(team); + }); + + it('should call repo with populate', async () => { + const team = teamFactory.buildWithId(); + teamsRepo.findById.mockResolvedValue(team); + + await service.findById(team.id); + expect(teamsRepo.findById).toHaveBeenCalledWith(team.id, true); + }); +}); diff --git a/apps/server/src/modules/teams/service/team-authorisable.service.ts b/apps/server/src/modules/teams/service/team-authorisable.service.ts new file mode 100644 index 00000000000..a24973c8cc0 --- /dev/null +++ b/apps/server/src/modules/teams/service/team-authorisable.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { TeamEntity } from '@shared/domain/entity'; +import { TeamsRepo } from '@shared/repo'; +import { AuthorizationLoaderServiceGeneric } from '@src/modules/authorization'; + +@Injectable() +export class TeamAuthorisableService implements AuthorizationLoaderServiceGeneric { + constructor(private readonly teamsRepo: TeamsRepo) {} + + findById(id: string): Promise { + return this.teamsRepo.findById(id, true); + } +} diff --git a/apps/server/src/modules/teams/teams.module.ts b/apps/server/src/modules/teams/teams.module.ts index 3b96d986c0a..6e3ec526640 100644 --- a/apps/server/src/modules/teams/teams.module.ts +++ b/apps/server/src/modules/teams/teams.module.ts @@ -2,11 +2,11 @@ import { Module } from '@nestjs/common'; import { TeamsRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { CqrsModule } from '@nestjs/cqrs'; -import { TeamService } from './service'; +import { TeamAuthorisableService, TeamService } from './service'; @Module({ imports: [CqrsModule, LoggerModule], - providers: [TeamService, TeamsRepo], - exports: [TeamService], + providers: [TeamService, TeamsRepo, TeamAuthorisableService], + exports: [TeamService, TeamAuthorisableService], }) export class TeamsModule {} From 1a8dd2dbd7aba71f4593f9a6b03973671d19f1a9 Mon Sep 17 00:00:00 2001 From: mkreuzkam-cap <144103168+mkreuzkam-cap@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:48:30 +0200 Subject: [PATCH 25/29] EW-997: Create TspProvisioningStrategy and implement user/account (#5219) * School adjustments for externalId. * Implement TspProvisioningStrategy. * Add test for TspProvisioningStrategy. * Fix missing config in ProvisioningModule. * Add missing provider to test. * Code review. --------- Co-authored-by: Alexander Weber <103171324+alweber-cap@users.noreply.github.com> Co-authored-by: Firas Shmit --- .../provisioning/provisioning.module.ts | 4 + .../service/provisioning.service.spec.ts | 9 + .../service/provisioning.service.ts | 5 +- .../strategy/tsp/tsp.strategy.spec.ts | 344 ++++++++++++++++++ .../provisioning/strategy/tsp/tsp.strategy.ts | 149 ++++++++ .../school/domain/query/school-query.ts | 2 + .../mikro-orm/school.repo.integration.spec.ts | 7 +- .../school/repo/mikro-orm/school.repo.ts | 2 + .../repo/mikro-orm/scope/school.scope.ts | 12 + .../interface/system-provisioning.strategy.ts | 1 + 10 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts create mode 100644 apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts diff --git a/apps/server/src/modules/provisioning/provisioning.module.ts b/apps/server/src/modules/provisioning/provisioning.module.ts index 6d65266a0a6..6a35d00e4f4 100644 --- a/apps/server/src/modules/provisioning/provisioning.module.ts +++ b/apps/server/src/modules/provisioning/provisioning.module.ts @@ -3,6 +3,7 @@ import { GroupModule } from '@modules/group'; import { LearnroomModule } from '@modules/learnroom'; import { LegacySchoolModule } from '@modules/legacy-school'; import { RoleModule } from '@modules/role'; +import { SchoolModule } from '@modules/school'; import { SystemModule } from '@modules/system/system.module'; import { ExternalToolModule } from '@modules/tool'; import { SchoolExternalToolModule } from '@modules/tool/school-external-tool'; @@ -26,6 +27,7 @@ import { SchulconnexToolProvisioningService, SchulconnexUserProvisioningService, } from './strategy/oidc/service'; +import { TspProvisioningStrategy } from './strategy/tsp/tsp.strategy'; @Module({ imports: [ @@ -41,6 +43,7 @@ import { UserLicenseModule, ExternalToolModule, SchoolExternalToolModule, + SchoolModule, ], providers: [ ProvisioningService, @@ -54,6 +57,7 @@ import { SanisProvisioningStrategy, IservProvisioningStrategy, OidcMockProvisioningStrategy, + TspProvisioningStrategy, ], exports: [ProvisioningService], }) diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts index 8320e2ceb53..37790f6cadd 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts @@ -13,6 +13,7 @@ import { } from '../dto'; import { IservProvisioningStrategy, OidcMockProvisioningStrategy, SanisProvisioningStrategy } from '../strategy'; import { ProvisioningService } from './provisioning.service'; +import { TspProvisioningStrategy } from '../strategy/tsp/tsp.strategy'; describe('ProvisioningService', () => { let module: TestingModule; @@ -53,6 +54,14 @@ describe('ProvisioningService', () => { }, }), }, + { + provide: TspProvisioningStrategy, + useValue: createMock({ + getType(): SystemProvisioningStrategy { + return SystemProvisioningStrategy.TSP; + }, + }), + }, ], }).compile(); diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.ts b/apps/server/src/modules/provisioning/service/provisioning.service.ts index 3aefef535a3..ef059ff6620 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.ts @@ -9,6 +9,7 @@ import { ProvisioningStrategy, SanisProvisioningStrategy, } from '../strategy'; +import { TspProvisioningStrategy } from '../strategy/tsp/tsp.strategy'; @Injectable() export class ProvisioningService { @@ -21,11 +22,13 @@ export class ProvisioningService { private readonly systemService: SystemService, private readonly sanisStrategy: SanisProvisioningStrategy, private readonly iservStrategy: IservProvisioningStrategy, - private readonly oidcMockStrategy: OidcMockProvisioningStrategy + private readonly oidcMockStrategy: OidcMockProvisioningStrategy, + private readonly tspStrategy: TspProvisioningStrategy ) { this.registerStrategy(sanisStrategy); this.registerStrategy(iservStrategy); this.registerStrategy(oidcMockStrategy); + this.registerStrategy(tspStrategy); } protected registerStrategy(strategy: ProvisioningStrategy) { diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts new file mode 100644 index 00000000000..3a1fa061ed6 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts @@ -0,0 +1,344 @@ +import { faker } from '@faker-js/faker'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AccountService } from '@modules/account'; +import { accountDoFactory } from '@modules/account/testing'; +import { RoleService } from '@modules/role'; +import { SchoolService } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; +import { UserService } from '@modules/user'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RoleReference } from '@shared/domain/domainobject'; +import { RoleName } from '@shared/domain/interface'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { userDoFactory } from '@shared/testing'; +import { + ExternalSchoolDto, + ExternalUserDto, + OauthDataDto, + OauthDataStrategyInputDto, + ProvisioningSystemDto, +} from '../..'; +import { TspProvisioningStrategy } from './tsp.strategy'; + +describe('TspProvisioningStrategy', () => { + let module: TestingModule; + let sut: TspProvisioningStrategy; + + let schoolService: DeepMocked; + let userService: DeepMocked; + let roleService: DeepMocked; + let accountService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TspProvisioningStrategy, + { + provide: SchoolService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(TspProvisioningStrategy); + schoolService = module.get(SchoolService); + userService = module.get(UserService); + roleService = module.get(RoleService); + accountService = module.get(AccountService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + describe('getType', () => { + describe('When called', () => { + it('should return type TSP', () => { + const result: SystemProvisioningStrategy = sut.getType(); + + expect(result).toEqual(SystemProvisioningStrategy.TSP); + }); + }); + }); + + describe('getData', () => { + describe('When called', () => { + it('should throw', () => { + expect(() => sut.getData({} as OauthDataStrategyInputDto)).toThrow(); + }); + }); + }); + + describe('apply', () => { + describe('When user for given data does not exist', () => { + const setup = () => { + const school = schoolFactory.build(); + + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ + externalId: faker.string.uuid(), + roles: [RoleName.TEACHER], + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }), + externalSchool: new ExternalSchoolDto({ externalId: school.id, name: school.getInfo().name }), + }); + + schoolService.getSchools.mockResolvedValue([school]); + roleService.findByNames.mockResolvedValue([ + new RoleReference({ id: faker.string.uuid(), name: RoleName.TEACHER }), + ]); + userService.findByExternalId.mockResolvedValue(null); + accountService.findByUserId.mockResolvedValue(null); + + return { data }; + }; + + it('create user and account', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).resolves.not.toThrow(); + expect(userService.save).toBeCalledTimes(1); + expect(accountService.saveWithValidation).toBeCalledTimes(1); + }); + }); + + describe('When user for given data does exist', () => { + const setup = () => { + const school = schoolFactory.build(); + const user = userDoFactory.build({ + id: faker.string.uuid(), + }); + const account = accountDoFactory.build({ + userId: user.id, + }); + + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ + externalId: faker.string.uuid(), + roles: [RoleName.TEACHER], + email: user.email, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }), + externalSchool: new ExternalSchoolDto({ externalId: school.id, name: school.getInfo().name }), + }); + + schoolService.getSchools.mockResolvedValue([school]); + roleService.findByNames.mockResolvedValue([]); + userService.findByExternalId.mockResolvedValue(user); + userService.save.mockResolvedValue(user); + accountService.findByUserId.mockResolvedValue(account); + + return { data }; + }; + + it('update user and account', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).resolves.not.toThrow(); + expect(userService.save).toBeCalledTimes(1); + expect(accountService.saveWithValidation).toBeCalledTimes(1); + }); + }); + + describe('When external user does not have a firstName', () => { + const setup = () => { + const school = schoolFactory.build(); + + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ + externalId: faker.string.uuid(), + roles: [RoleName.TEACHER], + firstName: undefined, + }), + externalSchool: new ExternalSchoolDto({ externalId: school.id, name: school.getInfo().name }), + }); + + schoolService.getSchools.mockResolvedValue([school]); + roleService.findByNames.mockResolvedValue([]); + userService.findByExternalId.mockResolvedValue(null); + + return { data }; + }; + + it('should throw', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).rejects.toThrow(); + }); + }); + + describe('When external user does not have a lastname', () => { + const setup = () => { + const school = schoolFactory.build(); + + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ + externalId: faker.string.uuid(), + roles: [RoleName.TEACHER], + lastName: undefined, + }), + externalSchool: new ExternalSchoolDto({ externalId: school.id, name: school.getInfo().name }), + }); + + schoolService.getSchools.mockResolvedValue([school]); + roleService.findByNames.mockResolvedValue([]); + userService.findByExternalId.mockResolvedValue(null); + + return { data }; + }; + + it('should throw', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).rejects.toThrow(); + }); + }); + + describe('When external user does not have an email', () => { + const setup = () => { + const school = schoolFactory.build(); + + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ + externalId: faker.string.uuid(), + roles: [RoleName.TEACHER], + email: undefined, + }), + externalSchool: new ExternalSchoolDto({ externalId: school.id, name: school.getInfo().name }), + }); + + schoolService.getSchools.mockResolvedValue([school]); + roleService.findByNames.mockResolvedValue([]); + userService.findByExternalId.mockResolvedValue(null); + + return { data }; + }; + + it('should throw', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).rejects.toThrow(); + }); + }); + + describe('When user does not have an id after creation', () => { + const setup = () => { + const school = schoolFactory.build(); + const user = userDoFactory.build({ + id: undefined, + }); + + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ + externalId: faker.string.uuid(), + roles: [RoleName.TEACHER], + email: user.email, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }), + externalSchool: new ExternalSchoolDto({ externalId: school.id, name: school.getInfo().name }), + }); + + schoolService.getSchools.mockResolvedValue([school]); + roleService.findByNames.mockResolvedValue([]); + userService.findByExternalId.mockResolvedValue(user); + userService.save.mockResolvedValue(user); + + return { data }; + }; + + it('should throw', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).rejects.toThrow(); + }); + }); + + describe('When external school is not given', () => { + const setup = () => { + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ externalId: faker.string.uuid() }), + }); + + return { data }; + }; + + it('should throw error', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).rejects.toThrow(); + }); + }); + + describe('When external school does not exist', () => { + const setup = () => { + const data: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: faker.string.uuid(), + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + externalUser: new ExternalUserDto({ externalId: faker.string.uuid() }), + externalSchool: new ExternalSchoolDto({ externalId: faker.string.uuid(), name: faker.string.alpha() }), + }); + + schoolService.getSchools.mockResolvedValue([]); + + return { data }; + }; + + it('should throw error', async () => { + const { data } = setup(); + + await expect(sut.apply(data)).rejects.toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts new file mode 100644 index 00000000000..3e011bf0286 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts @@ -0,0 +1,149 @@ +import { AccountSave, AccountService } from '@modules/account'; +import { RoleService } from '@modules/role'; +import { School, SchoolService } from '@modules/school'; +import { UserService } from '@modules/user'; +import { Injectable, NotImplementedException, UnprocessableEntityException } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { RoleReference, UserDO } from '@shared/domain/domainobject'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ExternalUserDto, OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto } from '../../dto'; +import { ProvisioningStrategy } from '../base.strategy'; + +@Injectable() +export class TspProvisioningStrategy extends ProvisioningStrategy { + constructor( + private readonly schoolService: SchoolService, + private readonly userService: UserService, + private readonly roleService: RoleService, + private readonly accountService: AccountService + ) { + super(); + } + + getType(): SystemProvisioningStrategy { + return SystemProvisioningStrategy.TSP; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + override getData(input: OauthDataStrategyInputDto): Promise { + // TODO EW-1004 + throw new NotImplementedException(); + } + + override async apply(data: OauthDataDto): Promise { + const school = await this.findSchoolOrFail(data); + const user = await this.provisionUserAndAccount(data, school); + + // TODO EW-999: Create or update classes + + return new ProvisioningDto({ externalUserId: user.externalId || data.externalUser.externalId }); + } + + private async findSchoolOrFail(data: OauthDataDto): Promise { + if (!data.externalSchool) { + throw new UnprocessableEntityException( + `Unable to create new external user ${data.externalUser.externalId} without a school` + ); + } + + const school = await this.schoolService.getSchools({ + systemId: data.system.systemId, + externalId: data.externalSchool.externalId, + }); + + if (!school || school.length === 0) { + throw new NotFoundLoggableException(School.name, { + systemId: data.system.systemId, + externalId: data.externalSchool.externalId, + }); + } + + return school[0]; + } + + private async provisionUserAndAccount(data: OauthDataDto, school: School): Promise { + const existingUser = await this.userService.findByExternalId(data.externalUser.externalId, data.system.systemId); + const roleRefs = await this.getRoleReferencesForUser(data.externalUser); + + let user: UserDO; + if (existingUser) { + // TODO EW-999: Check school change + + user = await this.updateUser(existingUser, data.externalUser, roleRefs, school.id); + } else { + user = await this.createUser(data.externalUser, roleRefs, school.id); + } + + await this.ensureAccountExists(user, data.system.systemId); + + return user; + } + + private async getRoleReferencesForUser(externalUser: ExternalUserDto): Promise { + const rolesDtos = await this.roleService.findByNames(externalUser.roles || []); + const roleRefs = rolesDtos.map((role) => new RoleReference({ id: role.id || '', name: role.name })); + + return roleRefs; + } + + private async updateUser( + existingUser: UserDO, + externalUser: ExternalUserDto, + roleRefs: RoleReference[], + schoolId: string + ): Promise { + existingUser.roles = roleRefs; + existingUser.schoolId = schoolId; + existingUser.firstName = externalUser.firstName || existingUser.firstName; + existingUser.lastName = externalUser.lastName || existingUser.lastName; + existingUser.email = externalUser.email || existingUser.email; + existingUser.birthday = externalUser.birthday; + const updatedUser = await this.userService.save(existingUser); + + return updatedUser; + } + + private async createUser( + externalUser: ExternalUserDto, + roleRefs: RoleReference[], + schoolId: string + ): Promise { + if (!externalUser.firstName || !externalUser.lastName || !externalUser.email) { + throw new UnprocessableEntityException('Unable to create user without first name, last name or email'); + } + + const newUser = new UserDO({ + roles: roleRefs, + schoolId, + firstName: externalUser.firstName, + lastName: externalUser.lastName, + email: externalUser.email, + birthday: externalUser.birthday, + }); + const savedUser = await this.userService.save(newUser); + + return savedUser; + } + + private async ensureAccountExists(user: UserDO, systemId: string): Promise { + if (!user.id) { + throw new UnprocessableEntityException('Unable to create account for user which has no id'); + } + + const account = await this.accountService.findByUserId(user.id); + + if (!account) { + await this.accountService.saveWithValidation( + new AccountSave({ + userId: user.id, + username: user.email, + systemId, + activated: true, + }) + ); + } else { + account.username = user.email; + await this.accountService.saveWithValidation(account); + } + } +} diff --git a/apps/server/src/modules/school/domain/query/school-query.ts b/apps/server/src/modules/school/domain/query/school-query.ts index f3cc8756658..adc1499a59b 100644 --- a/apps/server/src/modules/school/domain/query/school-query.ts +++ b/apps/server/src/modules/school/domain/query/school-query.ts @@ -2,4 +2,6 @@ import { EntityId } from '@shared/domain/types/entity-id'; export interface SchoolQuery { federalStateId?: EntityId; + externalId?: string; + systemId?: EntityId; } diff --git a/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts b/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts index 6ae7e8ca94e..7a3c3e7ecc1 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts @@ -1,3 +1,4 @@ +import { faker } from '@faker-js/faker'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; @@ -66,14 +67,16 @@ describe('SchoolMikroOrmRepo', () => { describe('when query is given', () => { const setup = async () => { const federalState = federalStateFactory.build(); - const entity1 = schoolEntityFactory.build({ federalState }); + const externalId = faker.string.uuid(); + const systems = systemEntityFactory.buildList(1); + const entity1 = schoolEntityFactory.build({ federalState, externalId, systems }); const entity2 = schoolEntityFactory.build(); await em.persistAndFlush([entity1, entity2]); em.clear(); const schoolDo1 = SchoolEntityMapper.mapToDo(entity1); const schoolDo2 = SchoolEntityMapper.mapToDo(entity2); - const query = { federalStateId: federalState.id }; + const query = { federalStateId: federalState.id, externalId, systemId: systems[0].id }; return { schoolDo1, schoolDo2, query }; }; diff --git a/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts b/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts index 2f7a7a63910..f158b9d22b3 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts @@ -19,6 +19,8 @@ export class SchoolMikroOrmRepo extends BaseDomainObjectRepo { this.addQuery({ federalState: federalStateId }); } } + + byExternalId(externalId?: string) { + if (externalId) { + this.addQuery({ externalId }); + } + } + + bySystemId(systemId?: EntityId) { + if (systemId) { + this.addQuery({ systems: { $in: [systemId] } }); + } + } } diff --git a/apps/server/src/shared/domain/interface/system-provisioning.strategy.ts b/apps/server/src/shared/domain/interface/system-provisioning.strategy.ts index 77343b33773..5b7e8de58de 100644 --- a/apps/server/src/shared/domain/interface/system-provisioning.strategy.ts +++ b/apps/server/src/shared/domain/interface/system-provisioning.strategy.ts @@ -2,5 +2,6 @@ export enum SystemProvisioningStrategy { SANIS = 'sanis', ISERV = 'iserv', OIDC = 'oidc', + TSP = 'tsp', UNDEFINED = 'undefined', } From c7708ca82b791f97c768a795acbd8b855fc37517 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Mon, 9 Sep 2024 13:47:25 +0200 Subject: [PATCH 26/29] BC-7881 - recreate tldraw migration job if necessary (#5229) Co-authored-by: Phillip Wirth --- .../roles/schulcloud-server-core/tasks/main.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index ffb6e8ad0da..10f7c353ab4 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -175,12 +175,25 @@ tags: - ingress - - name: tldaraw migration Job + + - 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 - state: "{{ 'present' if WITH_TLDRAW2 else 'absent'}}" + when: WITH_TLDRAW2 is defined and WITH_TLDRAW2|bool tags: - job From 2490c168e1bc71eaed2543ee67d0421be3403568 Mon Sep 17 00:00:00 2001 From: Uwe Ilgenstein Date: Tue, 10 Sep 2024 17:13:12 +0200 Subject: [PATCH 27/29] BC-8063 - Remove excess Method from BoardNode (#5232) --- apps/server/src/modules/board/domain/board-node.do.ts | 7 ------- .../src/modules/board/service/board-node.service.spec.ts | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/server/src/modules/board/domain/board-node.do.ts b/apps/server/src/modules/board/domain/board-node.do.ts index 1a7a74bbff0..0231bcbca4c 100644 --- a/apps/server/src/modules/board/domain/board-node.do.ts +++ b/apps/server/src/modules/board/domain/board-node.do.ts @@ -21,17 +21,10 @@ export abstract class BoardNode extends DomainObject { const parentNode = cardFactory.build(); parentNode.addChild(oldNode); - boardNodeRepo.findById.mockResolvedValueOnce(new Card({ ...parentNode.getTrueProps() })); + boardNodeRepo.findById.mockResolvedValueOnce(new Card({ ...parentNode.getProps() })); return { parentNode, @@ -179,7 +179,7 @@ describe(BoardNodeService.name, () => { await service.replace(oldNode, newNode); expect(boardNodeRepo.save).toHaveBeenCalledWith( - new Card({ ...parentNode.getTrueProps(), children: [oldNode, newNode] }) + new Card({ ...parentNode.getProps(), children: [oldNode, newNode] }) ); }); From 09381861beffc28954693574b620b4f2f6c2fd7b Mon Sep 17 00:00:00 2001 From: Simone Radtke <94017602+SimoneRadtke-Cap@users.noreply.github.com> Date: Wed, 11 Sep 2024 09:28:26 +0200 Subject: [PATCH 28/29] EW-1010 Correction of keycloak test credentials (#5231) --- config/test.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/test.json b/config/test.json index 3789f2633c5..cf057909d3d 100644 --- a/config/test.json +++ b/config/test.json @@ -35,11 +35,11 @@ "IDENTITY_MANAGEMENT": { "INTERNAL_URI": "http://localhost:8080", "EXTERNAL_URI": "http://localhost:8080", - "TENANT": "master", + "TENANT": "dBildungscloud", "CLIENTID": "dbc", "ADMIN_CLIENTID": "admin-cli", - "ADMIN_USER": "keycloak", - "ADMIN_PASSWORD": "keycloak" + "ADMIN_USER": "dbildungscloud", + "ADMIN_PASSWORD": "dBildungscloud" }, "NEST_LOG_LEVEL": "error", "CALENDAR_URI": "https://schul.tech:3000", From ca17bee1b2faba86fd8aa2c28c603f0bb1b7930b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:02:34 +0200 Subject: [PATCH 29/29] N21-2180 Fix date for synchronized courses (#5233) --- .../controller/api-test/group.api.spec.ts | 20 +++++++++++++++++++ .../controller/dto/response/group.response.ts | 5 +++++ .../dto/response/period.response.ts | 14 +++++++++++++ .../mapper/group-response.mapper.ts | 4 ++++ .../src/modules/group/domain/group-period.ts | 10 ++++++++++ apps/server/src/modules/group/domain/group.ts | 13 ++++-------- apps/server/src/modules/group/domain/index.ts | 1 + .../modules/group/repo/group-domain.mapper.ts | 13 ++++++------ .../src/modules/group/repo/group.repo.spec.ts | 6 ++---- .../group/uc/dto/resolved-group.dto.ts | 5 ++++- .../group/uc/mapper/group-uc.mapper.ts | 1 + .../schulconnex-course-sync.service.spec.ts | 16 +++++++-------- .../schulconnex-course-sync.service.ts | 4 ++-- ...lconnex-group-provisioning.service.spec.ts | 12 ++++------- .../schulconnex-group-provisioning.service.ts | 8 +++++--- .../domainobject/groups/group.factory.ts | 7 +++++-- 16 files changed, 96 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/modules/group/controller/dto/response/period.response.ts create mode 100644 apps/server/src/modules/group/domain/group-period.ts diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts index 7dc64b745c8..dfe8de3b430 100644 --- a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -195,6 +195,10 @@ describe('Group (API)', () => { role: teacherUser.roles[0].name, }, ], + validPeriod: { + from: group.validPeriod?.from.toISOString(), + until: group.validPeriod?.until.toISOString(), + }, externalSource: { externalId: group.externalSource?.externalId, systemId: group.externalSource?.system.id, @@ -319,6 +323,10 @@ describe('Group (API)', () => { role: adminUser.roles[0].name, }, ], + validPeriod: { + from: availableGroupInSchool.validPeriod?.from.toISOString(), + until: availableGroupInSchool.validPeriod?.until.toISOString(), + }, externalSource: { externalId: availableGroupInSchool.externalSource?.externalId, systemId: availableGroupInSchool.externalSource?.system.id, @@ -372,6 +380,10 @@ describe('Group (API)', () => { role: adminUser.roles[0].name, }, ], + validPeriod: { + from: groupInSchool.validPeriod?.from.toISOString(), + until: groupInSchool.validPeriod?.until.toISOString(), + }, externalSource: { externalId: groupInSchool.externalSource?.externalId, systemId: groupInSchool.externalSource?.system.id, @@ -492,6 +504,10 @@ describe('Group (API)', () => { role: teacherUser.roles[0].name, }, ], + validPeriod: { + from: availableTeachersGroup.validPeriod?.from.toISOString(), + until: availableTeachersGroup.validPeriod?.until.toISOString(), + }, externalSource: { externalId: availableTeachersGroup.externalSource?.externalId, systemId: availableTeachersGroup.externalSource?.system.id, @@ -544,6 +560,10 @@ describe('Group (API)', () => { role: teacherUser.roles[0].name, }, ], + validPeriod: { + from: teachersGroup.validPeriod?.from.toISOString(), + until: teachersGroup.validPeriod?.until.toISOString(), + }, externalSource: { externalId: teachersGroup.externalSource?.externalId, systemId: teachersGroup.externalSource?.system.id, diff --git a/apps/server/src/modules/group/controller/dto/response/group.response.ts b/apps/server/src/modules/group/controller/dto/response/group.response.ts index 1abb28a8a30..6cbf8b09c9a 100644 --- a/apps/server/src/modules/group/controller/dto/response/group.response.ts +++ b/apps/server/src/modules/group/controller/dto/response/group.response.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ExternalSourceResponse } from './external-source.response'; import { GroupTypeResponse } from './group-type.response'; import { GroupUserResponse } from './group-user.response'; +import { PeriodResponse } from './period.response'; export class GroupResponse { @ApiProperty() @@ -19,6 +20,9 @@ export class GroupResponse { @ApiPropertyOptional() externalSource?: ExternalSourceResponse; + @ApiPropertyOptional() + validPeriod?: PeriodResponse; + @ApiPropertyOptional() organizationId?: string; @@ -28,6 +32,7 @@ export class GroupResponse { this.type = group.type; this.users = group.users; this.externalSource = group.externalSource; + this.validPeriod = group.validPeriod; this.organizationId = group.organizationId; } } diff --git a/apps/server/src/modules/group/controller/dto/response/period.response.ts b/apps/server/src/modules/group/controller/dto/response/period.response.ts new file mode 100644 index 00000000000..b6b3f0d1c83 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/period.response.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PeriodResponse { + @ApiProperty() + from: Date; + + @ApiProperty() + until: Date; + + constructor(props: PeriodResponse) { + this.from = props.from; + this.until = props.until; + } +} diff --git a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts index ffb001e2f83..d0fd7081e20 100644 --- a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -12,6 +12,7 @@ import { GroupUserResponse, } from '../dto'; import { CourseInfoResponse } from '../dto/response/course-info.response'; +import { PeriodResponse } from '../dto/response/period.response'; const typeMapping: Record = { [GroupTypes.CLASS]: GroupTypeResponse.CLASS, @@ -81,6 +82,9 @@ export class GroupResponseMapper { type: typeMapping[resolvedGroup.type], externalSource, users, + validPeriod: resolvedGroup.validPeriod + ? new PeriodResponse({ from: resolvedGroup.validPeriod.from, until: resolvedGroup.validPeriod.until }) + : undefined, organizationId: resolvedGroup.organizationId, }); diff --git a/apps/server/src/modules/group/domain/group-period.ts b/apps/server/src/modules/group/domain/group-period.ts new file mode 100644 index 00000000000..a1156c6a13e --- /dev/null +++ b/apps/server/src/modules/group/domain/group-period.ts @@ -0,0 +1,10 @@ +export class GroupPeriod { + from: Date; + + until: Date; + + constructor(props: GroupPeriod) { + this.from = props.from; + this.until = props.until; + } +} diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 9cf0603a2bd..9aa294242da 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -1,6 +1,7 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { ExternalSource, type UserDO } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; +import { GroupPeriod } from './group-period'; import { GroupTypes } from './group-types'; import { GroupUser } from './group-user'; @@ -11,9 +12,7 @@ export interface GroupProps extends AuthorizableObject { type: GroupTypes; - validFrom?: Date; - - validUntil?: Date; + validPeriod?: GroupPeriod; externalSource?: ExternalSource; @@ -47,12 +46,8 @@ export class Group extends DomainObject { return this.props.type; } - get validFrom(): Date | undefined { - return this.props.validFrom; - } - - get validUntil(): Date | undefined { - return this.props.validUntil; + get validPeriod(): GroupPeriod | undefined { + return this.props.validPeriod; } removeUser(user: UserDO): void { diff --git a/apps/server/src/modules/group/domain/index.ts b/apps/server/src/modules/group/domain/index.ts index 713cae7ac17..adf5f691661 100644 --- a/apps/server/src/modules/group/domain/index.ts +++ b/apps/server/src/modules/group/domain/index.ts @@ -1,5 +1,6 @@ export * from './group'; export * from './group-user'; +export { GroupPeriod } from './group-period'; export * from './group-types'; export { GroupDeletedEvent } from './event'; export { GroupFilter } from './interface'; diff --git a/apps/server/src/modules/group/repo/group-domain.mapper.ts b/apps/server/src/modules/group/repo/group-domain.mapper.ts index affe2ec6343..2a8247dccbf 100644 --- a/apps/server/src/modules/group/repo/group-domain.mapper.ts +++ b/apps/server/src/modules/group/repo/group-domain.mapper.ts @@ -3,7 +3,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ExternalSourceEmbeddable, SystemEntity } from '@modules/system/entity'; import { ExternalSource } from '@shared/domain/domainobject'; import { Role, SchoolEntity, User } from '@shared/domain/entity'; -import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; +import { Group, GroupPeriod, GroupProps, GroupTypes, GroupUser } from '../domain'; import { GroupEntity, GroupEntityTypes, GroupUserEmbeddable, GroupValidPeriodEmbeddable } from '../entity'; const GroupEntityTypesToGroupTypesMapping: Record = { @@ -23,10 +23,10 @@ export class GroupDomainMapper { const props: GroupProps = group.getProps(); let validPeriod: GroupValidPeriodEmbeddable | undefined; - if (props.validFrom && props.validUntil) { + if (props.validPeriod) { validPeriod = new GroupValidPeriodEmbeddable({ - from: props.validFrom, - until: props.validUntil, + from: props.validPeriod.from, + until: props.validPeriod.until, }); } @@ -50,8 +50,9 @@ export class GroupDomainMapper { const group: Group = new Group({ id: entity.id, users: entity.users.map((groupUser): GroupUser => this.mapGroupUserEntityToGroupUser(groupUser)), - validFrom: entity.validPeriod ? entity.validPeriod.from : undefined, - validUntil: entity.validPeriod ? entity.validPeriod.until : undefined, + validPeriod: entity.validPeriod + ? new GroupPeriod({ from: entity.validPeriod.from, until: entity.validPeriod.until }) + : undefined, externalSource: entity.externalSource ? this.mapExternalSourceEntityToExternalSource(entity.externalSource) : undefined, diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index 57a1b9ca1b4..0edee200fc4 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -81,8 +81,7 @@ describe('GroupRepo', () => { }), ], organizationId: group.organization?.id, - validFrom: group.validPeriod?.from, - validUntil: group.validPeriod?.until, + validPeriod: group.validPeriod, }); }); }); @@ -690,8 +689,7 @@ describe('GroupRepo', () => { }), ], organizationId: groupEntity.organization?.id, - validFrom: groupEntity.validPeriod?.from, - validUntil: groupEntity.validPeriod?.until, + validPeriod: groupEntity.validPeriod, }); }); }); diff --git a/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts b/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts index b8b2b8ecec8..67152539075 100644 --- a/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts +++ b/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts @@ -1,5 +1,5 @@ import { ExternalSource } from '@shared/domain/domainobject'; -import { GroupTypes } from '../../domain'; +import { GroupPeriod, GroupTypes } from '../../domain'; import { ResolvedGroupUser } from './resolved-group-user'; export class ResolvedGroupDto { @@ -15,6 +15,8 @@ export class ResolvedGroupDto { organizationId?: string; + validPeriod?: GroupPeriod; + constructor(group: ResolvedGroupDto) { this.id = group.id; this.name = group.name; @@ -22,5 +24,6 @@ export class ResolvedGroupDto { this.users = group.users; this.externalSource = group.externalSource; this.organizationId = group.organizationId; + this.validPeriod = group.validPeriod; } } diff --git a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts index d0da50c17eb..aa2ba4d8b93 100644 --- a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts +++ b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts @@ -63,6 +63,7 @@ export class GroupUcMapper { externalSource: group.externalSource, users: resolvedGroupUsers, organizationId: group.organizationId, + validPeriod: group.validPeriod, }); return mapped; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts index 28b40ed5b8b..b83a48f9d27 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts @@ -88,8 +88,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: newGroup.name, - startDate: newGroup.validFrom, - untilDate: newGroup.validUntil, + startDate: newGroup.validPeriod?.from, + untilDate: newGroup.validPeriod?.until, studentIds: [studentId], teacherIds: [teacherId], }), @@ -128,8 +128,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: newGroup.name, - startDate: newGroup.validFrom, - untilDate: newGroup.validUntil, + startDate: newGroup.validPeriod?.from, + untilDate: newGroup.validPeriod?.until, studentIds: [], teacherIds: [], }), @@ -168,8 +168,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: course.name, - startDate: newGroup.validFrom, - untilDate: newGroup.validUntil, + startDate: newGroup.validPeriod?.from, + untilDate: newGroup.validPeriod?.until, studentIds: [], teacherIds: [], }), @@ -218,8 +218,8 @@ describe(SchulconnexCourseSyncService.name, () => { new Course({ ...course.getProps(), name: course.name, - startDate: newGroup.validFrom, - untilDate: newGroup.validUntil, + startDate: newGroup.validPeriod?.from, + untilDate: newGroup.validPeriod?.until, studentIds: [], teacherIds: [teacherUserId], syncedWithGroup: course.syncedWithGroup, diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts index e7be31cf427..c62fe52cb81 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts @@ -22,8 +22,8 @@ export class SchulconnexCourseSyncService { course.name = newGroup.name; } - course.startDate = newGroup.validFrom; - course.untilDate = newGroup.validUntil; + course.startDate = newGroup.validPeriod?.from; + course.untilDate = newGroup.validPeriod?.until; const students: GroupUser[] = newGroup.users.filter( (user: GroupUser): boolean => user.roleId === studentRole.id diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts index 0ebee724910..f5d48e48d4f 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts @@ -461,8 +461,7 @@ describe(SchulconnexGroupProvisioningService.name, () => { }, type: externalGroupDto.type, organizationId: school.id, - validFrom: externalGroupDto.from, - validUntil: externalGroupDto.until, + validPeriod: { from: externalGroupDto.from, until: externalGroupDto.until }, users: [ { userId: student.id, @@ -497,8 +496,7 @@ describe(SchulconnexGroupProvisioningService.name, () => { }, type: externalGroupDto.type, organizationId: school.id, - validFrom: externalGroupDto.from, - validUntil: externalGroupDto.until, + validPeriod: { from: externalGroupDto.from, until: externalGroupDto.until }, users: [ { userId: student.id, @@ -566,8 +564,7 @@ describe(SchulconnexGroupProvisioningService.name, () => { }, type: externalGroupDto.type, organizationId: undefined, - validFrom: externalGroupDto.from, - validUntil: externalGroupDto.until, + validPeriod: { from: externalGroupDto.from, until: externalGroupDto.until }, users: [ { userId: teacher.id, @@ -636,8 +633,7 @@ describe(SchulconnexGroupProvisioningService.name, () => { }, type: externalGroupDto.type, organizationId: undefined, - validFrom: externalGroupDto.from, - validUntil: externalGroupDto.until, + validPeriod: { from: externalGroupDto.from, until: externalGroupDto.until }, users: [ { userId: student.id, diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts index 0b05675b3cc..7d26dedcfe6 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts @@ -1,5 +1,5 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { Group, GroupFilter, GroupService, GroupTypes, GroupUser } from '@modules/group'; +import { Group, GroupFilter, GroupPeriod, GroupService, GroupTypes, GroupUser } from '@modules/group'; import { CourseDoService } from '@modules/learnroom'; import { Course } from '@modules/learnroom/domain'; import { @@ -110,8 +110,10 @@ export class SchulconnexGroupProvisioningService { }), type: externalGroup.type, organizationId, - validFrom: externalGroup.from, - validUntil: externalGroup.until, + validPeriod: + externalGroup.from && externalGroup.until + ? new GroupPeriod({ from: externalGroup.from, until: externalGroup.until }) + : undefined, users: existingGroup?.users ?? [], }); diff --git a/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts index 6d0ed928090..0a2a000ac31 100644 --- a/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts @@ -1,5 +1,6 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Group, GroupProps, GroupTypes } from '@modules/group/domain'; +import { GroupPeriod } from '@modules/group/domain/group-period'; import { ExternalSource } from '@shared/domain/domainobject'; import { DomainObjectFactory } from '../domain-object.factory'; @@ -14,8 +15,10 @@ export const groupFactory = DomainObjectFactory.define(Group, roleId: new ObjectId().toHexString(), }, ], - validFrom: new Date(2023, 1), - validUntil: new Date(2023, 6), + validPeriod: new GroupPeriod({ + from: new Date(2023, 1), + until: new Date(2023, 6), + }), organizationId: new ObjectId().toHexString(), externalSource: new ExternalSource({ externalId: `externalId-${sequence}`,