diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 7fb3b5f52dd..cb2ad5bba9e 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -8,6 +8,9 @@ metadata: data: update.sh: | #! /bin/bash + {% if KEDA_NAMESPACE_ACTIVATOR_ENABLED is defined %} + curl -XPUT -H 'Content-Type: application/json' -L 'http://ns-activator-svc.sc-common.svc.cluster.local:8080/namespace' -d '{"name" : "{{ NAMESPACE }}"}' + {% endif %} # necessary for secret handling and legacy indexes git clone https://github.com/hpi-schul-cloud/schulcloud-server.git cd /schulcloud-server @@ -25,9 +28,6 @@ data: else echo "gg, hacky mongo replicaset" fi - {% if KEDA_NAMESPACE_ACTIVATOR_ENABLED is defined %} - curl -XPUT -H 'Content-Type: application/json' -L 'http://ns-activator-svc.sc-common.svc.cluster.local:8080/namespace' -d '{"name" : "{{ NAMESPACE }}"}' - {% endif %} echo "seeding database" curl --retry 360 --retry-all-errors --retry-delay 10 -X POST 'http://mgmt-svc:3333/api/management/database/seed?with-indexes=true' @@ -68,7 +68,7 @@ data: }, "roleAttributeNameMapping" : { "roleStudent" : "cn=ROLE_STUDENT,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", - "roleTeacher" : "cn=ROLE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", + "roleTeacher": "cn=ROLE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org;;cn=ROLE_SUBSTITUTE_TEACHER,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", "roleAdmin" : "cn=ROLE_ADMIN,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org", "roleNoSc" : "cn=ROLE_NBC_EXCLUDE,ou=roles,o=schoolOne0,dc=de,dc=example,dc=org" }, @@ -116,7 +116,7 @@ data: }, "roleAttributeNameMapping" : { "roleStudent" : "cn=ROLE_STUDENT,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", - "roleTeacher" : "cn=ROLE_TEACHER,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", + "roleTeacher": "cn=ROLE_TEACHER,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org;;cn=ROLE_SUBSTITUTE_TEACHER,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", "roleAdmin" : "cn=ROLE_ADMIN,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org", "roleNoSc" : "cn=ROLE_NBC_EXCLUDE,ou=roles,o=schoolOne1,dc=de,dc=example,dc=org" }, @@ -183,7 +183,7 @@ data: "grantType": "authorization_code", "scope": "openid", "responseType": "code", - "redirectUri": "https://{{ NAMESPACE }}.cd.dbildungscloud.dev/api/v3/sso/oauth", + "redirectUri": "https://{{ NAMESPACE }}.nbc.dbildungscloud.dev/api/v3/sso/oauth", "authEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/auth", "provider": "sanis", "jwksEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/certs", @@ -198,15 +198,15 @@ data: # ========== Start of the Dev IServ configuration section. - # This is currently performed only for the following 2 namespaces: - # - 'nbc-main', - # - 'nbc-iserv-test'; + # This is currently performed only for the following 2 namespaces on *dev-nbc*: + # - 'main', + # - 'iserv-test'; # the first one being the namespace of the default nbc dev environment and the second one being # the additional namespace intended for use for the testing (and development) purposes if one want # to test anything that includes signing in with the IServ on nbc instance, but don't want to use # the default dev nbc instance as it would require merging the code to the main branch first. - if [[ "$NS" =~ ^(nbc-main|nbc-iserv-test)$ ]]; then + if [ "$SC_THEME" = "n21" ] && [[ "$NS" =~ ^(main|iserv-test)$ ]]; then ISERV_SYSTEM_ID=0000d186816abba584714c92 # Encrypt secrets that contain IServ's OAuth client secret and LDAP server's search user password. @@ -245,7 +245,7 @@ data: "grantType": "authorization_code", "scope": "openid uuid", "responseType": "code", - "redirectUri": "https://'$NS'.cd.dbildungscloud.dev/api/v3/sso/oauth", + "redirectUri": "https://'$NS'.nbc.dbildungscloud.dev/api/v3/sso/oauth", "authEndpoint": "'$ISERV_URL'/iserv/auth/auth", "provider": "iserv", "logoutEndpoint": "'$ISERV_URL'/iserv/auth/logout", @@ -265,7 +265,7 @@ data: # This is currently performed for any 'brb-*' namespace ('brb-main' for example). - if [[ "$NS" =~ ^brb-[^\s]+$ ]]; then + if [ "$SC_THEME" = "brb" ]; then UNIVENTION_LDAP_SYSTEM_ID=621beef78ec63ea12a3adae6 UNIVENTION_LDAP_FEDERAL_STATE_ID=0000b186816abba584714c53 @@ -306,11 +306,9 @@ data: # ========== Start of the Bettermarks tool configuration section. - # This is currently performed only for the following 4 namespaces: - # - 'nbc-bettermarks-test', - # - 'nbc-main', - # - 'brb-bettermarks-test', - # - 'brb-main'; + # This is currently performed only for the following namespaces on dev for each tenant nbc and brb: + # - 'bettermarks-test' + # - 'main' # the first two being the testing environments for the nbc instances # and the last two being the testing environments for the brb instances. @@ -319,16 +317,16 @@ data: if [ -n "$NS" ]; then # Set the BETTERMARKS_CLIENT_SECRET and BETTERMARKS_URL variables values according to the k8s namespace. - if [ "$NS" = "nbc-bettermarks-test" ]; then + if [ "$SC_THEME" = "n21" ] && [ "$NS" = "bettermarks-test" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_NBC_BETTERMARKS_TEST_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_NBC_BETTERMARKS_TEST_ENTRYPOINT - elif [ "$NS" = "nbc-main" ]; then + elif [ "$SC_THEME" = "n21" ] && [ "$NS" = "main" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_NBC_MAIN_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_NBC_MAIN_ENTRYPOINT - elif [ "$NS" = "brb-bettermarks-test" ]; then + elif [ "$SC_THEME" = "brb" ] && [ "$NS" = "bettermarks-test" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_BRB_BETTERMARKS_TEST_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_BRB_BETTERMARKS_TEST_ENTRYPOINT - elif [ "$NS" = "brb-main" ]; then + elif [ "$SC_THEME" = "brb" ] && [ "$NS" = "main" ]; then BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_BRB_MAIN_CLIENT_SECRET BETTERMARKS_URL=$BETTERMARKS_BRB_MAIN_ENTRYPOINT else diff --git a/apps/server/src/infra/preview-generator/interface/error-status.enum.ts b/apps/server/src/infra/preview-generator/interface/error-status.enum.ts new file mode 100644 index 00000000000..0f018ba720f --- /dev/null +++ b/apps/server/src/infra/preview-generator/interface/error-status.enum.ts @@ -0,0 +1,3 @@ +export enum ErrorType { + CREATE_PREVIEW_NOT_POSSIBLE = 'CREATE_PREVIEW_NOT_POSSIBLE', +} diff --git a/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts b/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts new file mode 100644 index 00000000000..708607b3556 --- /dev/null +++ b/apps/server/src/infra/preview-generator/loggable/preview-exception.spec.ts @@ -0,0 +1,40 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { PreviewNotPossibleException } from './preview-exception'; + +describe(PreviewNotPossibleException.name, () => { + describe('WHEN getLogMessage is called', () => { + const setup = () => { + const payload = { + originFilePath: 'originFilePath', + previewFilePath: 'previewFilePath', + previewOptions: { + format: 'format', + width: 100, + }, + }; + const error = new Error('error'); + + return { payload, error }; + }; + + it('should return error log message', () => { + const { payload, error } = setup(); + + const exception = new PreviewNotPossibleException(payload, error); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: InternalServerErrorException.name, + stack: exception.stack, + error, + data: { + originFilePath: 'originFilePath', + previewFilePath: 'previewFilePath', + format: 'format', + width: 100, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/preview-generator/loggable/preview-exception.ts b/apps/server/src/infra/preview-generator/loggable/preview-exception.ts new file mode 100644 index 00000000000..fcfd1c023dc --- /dev/null +++ b/apps/server/src/infra/preview-generator/loggable/preview-exception.ts @@ -0,0 +1,27 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; +import { PreviewFileOptions } from '../interface'; +import { ErrorType } from '../interface/error-status.enum'; + +export class PreviewNotPossibleException extends InternalServerErrorException implements Loggable { + constructor(private readonly payload: PreviewFileOptions, private readonly error?: Error) { + super(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + } + + getLogMessage(): ErrorLogMessage { + const { originFilePath, previewFilePath, previewOptions } = this.payload; + const message: ErrorLogMessage = { + type: InternalServerErrorException.name, + stack: this.stack, + error: this.error, + data: { + originFilePath, + previewFilePath, + format: previewOptions.format, + width: previewOptions.width, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts index 203e4aa9b56..eb8344e22f2 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts @@ -1,12 +1,14 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { GetFile, S3ClientAdapter } from '@infra/s3-client'; -import { UnprocessableEntityException } from '@nestjs/common'; +import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@src/core/logger'; -import { Readable } from 'node:stream'; +import { PassThrough, Readable } from 'node:stream'; +import { ErrorType } from './interface/error-status.enum'; import { PreviewGeneratorService } from './preview-generator.service'; -const streamMock = jest.fn(); +let streamMock = jest.fn(); const resizeMock = jest.fn(); const coalesceMock = jest.fn(); const selectFrameMock = jest.fn(); @@ -16,7 +18,7 @@ const imageMagickMock = () => { resize: resizeMock, selectFrame: selectFrameMock, coalesce: coalesceMock, - data: Readable.from('text'), + data: Buffer.from('text'), }; }; jest.mock('gm', () => { @@ -40,6 +42,21 @@ const createFile = (contentRange?: string, contentType?: string): GetFile => { return fileResponse; }; +const createMockStream = (err: Error | null = null) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + + streamMock = jest + .fn() + .mockImplementation( + (_format: string, callback: (err: Error | null, stdout: PassThrough, stderr: PassThrough) => void) => { + callback(err, stdout, stderr); + } + ); + + return { stdout, stderr }; +}; + describe('PreviewGeneratorService', () => { let module: TestingModule; let service: PreviewGeneratorService; @@ -92,15 +109,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/jpeg'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call storageClient get method with originFilePath', async () => { @@ -125,12 +141,18 @@ describe('PreviewGeneratorService', () => { await service.generatePreview(params); - expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format); + expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format, expect.any(Function)); expect(streamMock).toHaveBeenCalledTimes(1); }); it('should call S3ClientAdapters create method', async () => { - const { params, expectedFileData } = setup(); + const { params } = setup(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const expectedFileData = expect.objectContaining({ + data: expect.any(PassThrough), + mimeType: params.previewOptions.format, + }); await service.generatePreview(params); @@ -161,15 +183,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'application/pdf'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call imagemagicks selectFrameMock method', async () => { @@ -195,15 +216,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/gif'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should call imagemagicks coalesce method', async () => { @@ -237,7 +257,7 @@ describe('PreviewGeneratorService', () => { it('should throw UnprocessableEntityException', async () => { const { params } = setup(); - const error = new UnprocessableEntityException(); + const error = new UnprocessableEntityException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); await expect(service.generatePreview(params)).rejects.toThrowError(error); }); }); @@ -246,7 +266,7 @@ describe('PreviewGeneratorService', () => { it('should throw UnprocessableEntityException', async () => { const { params } = setup('text/plain'); - const error = new UnprocessableEntityException(); + const error = new UnprocessableEntityException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); await expect(service.generatePreview(params)).rejects.toThrowError(error); }); }); @@ -266,15 +286,14 @@ describe('PreviewGeneratorService', () => { const originFile = createFile(undefined, 'image/jpeg'); s3ClientAdapter.get.mockResolvedValueOnce(originFile); - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); + const data = Buffer.from('text'); + const { stdout } = createMockStream(); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + process.nextTick(() => { + stdout.write(data); + }); - return { params, originFile, expectedFileData }; + return { params, originFile }; }; it('should not call imagemagicks resize method', async () => { @@ -286,5 +305,75 @@ describe('PreviewGeneratorService', () => { expect(resizeMock).not.toHaveBeenCalledTimes(1); }); }); + + describe('WHEN STDERR stream has an error', () => { + const setup = () => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + const data1 = Buffer.from('imagemagick '); + const data2 = Buffer.from('is not found'); + const { stderr } = createMockStream(); + + process.nextTick(() => { + stderr.write(data1); + stderr.write(data2); + stderr.end(); + }); + + const expectedError = new InternalServerErrorException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + + return { params, originFile, expectedError }; + }; + + it('should throw error', async () => { + const { params, expectedError } = setup(); + + await expect(service.generatePreview(params)).rejects.toThrowError(expectedError); + }); + + it('should have external error in getLogMessage', async () => { + const { params } = setup(); + try { + await service.generatePreview(params); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(error.getLogMessage().error).toEqual(new Error('imagemagick is not found')); + } + }); + }); + + describe('WHEN GM library has an error', () => { + const setup = () => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + createMockStream(new Error('imagemagic is not found')); + + const expectedError = new InternalServerErrorException(ErrorType.CREATE_PREVIEW_NOT_POSSIBLE); + + return { params, originFile, expectedError }; + }; + + it('should throw error', async () => { + const { params, expectedError } = setup(); + + await expect(service.generatePreview(params)).rejects.toThrowError(expectedError); + }); + }); }); }); diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.ts b/apps/server/src/infra/preview-generator/preview-generator.service.ts index 5fd9fc8fb5e..d16cc5c8e0d 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.ts @@ -1,10 +1,11 @@ import { GetFile, S3ClientAdapter } from '@infra/s3-client'; import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Logger } from '@src/core/logger'; -import { subClass } from 'gm'; +import m, { subClass } from 'gm'; import { PassThrough } from 'stream'; import { PreviewFileOptions, PreviewInputMimeTypes, PreviewOptions, PreviewResponseMessage } from './interface'; import { PreviewActionsLoggable } from './loggable/preview-actions.loggable'; +import { PreviewNotPossibleException } from './loggable/preview-exception'; import { PreviewGeneratorBuilder } from './preview-generator.builder'; @Injectable() @@ -16,25 +17,28 @@ export class PreviewGeneratorService { } public async generatePreview(params: PreviewFileOptions): Promise { - this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:start', params)); - const { originFilePath, previewFilePath, previewOptions } = params; + try { + this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:start', params)); + const { originFilePath, previewFilePath, previewOptions } = params; - const original = await this.downloadOriginFile(originFilePath); + const original = await this.downloadOriginFile(originFilePath); - this.checkIfPreviewPossible(original, params); + this.checkIfPreviewPossible(original, params); - const preview = this.resizeAndConvert(original, previewOptions); + const preview = await this.resizeAndConvert(original, previewOptions); + const file = PreviewGeneratorBuilder.buildFile(preview, params.previewOptions); - const file = PreviewGeneratorBuilder.buildFile(preview, params.previewOptions); + await this.storageClient.create(previewFilePath, file); - await this.storageClient.create(previewFilePath, file); + this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:end', params)); - this.logger.info(new PreviewActionsLoggable('PreviewGeneratorService.generatePreview:end', params)); - - return { - previewFilePath, - status: true, - }; + return { + previewFilePath, + status: true, + }; + } catch (error) { + throw new PreviewNotPossibleException(params, error as Error); + } } private checkIfPreviewPossible(original: GetFile, params: PreviewFileOptions): void | UnprocessableEntityException { @@ -43,7 +47,7 @@ export class PreviewGeneratorService { if (!isPreviewPossible) { this.logger.warning(new PreviewActionsLoggable('PreviewGeneratorService.previewNotPossible', params)); - throw new UnprocessableEntityException(); + throw new UnprocessableEntityException('Unsupported file type for preview generation'); } } @@ -53,7 +57,7 @@ export class PreviewGeneratorService { return file; } - private resizeAndConvert(original: GetFile, previewParams: PreviewOptions): PassThrough { + private async resizeAndConvert(original: GetFile, previewParams: PreviewOptions): Promise { const { format, width } = previewParams; const preview = this.imageMagick(original.data); @@ -70,8 +74,38 @@ export class PreviewGeneratorService { preview.resize(width, undefined, '>'); } - const result = preview.stream(format); + return this.convert(preview, format); + } - return result; + private convert(preview: m.State, format: string): Promise { + const promise = new Promise((resolve, reject) => { + preview.stream(format, (err, stdout, stderr) => { + if (err) { + reject(err); + } + + const throughStream = new PassThrough(); + stdout.pipe(throughStream); + stdout.on('data', () => { + resolve(throughStream); + }); + + const errorChunks: Array = []; + stderr.on('data', (chunk: Uint8Array) => { + errorChunks.push(chunk); + }); + + stderr.on('end', () => { + let errorMessage = ''; + Buffer.concat(errorChunks).forEach((chunk) => { + errorMessage += String.fromCharCode(chunk); + }); + + reject(new Error(errorMessage)); + }); + }); + }); + + return promise; } } diff --git a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts index babef08f8d9..a8a998e7a4e 100644 --- a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts @@ -1,4 +1,4 @@ -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogStatisticBuilder } from '.'; describe(DeletionLogStatisticBuilder.name, () => { @@ -8,7 +8,7 @@ describe(DeletionLogStatisticBuilder.name, () => { it('should build generic deletionLogStatistic with all attributes', () => { // Arrange - const domain = DeletionDomainModel.PSEUDONYMS; + const domain = DomainModel.PSEUDONYMS; const modifiedCount = 0; const deletedCount = 2; diff --git a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts index 2e467eed310..fa0680b8500 100644 --- a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts @@ -1,9 +1,15 @@ -import { DeletionDomainModel } from '../domain/types'; -import { DeletionLogStatistic } from '../interface'; +import { DomainOperation } from '@shared/domain/interface'; +import { DomainModel } from '@shared/domain/types'; export class DeletionLogStatisticBuilder { - static build(domain: DeletionDomainModel, modifiedCount?: number, deletedCount?: number): DeletionLogStatistic { - const deletionLogStatistic = { domain, modifiedCount, deletedCount }; + static build( + domain: DomainModel, + modifiedCount: number, + deletedCount: number, + modifiedRef?: string[], + deletedRef?: string[] + ): DomainOperation { + const deletionLogStatistic = { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; return deletionLogStatistic; } diff --git a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts index 4a363d86a40..dcde2f6adb3 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts @@ -1,5 +1,5 @@ import { ObjectId } from 'bson'; -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequestBodyPropsBuilder } from './deletion-request-body-props.builder'; describe(DeletionRequestBodyPropsBuilder.name, () => { @@ -8,7 +8,7 @@ describe(DeletionRequestBodyPropsBuilder.name, () => { }); describe('when create deletionRequestBodyParams', () => { const setup = () => { - const domain = DeletionDomainModel.PSEUDONYMS; + const domain = DomainModel.PSEUDONYMS; const refId = new ObjectId().toHexString(); const deleteInMinutes = 1000; return { domain, refId, deleteInMinutes }; diff --git a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts index 2105f7dfc0c..21e00fb7ab0 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts @@ -1,9 +1,8 @@ -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionRequestBodyProps } from '../controller/dto'; -import { DeletionDomainModel } from '../domain/types'; export class DeletionRequestBodyPropsBuilder { - static build(domain: DeletionDomainModel, id: EntityId, deleteInMinutes?: number): DeletionRequestBodyProps { + static build(domain: DomainModel, id: EntityId, deleteInMinutes?: number): DeletionRequestBodyProps { const deletionRequestItem = { targetRef: { domain, id }, deleteInMinutes, diff --git a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts index 6d02894b43f..718af3faf2d 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts @@ -1,4 +1,4 @@ -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogStatisticBuilder, DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '.'; describe(DeletionRequestLogResponseBuilder, () => { @@ -8,7 +8,7 @@ describe(DeletionRequestLogResponseBuilder, () => { it('should build generic deletionRequestLog with all attributes', () => { // Arrange - const targetRefDomain = DeletionDomainModel.PSEUDONYMS; + const targetRefDomain = DomainModel.PSEUDONYMS; const targetRefId = '653e4833cc39e5907a1e18d2'; const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); const deletionPlannedAt = new Date(); diff --git a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts index be4b0ba5a96..04dccb52162 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts @@ -1,11 +1,12 @@ +import { DomainOperation } from '@shared/domain/interface'; import { DeletionRequestLogResponse } from '../controller/dto'; -import { DeletionLogStatistic, DeletionTargetRef } from '../interface'; +import { DeletionTargetRef } from '../interface'; export class DeletionRequestLogResponseBuilder { static build( targetRef: DeletionTargetRef, deletionPlannedAt: Date, - statistics?: DeletionLogStatistic[] + statistics?: DomainOperation[] ): DeletionRequestLogResponse { const deletionRequestLog = { targetRef, deletionPlannedAt, statistics }; diff --git a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts index 4667f290b80..762518d17bd 100644 --- a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts +++ b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts @@ -1,4 +1,4 @@ -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionTargetRefBuilder } from './index'; describe(DeletionTargetRefBuilder.name, () => { @@ -8,7 +8,7 @@ describe(DeletionTargetRefBuilder.name, () => { it('should build generic deletionTargetRef with all attributes', () => { // Arrange - const domain = DeletionDomainModel.PSEUDONYMS; + const domain = DomainModel.PSEUDONYMS; const refId = '653e4833cc39e5907a1e18d2'; const result = DeletionTargetRefBuilder.build(domain, refId); diff --git a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts index d1960a5d4a4..1d1cee14a04 100644 --- a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts +++ b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts @@ -1,9 +1,8 @@ -import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionTargetRef } from '../interface'; export class DeletionTargetRefBuilder { - static build(domain: DeletionDomainModel, id: EntityId): DeletionTargetRef { + static build(domain: DomainModel, id: EntityId): DeletionTargetRef { const deletionTargetRef = { domain, id }; return deletionTargetRef; diff --git a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts b/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts index 3616cac13dc..22814bf8590 100644 --- a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts +++ b/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts @@ -5,8 +5,8 @@ import { AuthGuard } from '@nestjs/passport'; import { EntityManager } from '@mikro-orm/mongodb'; import { TestXApiKeyClient } from '@shared/testing'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequestBodyProps, DeletionRequestResponse } from '../dto'; -import { DeletionDomainModel } from '../../domain/types'; import { DeletionRequestEntity } from '../../entity'; const baseRouteName = '/deletionRequests'; @@ -86,14 +86,14 @@ describe(`deletionRequest create (api)`, () => { const setup = () => { const deletionRequestToCreate: DeletionRequestBodyProps = { targetRef: { - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, id: '653e4833cc39e5907a1e18d2', }, }; const deletionRequestToImmediateRemoval: DeletionRequestBodyProps = { targetRef: { - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, id: '653e4833cc39e5907a1e18d2', }, deleteInMinutes: 0, diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts index 5036a0d39e0..be91c23b06c 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts +++ b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts @@ -1,5 +1,5 @@ import { ObjectId } from 'bson'; -import { DeletionDomainModel } from '../../domain/types'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogStatisticBuilder, DeletionTargetRefBuilder } from '../../builder'; import { DeletionRequestLogResponse } from './index'; @@ -7,7 +7,7 @@ describe(DeletionRequestLogResponse.name, () => { describe('constructor', () => { describe('when passed properties', () => { const setup = () => { - const targetRefDomain = DeletionDomainModel.PSEUDONYMS; + const targetRefDomain = DomainModel.PSEUDONYMS; const targetRefId = new ObjectId().toHexString(); const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); const deletionPlannedAt = new Date(); diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts index 3619bebace8..e0b5d1546fe 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts +++ b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsOptional } from 'class-validator'; -import { DeletionLogStatistic, DeletionTargetRef } from '../../interface'; +import { DomainOperation } from '@shared/domain/interface'; +import { DeletionTargetRef } from '../../interface'; export class DeletionRequestLogResponse { @ApiProperty() @@ -11,7 +12,7 @@ export class DeletionRequestLogResponse { @ApiProperty() @IsOptional() - statistics?: DeletionLogStatistic[]; + statistics?: DomainOperation[]; constructor(response: DeletionRequestLogResponse) { this.targetRef = response.targetRef; diff --git a/apps/server/src/modules/deletion/deletion-api.module.ts b/apps/server/src/modules/deletion/deletion-api.module.ts index 8187f2fd306..5e4a6cf427d 100644 --- a/apps/server/src/modules/deletion/deletion-api.module.ts +++ b/apps/server/src/modules/deletion/deletion-api.module.ts @@ -14,10 +14,11 @@ import { RocketChatUserModule } from '@modules/rocketchat-user'; import { Configuration } from '@hpi-schul-cloud/commons'; import { RocketChatModule } from '@modules/rocketchat'; import { RegistrationPinModule } from '@modules/registration-pin'; +import { TaskModule } from '@modules/task'; +import { FilesStorageClientModule } from '@modules/files-storage-client'; import { DeletionRequestsController } from './controller/deletion-requests.controller'; import { DeletionExecutionsController } from './controller/deletion-executions.controller'; import { DeletionRequestUc } from './uc'; -import { FilesStorageClientModule } from '../files-storage-client'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { FilesStorageClientModule } from '../files-storage-client'; RocketChatUserModule, RegistrationPinModule, FilesStorageClientModule, + TaskModule, RocketChatModule.forRoot({ uri: Configuration.get('ROCKET_CHAT_URI') as string, adminId: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts index fd320ff79cb..10040956627 100644 --- a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts @@ -1,7 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { deletionLogFactory } from './testing/factory/deletion-log.factory'; import { DeletionLog } from './deletion-log.do'; -import { DeletionOperationModel, DeletionDomainModel } from './types'; +import { DeletionOperationModel } from './types'; describe(DeletionLog.name, () => { describe('constructor', () => { @@ -35,7 +36,7 @@ describe(DeletionLog.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.ts b/apps/server/src/modules/deletion/domain/deletion-log.do.ts index c5ca2b652d1..81a9c7f41cb 100644 --- a/apps/server/src/modules/deletion/domain/deletion-log.do.ts +++ b/apps/server/src/modules/deletion/domain/deletion-log.do.ts @@ -1,14 +1,14 @@ -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; -import { DeletionDomainModel, DeletionOperationModel } from './types'; +import { DeletionOperationModel } from './types'; export interface DeletionLogProps extends AuthorizableObject { createdAt?: Date; updatedAt?: Date; - domain: DeletionDomainModel; + domain: DomainModel; operation?: DeletionOperationModel; - modifiedCount?: number; - deletedCount?: number; + modifiedCount: number; + deletedCount: number; deletionRequestId?: EntityId; performedAt?: Date; } @@ -22,7 +22,7 @@ export class DeletionLog extends DomainObject { return this.props.updatedAt; } - get domain(): DeletionDomainModel { + get domain(): DomainModel { return this.props.domain; } @@ -30,11 +30,11 @@ export class DeletionLog extends DomainObject { return this.props.operation; } - get modifiedCount(): number | undefined { + get modifiedCount(): number { return this.props.modifiedCount; } - get deletedCount(): number | undefined { + get deletedCount(): number { return this.props.deletedCount; } diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts index 1ffb7d3f906..2650e894fdc 100644 --- a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts @@ -1,6 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequest } from './deletion-request.do'; -import { DeletionDomainModel, DeletionStatusModel } from './types'; +import { DeletionStatusModel } from './types'; import { deletionRequestFactory } from './testing/factory/deletion-request.factory'; describe(DeletionRequest.name, () => { @@ -35,7 +36,7 @@ describe(DeletionRequest.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.ts b/apps/server/src/modules/deletion/domain/deletion-request.do.ts index 76b7f0371ba..92010ef2570 100644 --- a/apps/server/src/modules/deletion/domain/deletion-request.do.ts +++ b/apps/server/src/modules/deletion/domain/deletion-request.do.ts @@ -1,11 +1,11 @@ -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; -import { DeletionDomainModel, DeletionStatusModel } from './types'; +import { DeletionStatusModel } from './types'; export interface DeletionRequestProps extends AuthorizableObject { createdAt?: Date; updatedAt?: Date; - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; deleteAfter: Date; targetRefId: EntityId; status: DeletionStatusModel; @@ -20,7 +20,7 @@ export class DeletionRequest extends DomainObject { return this.props.updatedAt; } - get targetRefDomain(): DeletionDomainModel { + get targetRefDomain(): DomainModel { return this.props.targetRefDomain; } diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts index 2a3d0529866..2593ba5c242 100644 --- a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts @@ -1,12 +1,13 @@ import { DoBaseFactory } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLog, DeletionLogProps } from '../../deletion-log.do'; -import { DeletionOperationModel, DeletionDomainModel } from '../../types'; +import { DeletionOperationModel } from '../../types'; export const deletionLogFactory = DoBaseFactory.define(DeletionLog, () => { return { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts index cf1f64daaec..e0ae6e7e41b 100644 --- a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts @@ -1,8 +1,9 @@ import { DoBaseFactory } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequest, DeletionRequestProps } from '../../deletion-request.do'; -import { DeletionDomainModel, DeletionStatusModel } from '../../types'; +import { DeletionStatusModel } from '../../types'; class DeletionRequestFactory extends DoBaseFactory { withUserIds(id: string): this { @@ -17,7 +18,7 @@ class DeletionRequestFactory extends DoBaseFactory { return { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/domain/types/index.ts b/apps/server/src/modules/deletion/domain/types/index.ts index d1f4de8eb6b..607e7fbe5e5 100644 --- a/apps/server/src/modules/deletion/domain/types/index.ts +++ b/apps/server/src/modules/deletion/domain/types/index.ts @@ -1,3 +1,2 @@ -export * from './deletion-domain-model.enum'; export * from './deletion-operation-model.enum'; export * from './deletion-status-model.enum'; diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts index c1b5f5f7184..a0524a30a52 100644 --- a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts @@ -1,7 +1,8 @@ import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogEntity } from './deletion-log.entity'; -import { DeletionOperationModel, DeletionDomainModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; describe(DeletionLogEntity.name, () => { beforeAll(async () => { @@ -13,7 +14,7 @@ describe(DeletionLogEntity.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts index 31ec5447e56..03dfaf5123e 100644 --- a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts +++ b/apps/server/src/modules/deletion/entity/deletion-log.entity.ts @@ -1,15 +1,15 @@ import { Entity, Index, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { ObjectId } from 'bson'; -import { DeletionDomainModel, DeletionOperationModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; export interface DeletionLogEntityProps { id?: EntityId; - domain: DeletionDomainModel; + domain: DomainModel; operation?: DeletionOperationModel; - modifiedCount?: number; - deletedCount?: number; + modifiedCount: number; + deletedCount: number; deletionRequestId?: ObjectId; performedAt?: Date; createdAt?: Date; @@ -19,16 +19,16 @@ export interface DeletionLogEntityProps { @Entity({ tableName: 'deletionlogs' }) export class DeletionLogEntity extends BaseEntityWithTimestamps { @Property() - domain: DeletionDomainModel; + domain: DomainModel; @Property({ nullable: true }) operation?: DeletionOperationModel; - @Property({ nullable: true }) - modifiedCount?: number; + @Property() + modifiedCount: number; - @Property({ nullable: true }) - deletedCount?: number; + @Property() + deletedCount: number; @Property({ nullable: true }) deletionRequestId?: ObjectId; @@ -48,14 +48,8 @@ export class DeletionLogEntity extends BaseEntityWithTimestamps { if (props.operation !== undefined) { this.operation = props.operation; } - - if (props.modifiedCount !== undefined) { - this.modifiedCount = props.modifiedCount; - } - - if (props.deletedCount !== undefined) { - this.deletedCount = props.deletedCount; - } + this.modifiedCount = props.modifiedCount; + this.deletedCount = props.deletedCount; if (props.deletionRequestId !== undefined) { this.deletionRequestId = props.deletionRequestId; diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts index d4e8440bfa0..273679ef671 100644 --- a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts @@ -1,6 +1,7 @@ import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; +import { DeletionStatusModel } from '../domain/types'; import { DeletionRequestEntity } from '.'; describe(DeletionRequestEntity.name, () => { @@ -15,7 +16,7 @@ describe(DeletionRequestEntity.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts index bff42fac93a..b81835641a9 100644 --- a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts +++ b/apps/server/src/modules/deletion/entity/deletion-request.entity.ts @@ -1,12 +1,12 @@ import { Entity, Index, Property, Unique } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; +import { DeletionStatusModel } from '../domain/types'; const SECONDS_OF_90_DAYS = 90 * 24 * 60 * 60; export interface DeletionRequestEntityProps { id?: EntityId; - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; deleteAfter: Date; targetRefId: EntityId; status: DeletionStatusModel; @@ -25,7 +25,7 @@ export class DeletionRequestEntity extends BaseEntityWithTimestamps { targetRefId!: EntityId; @Property() - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; @Property() status: DeletionStatusModel; diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts index 93ed4198f15..6090f14402d 100644 --- a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts @@ -1,14 +1,15 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '@shared/testing'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogEntity, DeletionLogEntityProps } from '../../deletion-log.entity'; -import { DeletionOperationModel, DeletionDomainModel } from '../../../domain/types'; +import { DeletionOperationModel } from '../../../domain/types'; export const deletionLogEntityFactory = BaseFactory.define( DeletionLogEntity, () => { return { id: new ObjectId().toHexString(), - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, operation: DeletionOperationModel.DELETE, modifiedCount: 0, deletedCount: 1, diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts index cc65bb5f4dc..8f33e0d66d6 100644 --- a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts +++ b/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts @@ -1,6 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '@shared/testing'; -import { DeletionStatusModel, DeletionDomainModel } from '../../../domain/types'; +import { DomainModel } from '@shared/domain/types'; +import { DeletionStatusModel } from '../../../domain/types'; import { DeletionRequestEntity, DeletionRequestEntityProps } from '../../deletion-request.entity'; export const deletionRequestEntityFactory = BaseFactory.define( @@ -8,7 +9,7 @@ export const deletionRequestEntityFactory = BaseFactory.define { return { id: new ObjectId().toHexString(), - targetRefDomain: DeletionDomainModel.USER, + targetRefDomain: DomainModel.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), status: DeletionStatusModel.REGISTERED, diff --git a/apps/server/src/modules/deletion/interface/interfaces.ts b/apps/server/src/modules/deletion/interface/interfaces.ts index 9d75c1c0da2..7a2621d87b7 100644 --- a/apps/server/src/modules/deletion/interface/interfaces.ts +++ b/apps/server/src/modules/deletion/interface/interfaces.ts @@ -1,13 +1,6 @@ -import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel } from '../domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; export interface DeletionTargetRef { - domain: DeletionDomainModel; + domain: DomainModel; id: EntityId; } - -export interface DeletionLogStatistic { - domain: DeletionDomainModel; - modifiedCount?: number; - deletedCount?: number; -} diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts index 7b63e866b14..e453ab24419 100644 --- a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts +++ b/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts @@ -2,8 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainModel } from '@shared/domain/types'; import { DeletionLogRepo } from '../repo'; -import { DeletionDomainModel, DeletionOperationModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; import { DeletionLogService } from './deletion-log.service'; import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; @@ -47,7 +48,7 @@ describe(DeletionLogService.name, () => { describe('when creating a deletionRequest', () => { const setup = () => { const deletionRequestId = '653e4833cc39e5907a1e18d2'; - const domain = DeletionDomainModel.USER; + const domain = DomainModel.USER; const operation = DeletionOperationModel.DELETE; const modifiedCount = 0; const deletedCount = 1; @@ -82,7 +83,7 @@ describe(DeletionLogService.name, () => { const deletionLog1 = deletionLogFactory.build({ deletionRequestId }); const deletionLog2 = deletionLogFactory.build({ deletionRequestId, - domain: DeletionDomainModel.PSEUDONYMS, + domain: DomainModel.PSEUDONYMS, }); const deletionLogs = [deletionLog1, deletionLog2]; diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.ts b/apps/server/src/modules/deletion/services/deletion-log.service.ts index a06458fe748..577e76a864b 100644 --- a/apps/server/src/modules/deletion/services/deletion-log.service.ts +++ b/apps/server/src/modules/deletion/services/deletion-log.service.ts @@ -1,8 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionLog } from '../domain/deletion-log.do'; -import { DeletionDomainModel, DeletionOperationModel } from '../domain/types'; +import { DeletionOperationModel } from '../domain/types'; import { DeletionLogRepo } from '../repo'; @Injectable() @@ -11,7 +11,7 @@ export class DeletionLogService { async createDeletionLog( deletionRequestId: EntityId, - domain: DeletionDomainModel, + domain: DomainModel, operation: DeletionOperationModel, modifiedCount: number, deletedCount: number diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts index 99763882064..d4675d62861 100644 --- a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts +++ b/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts @@ -2,10 +2,11 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { setupEntities } from '@shared/testing'; +import { DomainModel } from '@shared/domain/types'; import { DeletionRequestService } from './deletion-request.service'; import { DeletionRequestRepo } from '../repo'; import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DeletionStatusModel } from '../domain/types'; describe(DeletionRequestService.name, () => { let module: TestingModule; @@ -47,7 +48,7 @@ describe(DeletionRequestService.name, () => { describe('when creating a deletionRequest', () => { const setup = () => { const targetRefId = '653e4833cc39e5907a1e18d2'; - const targetRefDomain = DeletionDomainModel.USER; + const targetRefDomain = DomainModel.USER; return { targetRefId, targetRefDomain }; }; diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.ts b/apps/server/src/modules/deletion/services/deletion-request.service.ts index d3a41e03c12..08c282a8693 100644 --- a/apps/server/src/modules/deletion/services/deletion-request.service.ts +++ b/apps/server/src/modules/deletion/services/deletion-request.service.ts @@ -1,8 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { DeletionRequest } from '../domain/deletion-request.do'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DeletionStatusModel } from '../domain/types'; import { DeletionRequestRepo } from '../repo/deletion-request.repo'; @Injectable() @@ -11,7 +11,7 @@ export class DeletionRequestService { async createDeletionRequest( targetRefId: EntityId, - targetRefDomain: DeletionDomainModel, + targetRefDomain: DomainModel, deleteInMinutes = 43200 ): Promise<{ requestId: EntityId; deletionPlannedAt: Date }> { const dateOfDeletion = new Date(); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts index bcdba26070a..cd7f0a00443 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -15,14 +15,17 @@ import { LegacyLogger } from '@src/core/logger'; import { ObjectId } from 'bson'; import { RegistrationPinService } from '@modules/registration-pin'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; -import { DeletionDomainModel, DeletionStatusModel } from '../domain/types'; +import { DomainModel } from '@shared/domain/types'; +import { TaskService } from '@modules/task'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { DeletionStatusModel } from '../domain/types'; import { DeletionLogService } from '../services/deletion-log.service'; import { DeletionRequestService } from '../services'; import { DeletionRequestUc } from './deletion-request.uc'; import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; import { deletionLogFactory } from '../domain/testing'; import { DeletionRequestBodyProps } from '../controller/dto'; -import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder, DeletionLogStatisticBuilder } from '../builder'; +import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; describe(DeletionRequestUc.name, () => { let module: TestingModule; @@ -43,6 +46,7 @@ describe(DeletionRequestUc.name, () => { let registrationPinService: DeepMocked; let filesStorageClientAdapterService: DeepMocked; let dashboardService: DeepMocked; + let taskService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -116,6 +120,10 @@ describe(DeletionRequestUc.name, () => { provide: DashboardService, useValue: createMock(), }, + { + provide: TaskService, + useValue: createMock(), + }, ], }).compile(); @@ -136,6 +144,7 @@ describe(DeletionRequestUc.name, () => { registrationPinService = module.get(RegistrationPinService); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); dashboardService = module.get(DashboardService); + taskService = module.get(TaskService); await setupEntities(); }); @@ -148,7 +157,7 @@ describe(DeletionRequestUc.name, () => { const setup = () => { const deletionRequestToCreate: DeletionRequestBodyProps = { targetRef: { - domain: DeletionDomainModel.USER, + domain: DomainModel.USER, id: new ObjectId().toHexString(), }, deleteInMinutes: 1440, @@ -200,6 +209,9 @@ describe(DeletionRequestUc.name, () => { userId: deletionRequestToExecute.targetRefId, }); const parentEmail = 'parent@parent.eu'; + const tasksModifiedByRemoveCreatorId = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const tasksModifiedByRemoveUserFromFinished = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const tasksDeleted = DomainOperationBuilder.build(DomainModel.TASK, 0, 1); registrationPinService.deleteRegistrationPinByEmail.mockResolvedValueOnce(2); classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); @@ -214,6 +226,9 @@ describe(DeletionRequestUc.name, () => { rocketChatUserService.deleteByUserId.mockResolvedValueOnce(1); filesStorageClientAdapterService.removeCreatorIdFromFileRecords.mockResolvedValueOnce(5); dashboardService.deleteDashboardByUserId.mockResolvedValueOnce(1); + taskService.removeCreatorIdFromTasks.mockResolvedValueOnce(tasksModifiedByRemoveCreatorId); + taskService.removeCreatorIdFromTasks.mockResolvedValueOnce(tasksModifiedByRemoveUserFromFinished); + taskService.deleteTasksByOnlyCreator.mockResolvedValueOnce(tasksDeleted); return { deletionRequestToExecute, @@ -418,6 +433,36 @@ describe(DeletionRequestUc.name, () => { expect(dashboardService.deleteDashboardByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); }); + it('should call taskService.deleteTasksByOnlyCreator to delete Tasks only with creator', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(taskService.deleteTasksByOnlyCreator).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call taskService.removeCreatorIdFromTasks to update Tasks without creatorId', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(taskService.removeCreatorIdFromTasks).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call taskService.removeUserFromFinished to update Tasks without creatorId in Finished collection', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(taskService.removeUserFromFinished).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { const { deletionRequestToExecute } = setup(); @@ -425,7 +470,7 @@ describe(DeletionRequestUc.name, () => { await uc.executeDeletionRequests(); - expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(12); + expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(13); }); }); @@ -470,7 +515,7 @@ describe(DeletionRequestUc.name, () => { deletionRequestExecuted.targetRefDomain, deletionRequestExecuted.targetRefId ); - const statistics = DeletionLogStatisticBuilder.build( + const statistics = DomainOperationBuilder.build( deletionLogExecuted.domain, deletionLogExecuted.modifiedCount, deletionLogExecuted.deletedCount diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts index 7d705b23b09..97d2284a4cc 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -10,14 +10,16 @@ import { RocketChatUserService } from '@modules/rocketchat-user'; import { TeamService } from '@modules/teams'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; +import { DomainModel, EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { DeletionLogStatisticBuilder, DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; +import { TaskService } from '@modules/task'; +import { DomainOperation } from '@shared/domain/interface'; +import { DomainOperationBuilder } from '@shared/domain/builder/domain-operation.builder'; +import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; import { DeletionRequestBodyProps, DeletionRequestLogResponse, DeletionRequestResponse } from '../controller/dto'; -import { DeletionLogStatistic } from './interface/interfaces'; import { DeletionRequest, DeletionLog } from '../domain'; -import { DeletionDomainModel, DeletionOperationModel, DeletionStatusModel } from '../domain/types'; +import { DeletionOperationModel, DeletionStatusModel } from '../domain/types'; import { DeletionRequestService, DeletionLogService } from '../services'; @Injectable() @@ -39,7 +41,8 @@ export class DeletionRequestUc { private readonly logger: LegacyLogger, private readonly registrationPinService: RegistrationPinService, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly dashboardService: DashboardService + private readonly dashboardService: DashboardService, + private readonly taskService: TaskService ) { this.logger.setContext(DeletionRequestUc.name); } @@ -79,10 +82,10 @@ export class DeletionRequestUc { if (deletionRequest.status === DeletionStatusModel.SUCCESS) { const deletionLog: DeletionLog[] = await this.deletionLogService.findByDeletionRequestId(deletionRequestId); - const deletionLogStatistic: DeletionLogStatistic[] = deletionLog.map((log) => - DeletionLogStatisticBuilder.build(log.domain, log.modifiedCount, log.deletedCount) + const domainOperation: DomainOperation[] = deletionLog.map((log) => + DomainOperationBuilder.build(log.domain, log.modifiedCount, log.deletedCount) ); - response = { ...response, statistics: deletionLogStatistic }; + response = { ...response, statistics: domainOperation }; } return response; @@ -110,6 +113,7 @@ export class DeletionRequestUc { this.removeUserFromRocketChat(deletionRequest), this.removeUserRegistrationPin(deletionRequest), this.removeUsersDashboard(deletionRequest), + this.removeUserFromTasks(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -120,27 +124,25 @@ export class DeletionRequestUc { private async logDeletion( deletionRequest: DeletionRequest, - domainModel: DeletionDomainModel, + domainModel: DomainModel, operationModel: DeletionOperationModel, updatedCount: number, deletedCount: number ): Promise { - if (updatedCount > 0 || deletedCount > 0) { - await this.deletionLogService.createDeletionLog( - deletionRequest.id, - domainModel, - operationModel, - updatedCount, - deletedCount - ); - } + await this.deletionLogService.createDeletionLog( + deletionRequest.id, + domainModel, + operationModel, + updatedCount, + deletedCount + ); } private async removeAccount(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeAccount', deletionRequest }); await this.accountService.deleteByUserId(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DeletionDomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); + await this.logDeletion(deletionRequest, DomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); } private async removeUserRegistrationPin(deletionRequest: DeletionRequest) { @@ -155,7 +157,7 @@ export class DeletionRequestUc { await this.logDeletion( deletionRequest, - DeletionDomainModel.REGISTRATIONPIN, + DomainModel.REGISTRATIONPIN, DeletionOperationModel.DELETE, 0, deletedRegistrationPin @@ -166,13 +168,7 @@ export class DeletionRequestUc { this.logger.debug({ action: 'removeUserFromClasses', deletionRequest }); const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.CLASS, - DeletionOperationModel.UPDATE, - classesUpdated, - 0 - ); + await this.logDeletion(deletionRequest, DomainModel.CLASS, DeletionOperationModel.UPDATE, classesUpdated, 0); } private async removeUserFromCourseGroup(deletionRequest: DeletionRequest) { @@ -183,7 +179,7 @@ export class DeletionRequestUc { ); await this.logDeletion( deletionRequest, - DeletionDomainModel.COURSEGROUP, + DomainModel.COURSEGROUP, DeletionOperationModel.UPDATE, courseGroupUpdated, 0 @@ -194,26 +190,14 @@ export class DeletionRequestUc { this.logger.debug({ action: 'removeUserFromCourse', deletionRequest }); const courseUpdated: number = await this.courseService.deleteUserDataFromCourse(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.COURSE, - DeletionOperationModel.UPDATE, - courseUpdated, - 0 - ); + await this.logDeletion(deletionRequest, DomainModel.COURSE, DeletionOperationModel.UPDATE, courseUpdated, 0); } private async removeUsersDashboard(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeUsersDashboard', deletionRequest }); const dashboardDeleted: number = await this.dashboardService.deleteDashboardByUserId(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.DASHBOARD, - DeletionOperationModel.DELETE, - 0, - dashboardDeleted - ); + await this.logDeletion(deletionRequest, DomainModel.DASHBOARD, DeletionOperationModel.DELETE, 0, dashboardDeleted); } private async removeUsersFilesAndPermissions(deletionRequest: DeletionRequest) { @@ -225,7 +209,7 @@ export class DeletionRequestUc { ); await this.logDeletion( deletionRequest, - DeletionDomainModel.FILE, + DomainModel.FILE, DeletionOperationModel.UPDATE, filesDeleted + filePermissionsUpdated, 0 @@ -241,7 +225,7 @@ export class DeletionRequestUc { await this.logDeletion( deletionRequest, - DeletionDomainModel.FILERECORDS, + DomainModel.FILERECORDS, DeletionOperationModel.UPDATE, fileRecordsUpdated, 0 @@ -252,40 +236,28 @@ export class DeletionRequestUc { this.logger.debug({ action: 'removeUserFromLessons', deletionRequest }); const lessonsUpdated: number = await this.lessonService.deleteUserDataFromLessons(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.LESSONS, - DeletionOperationModel.UPDATE, - lessonsUpdated, - 0 - ); + await this.logDeletion(deletionRequest, DomainModel.LESSONS, DeletionOperationModel.UPDATE, lessonsUpdated, 0); } private async removeUsersPseudonyms(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeUsersPseudonyms', deletionRequest }); const pseudonymDeleted: number = await this.pseudonymService.deleteByUserId(deletionRequest.targetRefId); - await this.logDeletion( - deletionRequest, - DeletionDomainModel.PSEUDONYMS, - DeletionOperationModel.DELETE, - 0, - pseudonymDeleted - ); + await this.logDeletion(deletionRequest, DomainModel.PSEUDONYMS, DeletionOperationModel.DELETE, 0, pseudonymDeleted); } private async removeUserFromTeams(deletionRequest: DeletionRequest) { this.logger.debug({ action: ' removeUserFromTeams', deletionRequest }); const teamsUpdated: number = await this.teamService.deleteUserDataFromTeams(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DeletionDomainModel.TEAMS, DeletionOperationModel.UPDATE, teamsUpdated, 0); + await this.logDeletion(deletionRequest, DomainModel.TEAMS, DeletionOperationModel.UPDATE, teamsUpdated, 0); } private async removeUser(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeUser', deletionRequest }); const userDeleted: number = await this.userService.deleteUser(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DeletionDomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); + await this.logDeletion(deletionRequest, DomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); } private async removeUserFromRocketChat(deletionRequest: DeletionRequest) { @@ -299,10 +271,28 @@ export class DeletionRequestUc { ]); await this.logDeletion( deletionRequest, - DeletionDomainModel.ROCKETCHATUSER, + DomainModel.ROCKETCHATUSER, DeletionOperationModel.DELETE, 0, rocketChatUserDeleted ); } + + private async removeUserFromTasks(deletionRequest: DeletionRequest) { + this.logger.debug({ action: 'removeUserFromTasks', deletionRequest }); + + const tasksDeleted = await this.taskService.deleteTasksByOnlyCreator(deletionRequest.targetRefId); + const tasksModifiedByRemoveCreator = await this.taskService.removeCreatorIdFromTasks(deletionRequest.targetRefId); + const tasksModifiedByRemoveUserFromFinished = await this.taskService.removeUserFromFinished( + deletionRequest.targetRefId + ); + + await this.logDeletion( + deletionRequest, + DomainModel.TASK, + DeletionOperationModel.UPDATE, + tasksModifiedByRemoveCreator.modifiedCount + tasksModifiedByRemoveUserFromFinished.modifiedCount, + tasksDeleted.deletedCount + ); + } } diff --git a/apps/server/src/modules/deletion/uc/interface/interfaces.ts b/apps/server/src/modules/deletion/uc/interface/interfaces.ts index 57275b5b530..004484c20b2 100644 --- a/apps/server/src/modules/deletion/uc/interface/interfaces.ts +++ b/apps/server/src/modules/deletion/uc/interface/interfaces.ts @@ -1,8 +1,8 @@ import { EntityId } from '@shared/domain/types'; -import { DeletionDomainModel } from '../../domain/types/deletion-domain-model.enum'; +import { DomainModel } from '@shared/domain/types/domain'; export interface DeletionTargetRef { - targetRefDomain: DeletionDomainModel; + targetRefDomain: DomainModel; targetRefId: EntityId; } @@ -13,13 +13,13 @@ export interface DeletionRequestLog { } export interface DeletionLogStatistic { - domain: DeletionDomainModel; + domain: DomainModel; modifiedCount?: number; deletedCount?: number; } export interface DeletionRequestProps { - targetRef: { targetRefDoamin: DeletionDomainModel; targetRefId: EntityId }; + targetRef: { targetRefDoamin: DomainModel; targetRefId: EntityId }; deleteInMinutes?: number; } 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 7d08a0fd2fa..32a9740cd7d 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 @@ -47,13 +47,13 @@ describe('Group (API)', () => { describe('[GET] /groups/class', () => { describe('when an admin requests a list of classes', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ currentYear: schoolYear }); const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); const system: SystemEntity = systemEntityFactory.buildWithId(); - const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); const clazz: ClassEntity = classEntityFactory.buildWithId({ name: 'Group A', schoolId: school._id, diff --git a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts index 8f6f1f45e65..241200e81c2 100644 --- a/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts +++ b/apps/server/src/modules/legacy-school/repo/schoolyear.repo.integration.spec.ts @@ -46,30 +46,65 @@ describe('schoolyear repo', () => { }); describe('findCurrentYear', () => { - describe('when date is between schoolyears start and end date', () => { - const setup = async () => { - const schoolYear: SchoolYearEntity = schoolYearFactory.build({ - startDate: new Date('2020-08-01'), - endDate: new Date('9999-07-31'), + describe('when current date is between schoolyears start and end date', () => { + describe('when current date year is in the schoolyears start date', () => { + const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2023-10-01')); + + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2023-08-01'), + endDate: new Date('2024-07-31'), + }); + + await em.persistAndFlush(schoolYear); + em.clear(); + + return { schoolYear }; + }; + + it('should return the current schoolyear', async () => { + const { schoolYear } = await setup(); + + const currentYear = await repo.findCurrentYear(); + + expect(currentYear).toEqual(schoolYear); }); + }); + describe('when current date year is in the schoolyears end date', () => { + const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2024-03-01')); - await em.persistAndFlush(schoolYear); - em.clear(); + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2023-08-01'), + endDate: new Date('2024-07-31'), + }); - return { schoolYear }; - }; + await em.persistAndFlush(schoolYear); + em.clear(); + + return { schoolYear }; + }; - it('should return the current schoolyear', async () => { - const { schoolYear } = await setup(); + it('should return the current schoolyear', async () => { + const { schoolYear } = await setup(); - const currentYear = await repo.findCurrentYear(); + const currentYear = await repo.findCurrentYear(); - expect(currentYear).toEqual(schoolYear); + expect(currentYear).toEqual(schoolYear); + }); }); }); - describe('when date is not between schoolyears start and end date', () => { + describe('when current date is outside schoolyears start and end date', () => { const setup = async () => { + jest + .useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }) + .setSystemTime(new Date('2024-01-01')); + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ startDate: new Date('2020-08-01'), endDate: new Date('2021-07-31'), @@ -81,7 +116,7 @@ describe('schoolyear repo', () => { return { schoolYear }; }; - it('should return the current schoolyear', async () => { + it('should throw', async () => { await setup(); const func = () => repo.findCurrentYear(); diff --git a/apps/server/src/modules/task/service/task.service.spec.ts b/apps/server/src/modules/task/service/task.service.spec.ts index 4cf950964bd..fd77063c934 100644 --- a/apps/server/src/modules/task/service/task.service.spec.ts +++ b/apps/server/src/modules/task/service/task.service.spec.ts @@ -1,8 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { TaskRepo } from '@shared/repo'; -import { setupEntities, submissionFactory, taskFactory } from '@shared/testing'; +import { courseFactory, setupEntities, submissionFactory, taskFactory, userFactory } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { DomainModel } from '@shared/domain/types'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { LegacyLogger } from '@src/core/logger'; import { SubmissionService } from './submission.service'; import { TaskService } from './task.service'; @@ -29,6 +32,10 @@ describe('TaskService', () => { provide: FilesStorageClientAdapterService, useValue: createMock(), }, + { + provide: LegacyLogger, + useValue: createMock(), + }, ], }).compile(); @@ -102,4 +109,107 @@ describe('TaskService', () => { expect(taskRepo.delete).toBeCalledWith(task); }); }); + + describe('deleteTasksByOnlyCreator', () => { + describe('when task has only user as parent', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const taskWithoutCourse = taskFactory.buildWithId({ creator }); + + taskRepo.findByOnlyCreatorId.mockResolvedValue([[taskWithoutCourse], 1]); + + const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 0, 1); + + return { creator, expectedResult }; + }; + + it('should call taskRepo.findByOnlyCreatorId with creatorId', async () => { + const { creator } = setup(); + + await taskService.deleteTasksByOnlyCreator(creator.id); + + expect(taskRepo.findByOnlyCreatorId).toBeCalledWith(creator.id); + }); + + it('should return the object with information on the actions performed', async () => { + const { creator, expectedResult } = setup(); + + const result = await taskService.deleteTasksByOnlyCreator(creator.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('removeCreatorIdFromTasks', () => { + describe('when tasks where user is parent, and when task has course', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const course = courseFactory.build(); + const taskWithCourse = taskFactory.buildWithId({ creator, course }); + + taskRepo.findByCreatorIdWithCourseAndLesson.mockResolvedValue([[taskWithCourse], 1]); + + const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const taskWithCourseToUpdate = { ...taskWithCourse, creator: undefined }; + + return { creator, expectedResult, taskWithCourseToUpdate }; + }; + + it('should call taskRepo.findByCreatorIdWithCourseAndLesson with creatorId', async () => { + const { creator } = setup(); + + await taskService.removeCreatorIdFromTasks(creator.id); + + expect(taskRepo.findByCreatorIdWithCourseAndLesson).toBeCalledWith(creator.id); + }); + + it('should call taskRepo.save with task to update', async () => { + const { creator, taskWithCourseToUpdate } = setup(); + + await taskService.removeCreatorIdFromTasks(creator.id); + + expect(taskRepo.save).toBeCalledWith([taskWithCourseToUpdate]); + }); + + it('should return the object with information on the actions performed', async () => { + const { creator, expectedResult } = setup(); + + const result = await taskService.removeCreatorIdFromTasks(creator.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('removeUserFromFinished', () => { + describe('when task has user in finished array', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const finishedTask = taskFactory.finished(creator).buildWithId(); + + taskRepo.findByUserIdInFinished.mockResolvedValue([[finishedTask], 1]); + + const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + + return { creator, expectedResult }; + }; + + it('should call taskRepo.findByUserIdInFinished with creatorId', async () => { + const { creator } = setup(); + + await taskService.removeUserFromFinished(creator.id); + + expect(taskRepo.findByUserIdInFinished).toBeCalledWith(creator.id); + }); + + it('should return the object with information on the actions performed', async () => { + const { creator, expectedResult } = setup(); + + const result = await taskService.removeUserFromFinished(creator.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); }); diff --git a/apps/server/src/modules/task/service/task.service.ts b/apps/server/src/modules/task/service/task.service.ts index 3af74484604..0472c23bf42 100644 --- a/apps/server/src/modules/task/service/task.service.ts +++ b/apps/server/src/modules/task/service/task.service.ts @@ -1,9 +1,11 @@ import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; import { Task } from '@shared/domain/entity'; -import { IFindOptions } from '@shared/domain/interface'; -import { Counted, EntityId } from '@shared/domain/types'; +import { DomainOperation, IFindOptions } from '@shared/domain/interface'; +import { Counted, DomainModel, EntityId } from '@shared/domain/types'; import { TaskRepo } from '@shared/repo'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { LegacyLogger } from '@src/core/logger'; import { SubmissionService } from './submission.service'; @Injectable() @@ -11,8 +13,11 @@ export class TaskService { constructor( private readonly taskRepo: TaskRepo, private readonly submissionService: SubmissionService, - private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService - ) {} + private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, + private readonly logger: LegacyLogger + ) { + this.logger.setContext(TaskService.name); + } async findBySingleParent( creatorId: EntityId, @@ -41,4 +46,56 @@ export class TaskService { async findById(taskId: EntityId): Promise { return this.taskRepo.findById(taskId); } + + async deleteTasksByOnlyCreator(creatorId: EntityId): Promise { + this.logger.log(`Deleting Tasks where creatorId ${creatorId} is only parent`); + const [tasksByOnlyCreatorId, counterOfTasksOnlyWithCreatorId] = await this.taskRepo.findByOnlyCreatorId(creatorId); + + if (counterOfTasksOnlyWithCreatorId > 0) { + const promiseDeletedTasks = tasksByOnlyCreatorId.map((task: Task) => this.delete(task)); + await Promise.all(promiseDeletedTasks); + } + + const result = DomainOperationBuilder.build(DomainModel.TASK, 0, counterOfTasksOnlyWithCreatorId); + this.logger.log( + `Successfully deleted ${counterOfTasksOnlyWithCreatorId} where creatorId ${creatorId} is only parent` + ); + + return result; + } + + async removeCreatorIdFromTasks(creatorId: EntityId): Promise { + this.logger.log(`Deleting creatorId ${creatorId} from Tasks`); + const [tasksByCreatorIdWithCoursesAndLessons, counterOfTasksWithCoursesorLessons] = + await this.taskRepo.findByCreatorIdWithCourseAndLesson(creatorId); + + if (counterOfTasksWithCoursesorLessons > 0) { + tasksByCreatorIdWithCoursesAndLessons.forEach((task: Task) => task.removeCreatorId()); + await this.taskRepo.save(tasksByCreatorIdWithCoursesAndLessons); + } + + const result = DomainOperationBuilder.build(DomainModel.TASK, counterOfTasksWithCoursesorLessons, 0); + this.logger.log(`Successfully updated ${counterOfTasksWithCoursesorLessons} Tasks without creatorId ${creatorId}`); + return result; + } + + async removeUserFromFinished(userId: EntityId): Promise { + this.logger.log(`Deleting userId ${userId} from Archve collection in Tasks`); + const [tasksWithUserInFinished, counterOfTasksWithUserInFinished] = await this.taskRepo.findByUserIdInFinished( + userId + ); + + if (counterOfTasksWithUserInFinished > 0) { + tasksWithUserInFinished.forEach((task: Task) => task.removeUserFromFinished(userId)); + + await this.taskRepo.save(tasksWithUserInFinished); + } + + const result = DomainOperationBuilder.build(DomainModel.TASK, counterOfTasksWithUserInFinished, 0); + this.logger.log( + `Successfully updated ${counterOfTasksWithUserInFinished} Tasks without userId ${userId} in archive collection in Tasks` + ); + + return result; + } } diff --git a/apps/server/src/modules/task/task.module.ts b/apps/server/src/modules/task/task.module.ts index 87ecf144798..bd68fa8c5ef 100644 --- a/apps/server/src/modules/task/task.module.ts +++ b/apps/server/src/modules/task/task.module.ts @@ -2,10 +2,11 @@ import { CopyHelperModule } from '@modules/copy-helper'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { Module } from '@nestjs/common'; import { CourseRepo, SubmissionRepo, TaskRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { SubmissionService, TaskCopyService, TaskService } from './service'; @Module({ - imports: [FilesStorageClientModule, CopyHelperModule], + imports: [FilesStorageClientModule, CopyHelperModule, LoggerModule], providers: [TaskService, TaskCopyService, SubmissionService, TaskRepo, CourseRepo, SubmissionRepo], exports: [TaskService, TaskCopyService, SubmissionService], }) diff --git a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts index ca64669c166..66b0b2ecf4f 100644 --- a/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts +++ b/apps/server/src/modules/tool/common/controller/dto/context-external-tool-configuration-status.response.ts @@ -15,8 +15,15 @@ export class ContextExternalToolConfigurationStatusResponse { }) isOutdatedOnScopeContext: boolean; + @ApiProperty({ + type: Boolean, + description: 'Is the tool deactivated, because of superhero or school administrator', + }) + isDeactivated: boolean; + constructor(props: ContextExternalToolConfigurationStatusResponse) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; this.isOutdatedOnScopeContext = props.isOutdatedOnScopeContext; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts b/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts index be533e50212..ac66651841a 100644 --- a/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts +++ b/apps/server/src/modules/tool/common/domain/context-external-tool-configuration-status.ts @@ -3,8 +3,11 @@ export class ContextExternalToolConfigurationStatus { isOutdatedOnScopeContext: boolean; + isDeactivated: boolean; + constructor(props: ContextExternalToolConfigurationStatus) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; this.isOutdatedOnScopeContext = props.isOutdatedOnScopeContext; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts index 7e75fdd81ae..3ba0b6d9328 100644 --- a/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts +++ b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts @@ -7,6 +7,7 @@ export class ToolStatusResponseMapper { new ContextExternalToolConfigurationStatusResponse({ isOutdatedOnScopeSchool: status.isOutdatedOnScopeSchool, isOutdatedOnScopeContext: status.isOutdatedOnScopeContext, + isDeactivated: status.isDeactivated, }); return configurationStatus; diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts index cabd791a766..a47489f0892 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts @@ -86,6 +86,7 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: false, }) ); }); @@ -117,6 +118,7 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: false, }) ); }); @@ -210,6 +212,7 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isDeactivated: false, }) ); }); @@ -241,6 +244,76 @@ describe('CommonToolService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isDeactivated: false, + }) + ); + }); + }); + + describe('when schoolExternalTool is deactivated', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1 }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolVersion: 2, + status: { isDeactivated: true }, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 2 }); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return a configuration status with deactivated true', () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const result: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(result).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isDeactivated: true, + }) + ); + }); + }); + + describe('when externalTool is deactivated', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId({ version: 1, isDeactivated: true }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolVersion: 2, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ toolVersion: 2 }); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return a configuration status with deactivated true', () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const result: ContextExternalToolConfigurationStatus = service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(result).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isDeactivated: true, }) ); }); diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.ts b/apps/server/src/modules/tool/common/service/common-tool.service.ts index b1c8d0e8da2..9b5404f7ae7 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.ts @@ -20,6 +20,7 @@ export class CommonToolService { const configurationStatus: ContextExternalToolConfigurationStatus = new ContextExternalToolConfigurationStatus({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: false, }); if ( @@ -34,6 +35,10 @@ export class CommonToolService { configurationStatus.isOutdatedOnScopeSchool = true; } + if (externalTool.isDeactivated || schoolExternalTool.status?.isDeactivated) { + configurationStatus.isDeactivated = true; + } + return configurationStatus; } diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts index 7edeca8459e..f55651f738b 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.spec.ts @@ -5,6 +5,7 @@ import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory, + schoolToolConfigurationStatusFactory, toolConfigurationStatusFactory, } from '@shared/testing'; import { ContextExternalToolConfigurationStatus } from '../../common/domain'; @@ -319,5 +320,132 @@ describe('ToolVersionService', () => { expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); }); }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and SchoolExternalTool is deactivated', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + schoolExternalTool.status = schoolToolConfigurationStatusFactory.build({ isDeactivated: true }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return status is deactivated', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: true, + isOutdatedOnScopeSchool: true, + isDeactivated: true, + }) + ); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and externalTool is deactivated', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({ + isDeactivated: true, + }); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return deactivated tool status', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: true, + isOutdatedOnScopeSchool: true, + isDeactivated: true, + }) + ); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true, externalTool and schoolExternalTool are not deactivated', () => { + const setup = () => { + const externalTool = externalToolFactory.buildWithId({}); + + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(); + + toolFeatures.toolStatusWithoutVersions = true; + + schoolExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + contextExternalToolValidationService.validate.mockRejectedValueOnce(Promise.resolve()); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return deactivated tool status', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const status: ContextExternalToolConfigurationStatus = await service.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + expect(status).toEqual( + toolConfigurationStatusFactory.build({ + isOutdatedOnScopeContext: true, + isOutdatedOnScopeSchool: true, + isDeactivated: false, + }) + ); + }); + }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts index 191e1d0cc77..afe8110a88a 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-version-service.ts @@ -28,6 +28,7 @@ export class ToolVersionService { const configurationStatus: ContextExternalToolConfigurationStatus = new ContextExternalToolConfigurationStatus({ isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isDeactivated: this.isToolDeactivated(externalTool, schoolExternalTool), }); try { @@ -52,4 +53,12 @@ export class ToolVersionService { return status; } + + private isToolDeactivated(externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool) { + if (externalTool.isDeactivated || (schoolExternalTool.status && schoolExternalTool.status.isDeactivated)) { + return true; + } + + return false; + } } diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts index 93da1d956b2..e41be01e880 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts @@ -87,6 +87,7 @@ describe('ToolController (API)', () => { baseUrl: 'https://link.to-my-tool.com/:key', }, isHidden: false, + isDeactivated: false, logoUrl: 'https://link.to-my-logo.com', url: 'https://link.to-my-tool.com', openNewTab: true, @@ -151,6 +152,7 @@ describe('ToolController (API)', () => { baseUrl: 'https://link.to-my-tool.com/:key', }, isHidden: false, + isDeactivated: false, logoUrl: 'https://link.to-my-logo.com', url: 'https://link.to-my-tool.com', openNewTab: true, @@ -382,6 +384,7 @@ describe('ToolController (API)', () => { baseUrl: 'https://link.to-my-tool.com/:key', }, isHidden: false, + isDeactivated: false, logoUrl: 'https://link.to-my-logo.com', url: 'https://link.to-my-tool.com', openNewTab: true, @@ -449,6 +452,7 @@ describe('ToolController (API)', () => { baseUrl: 'https://link.to-my-tool.com/:key', }, isHidden: false, + isDeactivated: false, logoUrl: 'https://link.to-my-logo.com', url: 'https://link.to-my-tool.com', openNewTab: true, diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts index 780535f3626..b3e54a14d9b 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts @@ -58,6 +58,14 @@ export class ExternalToolCreateParams { @ApiProperty() isHidden!: boolean; + @IsBoolean() + @ApiProperty({ + type: Boolean, + default: false, + description: 'Tool can be deactivated, related tools can not be added to e.g. school, course or board anymore', + }) + isDeactivated!: boolean; + @IsBoolean() @ApiProperty() openNewTab!: boolean; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts index be19e9c0bde..a3b642f496e 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts @@ -62,6 +62,13 @@ export class ExternalToolUpdateParams { @ApiProperty() isHidden!: boolean; + @IsBoolean() + @ApiProperty({ + type: Boolean, + default: false, + }) + isDeactivated!: boolean; + @IsBoolean() @ApiProperty() openNewTab!: boolean; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts index dc20b85b520..b77d170a876 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts @@ -25,6 +25,9 @@ export class ExternalToolResponse { @ApiProperty() isHidden: boolean; + @ApiProperty() + isDeactivated: boolean; + @ApiProperty() openNewTab: boolean; @@ -42,6 +45,7 @@ export class ExternalToolResponse { this.config = response.config; this.parameters = response.parameters; this.isHidden = response.isHidden; + this.isDeactivated = response.isDeactivated; this.openNewTab = response.openNewTab; this.version = response.version; this.restrictToContexts = response.restrictToContexts; diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts index 22c77510b3d..b47fef01f6a 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.spec.ts @@ -60,6 +60,7 @@ describe('ExternalTool', () => { isHidden: false, openNewTab: false, config: basicToolConfigFactory.build(), + isDeactivated: false, }); }).toThrowError(); }); diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts index 9460fad9bc8..4bb48e21ab1 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts @@ -22,6 +22,8 @@ export interface ExternalToolProps { isHidden: boolean; + isDeactivated: boolean; + openNewTab: boolean; version: number; @@ -44,6 +46,8 @@ export class ExternalTool extends BaseDO implements ToolVersion { isHidden: boolean; + isDeactivated: boolean; + openNewTab: boolean; version: number; @@ -68,6 +72,7 @@ export class ExternalTool extends BaseDO implements ToolVersion { } this.parameters = props.parameters; this.isHidden = props.isHidden; + this.isDeactivated = props.isDeactivated; this.openNewTab = props.openNewTab; this.version = props.version; this.restrictToContexts = props.restrictToContexts; diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts index 9d037046942..9b144b2c725 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts @@ -57,6 +57,7 @@ describe('ExternalToolEntity', () => { config: basicToolConfig, parameters: [customParameter], isHidden: true, + isDeactivated: false, openNewTab: true, version: 1, }); diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts index 8a80404fe14..bc79a891392 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts @@ -31,6 +31,9 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { @Property() isHidden: boolean; + @Property() + isDeactivated: boolean; + @Property() openNewTab: boolean; @@ -49,6 +52,7 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { this.config = props.config; this.parameters = props.parameters; this.isHidden = props.isHidden; + this.isDeactivated = props.isDeactivated; this.openNewTab = props.openNewTab; this.version = props.version; this.restrictToContexts = props.restrictToContexts; diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts index cf29d8e874c..8089b5b4f87 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts @@ -81,6 +81,7 @@ describe('ExternalToolRequestMapper', () => { externalToolCreateParams.isHidden = true; externalToolCreateParams.openNewTab = true; externalToolCreateParams.config = basicConfigParams; + externalToolCreateParams.isDeactivated = true; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -109,6 +110,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: basicToolConfigDO, + isDeactivated: true, }); return { @@ -170,6 +172,7 @@ describe('ExternalToolRequestMapper', () => { externalToolCreateParams.isHidden = true; externalToolCreateParams.openNewTab = true; externalToolCreateParams.config = lti11ConfigParams; + externalToolCreateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -193,6 +196,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigDO, + isDeactivated: false, }); return { @@ -256,6 +260,7 @@ describe('ExternalToolRequestMapper', () => { externalToolCreateParams.isHidden = true; externalToolCreateParams.openNewTab = true; externalToolCreateParams.config = oauth2ConfigParams; + externalToolCreateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -279,6 +284,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigDO, + isDeactivated: false, }); return { @@ -326,6 +332,7 @@ describe('ExternalToolRequestMapper', () => { externalToolUpdateParams.isHidden = true; externalToolUpdateParams.openNewTab = true; externalToolUpdateParams.config = basicConfigParams; + externalToolUpdateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -355,6 +362,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: basicToolConfigDO, + isDeactivated: false, }, externalToolUpdateParams.id ); @@ -419,6 +427,7 @@ describe('ExternalToolRequestMapper', () => { externalToolUpdateParams.isHidden = true; externalToolUpdateParams.openNewTab = true; externalToolUpdateParams.config = lti11ConfigParams; + externalToolUpdateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -443,6 +452,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigDO, + isDeactivated: false, }, externalToolUpdateParams.id ); @@ -509,6 +519,7 @@ describe('ExternalToolRequestMapper', () => { externalToolUpdateParams.isHidden = true; externalToolUpdateParams.openNewTab = true; externalToolUpdateParams.config = oauth2ConfigParams; + externalToolUpdateParams.isDeactivated = false; const customParameterDO: CustomParameter = customParameterFactory.build({ name: 'mockName', @@ -533,6 +544,7 @@ describe('ExternalToolRequestMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigDO, + isDeactivated: false, }, externalToolUpdateParams.id ); diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts index c57e0ee457a..3ba9d04db37 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts @@ -79,6 +79,7 @@ export class ExternalToolRequestMapper { config: mappedConfig, parameters: mappedCustomParameter, isHidden: externalToolUpdateParams.isHidden, + isDeactivated: externalToolUpdateParams.isDeactivated, openNewTab: externalToolUpdateParams.openNewTab, version, restrictToContexts: externalToolUpdateParams.restrictToContexts, @@ -106,6 +107,7 @@ export class ExternalToolRequestMapper { config: mappedConfig, parameters: mappedCustomParameter, isHidden: externalToolCreateParams.isHidden, + isDeactivated: externalToolCreateParams.isDeactivated, openNewTab: externalToolCreateParams.openNewTab, version, restrictToContexts: externalToolCreateParams.restrictToContexts, diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts index 0a491222081..e87502a5d21 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts @@ -61,6 +61,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: basicToolConfigResponse, + isDeactivated: true, }); const basicToolConfig: BasicToolConfig = basicToolConfigFactory.build({ @@ -91,6 +92,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: basicToolConfig, + isDeactivated: true, }); return { @@ -157,6 +159,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigResponse, + isDeactivated: false, }); const customParameter: CustomParameter = customParameterFactory.build({ @@ -182,6 +185,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: oauth2ToolConfigDO, + isDeactivated: false, }); return { @@ -245,6 +249,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigResponse, + isDeactivated: false, }); const customParameter: CustomParameter = customParameterFactory.build({ @@ -269,6 +274,7 @@ describe('ExternalToolResponseMapper', () => { openNewTab: true, version: 1, config: lti11ToolConfigDO, + isDeactivated: false, }); return { diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts index 7be202132ef..283f49906cf 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts @@ -63,6 +63,7 @@ export class ExternalToolResponseMapper { config: mappedConfig, parameters: mappedCustomParameter, isHidden: externalTool.isHidden, + isDeactivated: externalTool.isDeactivated, openNewTab: externalTool.openNewTab, version: externalTool.version, restrictToContexts: externalTool.restrictToContexts, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts index 6e6f7159e80..ae0722d937c 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts @@ -6,6 +6,7 @@ import { customParameterFactory, externalToolFactory, schoolExternalToolFactory, + schoolToolConfigurationStatusFactory, setupEntities, } from '@shared/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; @@ -65,9 +66,11 @@ describe('ExternalToolConfigurationService', () => { externalToolFactory.buildWithId(undefined, 'usedToolId'), externalToolFactory.buildWithId(undefined, 'unusedToolId'), ]; + const externalTools: ExternalTool[] = [ ...notHiddenTools, externalToolFactory.buildWithId({ isHidden: true }, 'hiddenToolId'), + externalToolFactory.buildWithId({ isDeactivated: true }, 'deactivatedToolId'), ]; const externalToolsPage: Page = new Page(externalTools, externalTools.length); const toolIdsInUse: EntityId[] = ['usedToolId', 'hiddenToolId']; @@ -98,6 +101,14 @@ describe('ExternalToolConfigurationService', () => { expect(result.length).toBe(notHiddenTools.length); }); + + it('should filter out deactivated tools', () => { + const { externalToolsPage, toolIdsInUse } = setup(); + + const result: ExternalTool[] = service.filterForAvailableTools(externalToolsPage, toolIdsInUse); + + expect(result.some((tool) => tool.id !== 'deactivatedToolId')).toBe(true); + }); }); }); @@ -176,9 +187,26 @@ describe('ExternalToolConfigurationService', () => { const availableSchoolExternalTools: SchoolExternalTool[] = [ schoolExternalToolFactory.buildWithId({ toolId: usedExternalToolId }, 'usedSchoolExternalToolId'), schoolExternalToolFactory.buildWithId(undefined, 'unusedSchoolExternalToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'deactivatedToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'deactivatedToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'deactivatedToolId'), + schoolExternalToolFactory.buildWithId(undefined, 'unusedSchoolExternalToolId'), schoolExternalToolFactory.buildWithId({ toolId: usedExternalToolHiddenId }, 'usedSchoolExternalToolHiddenId'), ]; + availableSchoolExternalTools.forEach((tool): void => { + if (tool.id === 'deactivatedToolId') { + tool.status = schoolToolConfigurationStatusFactory.build({ + isDeactivated: true, + isOutdatedOnScopeSchool: false, + }); + } + tool.status = schoolToolConfigurationStatusFactory.build({ + isDeactivated: false, + isOutdatedOnScopeSchool: false, + }); + }); + return { externalTools, availableSchoolExternalTools }; }; @@ -192,6 +220,34 @@ describe('ExternalToolConfigurationService', () => { expect(result.every((toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.externalTool.isHidden)).toBe(true); }); + + it('should filter out deactivated external tools', () => { + const { externalTools, availableSchoolExternalTools } = setup(); + + const result: ContextExternalToolTemplateInfo[] = service.filterForAvailableExternalTools( + externalTools, + availableSchoolExternalTools + ); + + expect(result.every((toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.externalTool.isDeactivated)).toBe( + true + ); + }); + + it('should filter out deactivated school external tools', () => { + const { externalTools, availableSchoolExternalTools } = setup(); + + const result: ContextExternalToolTemplateInfo[] = service.filterForAvailableExternalTools( + externalTools, + availableSchoolExternalTools + ); + + expect( + result.every( + (toolInfo: ContextExternalToolTemplateInfo) => !toolInfo.schoolExternalTool.status?.isDeactivated + ) + ).toBe(true); + }); }); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts index da852d195c0..8fd21257815 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts @@ -20,9 +20,9 @@ export class ExternalToolConfigurationService { public filterForAvailableTools(externalTools: Page, toolIdsInUse: EntityId[]): ExternalTool[] { const visibleTools: ExternalTool[] = externalTools.data.filter((tool: ExternalTool): boolean => !tool.isHidden); - const availableTools: ExternalTool[] = visibleTools.filter( - (tool: ExternalTool): boolean => !!tool.id && !toolIdsInUse.includes(tool.id) - ); + const availableTools: ExternalTool[] = visibleTools + .filter((tool: ExternalTool): boolean => !!tool.id && !toolIdsInUse.includes(tool.id)) + .filter((tool) => !tool.isDeactivated); return availableTools; } @@ -72,9 +72,10 @@ export class ExternalToolConfigurationService { const unusedTools: ContextExternalToolTemplateInfo[] = toolsWithSchoolTool.filter( (toolRef): toolRef is ContextExternalToolTemplateInfo => !!toolRef ); - const availableTools: ContextExternalToolTemplateInfo[] = unusedTools.filter( - (toolRef): toolRef is ContextExternalToolTemplateInfo => !toolRef.externalTool.isHidden - ); + const availableTools: ContextExternalToolTemplateInfo[] = unusedTools + .filter((toolRef): toolRef is ContextExternalToolTemplateInfo => !toolRef.externalTool.isHidden) + .filter((toolRef) => !toolRef.externalTool.isDeactivated) + .filter((toolRef) => !toolRef.schoolExternalTool.status?.isDeactivated); return availableTools; } diff --git a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts index 707546ba55e..a2daafe5f69 100644 --- a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts +++ b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts @@ -31,6 +31,8 @@ export type ExternalToolDto = { isHidden: boolean; + isDeactivated: boolean; + openNewTab: boolean; version: number; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 8f7f13c1d13..04c9d0d7b5d 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -96,6 +96,7 @@ describe('ToolSchoolController (API)', () => { { name: 'param1', value: 'value' }, { name: 'param2', value: '' }, ], + isDeactivated: false, }; em.persist([ @@ -458,6 +459,7 @@ describe('ToolSchoolController (API)', () => { schoolId: school.id, version: 1, parameters: [paramEntry], + isDeactivated: false, }; const updatedParamEntry: CustomParameterEntryParam = { name: 'param1', value: 'updatedValue' }; @@ -466,6 +468,7 @@ describe('ToolSchoolController (API)', () => { schoolId: school.id, version: 1, parameters: [updatedParamEntry], + isDeactivated: false, }; const schoolExternalToolResponse: SchoolExternalToolResponse = new SchoolExternalToolResponse({ diff --git a/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts b/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts index 8e86a8894e2..b8dcfcd13d3 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/domain/school-external-tool-configuration-status.ts @@ -1,7 +1,10 @@ export class SchoolExternalToolConfigurationStatus { isOutdatedOnScopeSchool: boolean; + isDeactivated: boolean; + constructor(props: SchoolExternalToolConfigurationStatus) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts index b8bfe811f83..36d500ba88e 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-configuration.response.ts @@ -8,7 +8,14 @@ export class SchoolExternalToolConfigurationStatusResponse { }) isOutdatedOnScopeSchool: boolean; + @ApiProperty({ + type: Boolean, + description: 'Is the tool deactivated, because of school administrator?', + }) + isDeactivated: boolean; + constructor(props: SchoolExternalToolConfigurationStatusResponse) { this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; + this.isDeactivated = props.isDeactivated; } } diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts index e65b1df0dd5..e21be39714b 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool-post.params.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsMongoId, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsBoolean, IsMongoId, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; import { CustomParameterEntryParam } from './custom-parameter-entry.params'; export class SchoolExternalToolPostParams { @@ -21,6 +21,14 @@ export class SchoolExternalToolPostParams { @Type(() => CustomParameterEntryParam) parameters?: CustomParameterEntryParam[]; + @ApiProperty({ + type: Boolean, + default: false, + description: 'Tool can be deactivated, related tools can not be added to e.g. course or board anymore', + }) + @IsBoolean() + isDeactivated!: boolean; + @ApiProperty() @IsNumber() version!: number; diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts new file mode 100644 index 00000000000..ded662d34a6 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.spec.ts @@ -0,0 +1,38 @@ +import { setupEntities } from '@shared/testing'; +import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; +import { SchoolExternalToolConfigurationStatusEntity } from './school-external-tool-configuration-status.entity'; + +describe('SchoolExternalToolConfigurationStatusEntity', () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new SchoolExternalToolConfigurationStatusEntity(); + expect(test).toThrow(); + }); + + it('should create a school external tool configuration status by passing required properties', () => { + const schoolExternalToolConfigurationStatusEntity: SchoolExternalToolConfigurationStatusEntity = + schoolExternalToolConfigurationStatusEntityFactory.build(); + expect( + schoolExternalToolConfigurationStatusEntity instanceof SchoolExternalToolConfigurationStatusEntity + ).toEqual(false); + }); + + it('should set school external tool status', () => { + const schoolExternalToolConfigurationStatusEntity: SchoolExternalToolConfigurationStatusEntity = + new SchoolExternalToolConfigurationStatusEntity({ + isDeactivated: true, + isOutdatedOnScopeSchool: false, + }); + + expect(schoolExternalToolConfigurationStatusEntity).toEqual({ + isDeactivated: true, + isOutdatedOnScopeSchool: false, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts new file mode 100644 index 00000000000..ea071f996e1 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool-configuration-status.entity.ts @@ -0,0 +1,15 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +@Embeddable() +export class SchoolExternalToolConfigurationStatusEntity { + @Property() + isOutdatedOnScopeSchool: boolean; + + @Property() + isDeactivated: boolean; + + constructor(props: SchoolExternalToolConfigurationStatusEntity) { + this.isOutdatedOnScopeSchool = props.isOutdatedOnScopeSchool; + this.isDeactivated = props.isDeactivated; + } +} diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts index c3b03b2d0cd..932ec713f54 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts @@ -1,11 +1,13 @@ -import { schoolFactory, setupEntities } from '@shared/testing'; -import { schoolExternalToolEntityFactory } from '@shared/testing/factory/school-external-tool-entity.factory'; import { - BasicToolConfigEntity, - CustomParameterEntity, - ExternalToolEntity, - ExternalToolConfigEntity, -} from '../../external-tool/entity'; + basicToolConfigFactory, + customParameterEntityFactory, + externalToolEntityFactory, + schoolFactory, + setupEntities, +} from '@shared/testing'; +import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; +import { schoolExternalToolEntityFactory } from '@shared/testing/factory/school-external-tool-entity.factory'; +import { CustomParameterEntity, ExternalToolEntity, ExternalToolConfigEntity } from '../../external-tool/entity'; import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolConfigType } from '../../common/enum'; import { SchoolExternalToolEntity } from './school-external-tool.entity'; @@ -27,11 +29,11 @@ describe('SchoolExternalToolEntity', () => { }); it('should set schoolParameters to empty when is undefined', () => { - const externalToolConfigEntity: ExternalToolConfigEntity = new BasicToolConfigEntity({ + const externalToolConfigEntity: ExternalToolConfigEntity = basicToolConfigFactory.buildWithId({ type: ToolConfigType.OAUTH2, baseUrl: 'mockBaseUrl', }); - const customParameter: CustomParameterEntity = new CustomParameterEntity({ + const customParameter: CustomParameterEntity = customParameterEntityFactory.build({ name: 'parameterName', displayName: 'User Friendly Name', default: 'mock', @@ -43,7 +45,7 @@ describe('SchoolExternalToolEntity', () => { isOptional: false, isProtected: false, }); - const externalToolEntity: ExternalToolEntity = new ExternalToolEntity({ + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ name: 'toolName', url: 'mockUrl', logoUrl: 'mockLogoUrl', @@ -52,15 +54,56 @@ describe('SchoolExternalToolEntity', () => { isHidden: true, openNewTab: true, version: 1, + isDeactivated: false, }); - const schoolExternalToolEntity: SchoolExternalToolEntity = new SchoolExternalToolEntity({ + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, school: schoolFactory.buildWithId(), schoolParameters: [], toolVersion: 1, + status: schoolExternalToolConfigurationStatusEntityFactory.build(), }); expect(schoolExternalToolEntity.schoolParameters).toEqual([]); }); + + it('should set school external tool configuration status', () => { + const externalToolConfigEntity: ExternalToolConfigEntity = basicToolConfigFactory.buildWithId({ + type: ToolConfigType.OAUTH2, + baseUrl: 'mockBaseUrl', + }); + const customParameter: CustomParameterEntity = customParameterEntityFactory.build({ + name: 'parameterName', + displayName: 'User Friendly Name', + default: 'mock', + location: CustomParameterLocation.PATH, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: 'mockRegex', + regexComment: 'mockComment', + isOptional: false, + isProtected: false, + }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + name: 'toolName', + url: 'mockUrl', + logoUrl: 'mockLogoUrl', + config: externalToolConfigEntity, + parameters: [customParameter], + isHidden: true, + openNewTab: true, + version: 1, + isDeactivated: false, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school: schoolFactory.buildWithId(), + schoolParameters: [], + toolVersion: 1, + status: schoolExternalToolConfigurationStatusEntityFactory.build(), + }); + + expect(schoolExternalToolEntity.status).toEqual({ isDeactivated: false, isOutdatedOnScopeSchool: false }); + }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts index b5545239042..2662a5d3986 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts @@ -3,12 +3,14 @@ import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; import { CustomParameterEntryEntity } from '../../common/entity'; import { ExternalToolEntity } from '../../external-tool/entity'; +import { SchoolExternalToolConfigurationStatusEntity } from './school-external-tool-configuration-status.entity'; export interface SchoolExternalToolProperties { tool: ExternalToolEntity; school: SchoolEntity; schoolParameters?: CustomParameterEntryEntity[]; toolVersion: number; + status?: SchoolExternalToolConfigurationStatusEntity; } @Entity({ tableName: 'school-external-tools' }) @@ -25,11 +27,15 @@ export class SchoolExternalToolEntity extends BaseEntityWithTimestamps { @Property() toolVersion: number; + @Embedded(() => SchoolExternalToolConfigurationStatusEntity, { object: true, nullable: true }) + status?: SchoolExternalToolConfigurationStatusEntity; + constructor(props: SchoolExternalToolProperties) { super(); this.tool = props.tool; this.school = props.school; this.schoolParameters = props.schoolParameters ?? []; this.toolVersion = props.toolVersion; + this.status = props.status; } } diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts index 002f2c571cc..dbbc9717c04 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.spec.ts @@ -1,3 +1,4 @@ +import { schoolToolConfigurationStatusFactory } from '@shared/testing'; import { SchoolExternalToolRequestMapper } from './school-external-tool-request.mapper'; import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; @@ -17,6 +18,7 @@ describe('SchoolExternalToolRequestMapper', () => { version: 1, schoolId: 'schoolId', parameters: [param], + isDeactivated: true, }; return { @@ -35,6 +37,7 @@ describe('SchoolExternalToolRequestMapper', () => { parameters: [{ name: param.name, value: param.value }], schoolId: params.schoolId, toolVersion: params.version, + status: schoolToolConfigurationStatusFactory.build({ isDeactivated: true }), }); }); }); @@ -46,6 +49,7 @@ describe('SchoolExternalToolRequestMapper', () => { version: 1, schoolId: 'schoolId', parameters: undefined, + isDeactivated: false, }; return { @@ -63,6 +67,7 @@ describe('SchoolExternalToolRequestMapper', () => { parameters: [], schoolId: params.schoolId, toolVersion: params.version, + status: schoolToolConfigurationStatusFactory.build(), }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts index eff05c092cb..3fb0da2ee5b 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-request.mapper.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; import { CustomParameterEntry } from '../../common/domain'; -import { CustomParameterEntryParam, SchoolExternalToolPostParams } from '../controller/dto'; +import { + CustomParameterEntryParam, + SchoolExternalToolConfigurationStatus, + SchoolExternalToolPostParams, +} from '../controller/dto'; import { SchoolExternalToolDto } from '../uc/dto/school-external-tool.types'; @Injectable() @@ -11,6 +15,10 @@ export class SchoolExternalToolRequestMapper { schoolId: request.schoolId, toolVersion: request.version, parameters: this.mapRequestToCustomParameterEntryDO(request.parameters ?? []), + status: new SchoolExternalToolConfigurationStatus({ + isOutdatedOnScopeSchool: false, + isDeactivated: request.isDeactivated, + }), }; } diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts index eb2e0da2fe5..73580d89633 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts @@ -58,6 +58,7 @@ describe('SchoolExternalToolResponseMapper', () => { ], status: schoolToolConfigurationStatusResponseFactory.build({ isOutdatedOnScopeSchool: false, + isDeactivated: false, }), }, { @@ -74,6 +75,7 @@ describe('SchoolExternalToolResponseMapper', () => { ], status: schoolToolConfigurationStatusFactory.build({ isOutdatedOnScopeSchool: false, + isDeactivated: false, }), }, ]) @@ -108,6 +110,7 @@ describe('SchoolExternalToolResponseMapper', () => { name: '', status: schoolToolConfigurationStatusResponseFactory.build({ isOutdatedOnScopeSchool: false, + isDeactivated: false, }), }) ); diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts index 7f48f554003..d0b984a77b8 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts @@ -26,7 +26,7 @@ export class SchoolExternalToolResponseMapper { parameters: this.mapToCustomParameterEntryResponse(schoolExternalTool.parameters), toolVersion: schoolExternalTool.toolVersion, status: SchoolToolConfigurationStatusResponseMapper.mapToResponse( - schoolExternalTool.status ?? { isOutdatedOnScopeSchool: false } + schoolExternalTool.status ?? { isOutdatedOnScopeSchool: false, isDeactivated: false } ), }; } diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts index 290ad5c084a..001efe071c0 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-status-response.mapper.ts @@ -6,6 +6,7 @@ export class SchoolToolConfigurationStatusResponseMapper { const configurationStatus: SchoolExternalToolConfigurationStatusResponse = new SchoolExternalToolConfigurationStatusResponse({ isOutdatedOnScopeSchool: status.isOutdatedOnScopeSchool, + isDeactivated: status.isDeactivated, }); return configurationStatus; diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts index 43875b3d55c..ad0183f4536 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts @@ -191,6 +191,19 @@ describe('SchoolExternalToolService', () => { }) ); }); + + it('should return non deactivated tool status', async () => { + const { schoolExternalTool } = setup(); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); + + expect(schoolExternalToolValidationService.validate).toHaveBeenCalledWith(schoolExternalTool); + expect(schoolExternalToolDOs[0].status).toEqual( + schoolToolConfigurationStatusFactory.build({ + isDeactivated: false, + }) + ); + }); }); describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and validation throws error', () => { @@ -221,6 +234,66 @@ describe('SchoolExternalToolService', () => { ); }); }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and schoolExternalTool is deactivated', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + status: schoolToolConfigurationStatusFactory.build({ isDeactivated: true }), + }); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolValidationService.validate.mockRejectedValue(Promise.resolve()); + toolFearures.toolStatusWithoutVersions = true; + + return { + schoolExternalTool, + }; + }; + + it('should return deactivated tool status true', async () => { + const { schoolExternalTool } = setup(); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); + + expect(schoolExternalToolDOs[0].status).toEqual( + schoolToolConfigurationStatusFactory.build({ + isDeactivated: true, + isOutdatedOnScopeSchool: true, + }) + ); + }); + }); + + describe('when FEATURE_COMPUTE_TOOL_STATUS_WITHOUT_VERSIONS_ENABLED is true and externalTool is deactivated', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ isDeactivated: true }); + + schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); + externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolValidationService.validate.mockRejectedValue(Promise.resolve()); + toolFearures.toolStatusWithoutVersions = true; + + return { + schoolExternalTool, + }; + }; + + it('should return deactivated tool status true', async () => { + const { schoolExternalTool } = setup(); + + const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); + + expect(schoolExternalToolDOs[0].status).toEqual( + schoolToolConfigurationStatusFactory.build({ + isDeactivated: true, + isOutdatedOnScopeSchool: true, + }) + ); + }); + }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts index b396147e72c..0d58bcf00d0 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts @@ -59,6 +59,7 @@ export class SchoolExternalToolService { ): Promise { const status: SchoolExternalToolConfigurationStatus = new SchoolExternalToolConfigurationStatus({ isOutdatedOnScopeSchool: true, + isDeactivated: this.isToolDeactivated(externalTool, tool), }); if (this.toolFeatures.toolStatusWithoutVersions) { @@ -91,4 +92,12 @@ export class SchoolExternalToolService { createdSchoolExternalTool = await this.enrichDataFromExternalTool(createdSchoolExternalTool); return createdSchoolExternalTool; } + + private isToolDeactivated(externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool) { + if (externalTool.isDeactivated || schoolExternalTool.status?.isDeactivated) { + return true; + } + + return false; + } } diff --git a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts index aa35059b564..d313cae5d50 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts @@ -16,6 +16,7 @@ import { schoolFactory, customParameterFactory, } from '@shared/testing'; +import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; import { Response } from 'supertest'; import { CustomParameterLocation, CustomParameterScope, ToolConfigType } from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; @@ -174,6 +175,114 @@ describe('ToolLaunchController (API)', () => { }); }); + describe('when user wants to launch a deactivated tool', () => { + describe('when external tool is deactivated', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + version: 1, + isDeactivated: true, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + toolVersion: 0, + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + toolVersion: 0, + }); + + const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { params, loggedInClient }; + }; + + it('should return a bad request', async () => { + const { params, loggedInClient } = await setup(); + + const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when school external tool is deactivated', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + version: 1, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + toolVersion: 0, + status: schoolExternalToolConfigurationStatusEntityFactory.build({ + isDeactivated: true, + }), + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + toolVersion: 0, + }); + + const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { params, loggedInClient }; + }; + + it('should return a bad request', async () => { + const { params, loggedInClient } = await setup(); + + const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + }); + describe('when user wants to launch tool from another school', () => { const setup = async () => { const toolSchool: SchoolEntity = schoolFactory.buildWithId(); diff --git a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts index 168d32e1f12..814dfad394c 100644 --- a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts @@ -12,7 +12,8 @@ describe('ToolStatusOutdatedLoggableException', () => { userId, toolId, toolConfigStatus.isOutdatedOnScopeSchool, - toolConfigStatus.isOutdatedOnScopeContext + toolConfigStatus.isOutdatedOnScopeContext, + toolConfigStatus.isDeactivated ); return { @@ -34,6 +35,7 @@ describe('ToolStatusOutdatedLoggableException', () => { toolId: 'toolId', isOutdatedOnScopeSchool: false, isOutdatedOnScopeContext: false, + isDeactivated: false, }, }); }); diff --git a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts index 59a4fea3766..84b358e2ec6 100644 --- a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts +++ b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.ts @@ -7,7 +7,8 @@ export class ToolStatusOutdatedLoggableException extends BadRequestException imp private readonly userId: EntityId, private readonly toolId: EntityId, private readonly isOutdatedOnScopeSchool: boolean, - private readonly isOutdatedOnScopeContext: boolean + private readonly isOutdatedOnScopeContext: boolean, + private readonly isDeactivated: boolean ) { super(); } @@ -22,6 +23,7 @@ export class ToolStatusOutdatedLoggableException extends BadRequestException imp toolId: this.toolId, isOutdatedOnScopeSchool: this.isOutdatedOnScopeSchool, isOutdatedOnScopeContext: this.isOutdatedOnScopeContext, + isDeactivated: this.isDeactivated, }, }; } diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index 8bfae71cce1..30ce3c20cbf 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -200,7 +200,7 @@ describe('ToolLaunchService', () => { }); }); - describe('when tool configuration status is not LATEST', () => { + describe('when tool configuration status is not launchable', () => { const setup = () => { const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const contextExternalTool: ContextExternalTool = contextExternalToolFactory @@ -233,6 +233,7 @@ describe('ToolLaunchService', () => { toolConfigurationStatusFactory.build({ isOutdatedOnScopeContext: true, isOutdatedOnScopeSchool: true, + isDeactivated: true, }) ); @@ -249,7 +250,7 @@ describe('ToolLaunchService', () => { const func = () => service.getLaunchData(userId, launchParams.contextExternalTool); await expect(func).rejects.toThrow( - new ToolStatusOutdatedLoggableException(userId, contextExternalToolId, true, true) + new ToolStatusOutdatedLoggableException(userId, contextExternalToolId, true, true, true) ); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts index bb323017967..8378926a107 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts @@ -54,7 +54,7 @@ export class ToolLaunchService { const { externalTool, schoolExternalTool } = await this.loadToolHierarchy(schoolExternalToolId); - await this.isToolStatusLatestOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); + await this.isToolStatusLaunchableOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); const strategy: ToolLaunchStrategy | undefined = this.strategies.get(externalTool.config.type); @@ -84,7 +84,7 @@ export class ToolLaunchService { }; } - private async isToolStatusLatestOrThrow( + private async isToolStatusLaunchableOrThrow( userId: EntityId, externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, @@ -97,12 +97,13 @@ export class ToolLaunchService { contextExternalTool ); - if (status.isOutdatedOnScopeSchool || status.isOutdatedOnScopeContext) { + if (status.isOutdatedOnScopeSchool || status.isOutdatedOnScopeContext || status.isDeactivated) { throw new ToolStatusOutdatedLoggableException( userId, contextExternalTool.id ?? '', status.isOutdatedOnScopeSchool, - status.isOutdatedOnScopeContext + status.isOutdatedOnScopeContext, + status.isDeactivated ); } } diff --git a/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts b/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts new file mode 100644 index 00000000000..940224c3cf7 --- /dev/null +++ b/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts @@ -0,0 +1,29 @@ +import { DomainModel } from '@shared/domain/types'; +import { ObjectId } from 'bson'; +import { DomainOperationBuilder } from '.'; + +describe(DomainOperationBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const domain = DomainModel.PSEUDONYMS; + const modifiedCount = 0; + const modifiedRef = []; + const deletedRef = [new ObjectId().toHexString(), new ObjectId().toHexString()]; + const deletedCount = 2; + + return { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; + }; + + it('should build generic domainOperation with all attributes', () => { + const { domain, modifiedCount, deletedCount, modifiedRef, deletedRef } = setup(); + + const result = DomainOperationBuilder.build(domain, modifiedCount, deletedCount, modifiedRef, deletedRef); + + expect(result.domain).toEqual(domain); + expect(result.modifiedCount).toEqual(modifiedCount); + expect(result.deletedCount).toEqual(deletedCount); + }); +}); diff --git a/apps/server/src/shared/domain/builder/domain-operation.builder.ts b/apps/server/src/shared/domain/builder/domain-operation.builder.ts new file mode 100644 index 00000000000..b9c6619482f --- /dev/null +++ b/apps/server/src/shared/domain/builder/domain-operation.builder.ts @@ -0,0 +1,16 @@ +import { DomainOperation } from '@shared/domain/interface'; +import { DomainModel } from '@shared/domain/types'; + +export class DomainOperationBuilder { + static build( + domain: DomainModel, + modifiedCount: number, + deletedCount: number, + modifiedRef?: string[], + deletedRef?: string[] + ): DomainOperation { + const domainOperation = { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; + + return domainOperation; + } +} diff --git a/apps/server/src/shared/domain/builder/index.ts b/apps/server/src/shared/domain/builder/index.ts new file mode 100644 index 00000000000..5f9d180968d --- /dev/null +++ b/apps/server/src/shared/domain/builder/index.ts @@ -0,0 +1 @@ +export * from './domain-operation.builder'; diff --git a/apps/server/src/shared/domain/entity/task.entity.spec.ts b/apps/server/src/shared/domain/entity/task.entity.spec.ts index 8f0d1ebd278..a8a1007a898 100644 --- a/apps/server/src/shared/domain/entity/task.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/task.entity.spec.ts @@ -866,4 +866,52 @@ describe('Task Entity', () => { expect(schoolId).toEqual(school.id); }); }); + + describe('removeCreatorId is called', () => { + describe('WHEN creatorId exists', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const task = taskFactory.buildWithId({ creator: user }); + + return { task }; + }; + + it('should set it to undefined', () => { + const { task } = setup(); + + const result = task.removeCreatorId(); + + expect(result).toBe(undefined); + }); + }); + }); + + describe('removeUserFromFinished', () => { + describe('when user exist in Finished array', () => { + const setup = () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const task = taskFactory.buildWithId({ finished: [user1, user2] }); + + return { user1, user2, task }; + }; + + it('should remove user form finished collection', () => { + const { task, user1 } = setup(); + + task.removeUserFromFinished(user1.id); + + expect(task.finished.contains(user1)).toBe(false); + }); + + it('should remove only user selected, not other users in finished collection', () => { + const { task, user1, user2 } = setup(); + + task.removeUserFromFinished(user1.id); + + expect(task.finished.contains(user1)).toBe(false); + expect(task.finished.contains(user2)).toBe(true); + }); + }); + }); }); diff --git a/apps/server/src/shared/domain/entity/task.entity.ts b/apps/server/src/shared/domain/entity/task.entity.ts index 88af11a0a7d..c5ae56b7286 100644 --- a/apps/server/src/shared/domain/entity/task.entity.ts +++ b/apps/server/src/shared/domain/entity/task.entity.ts @@ -67,8 +67,8 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, teamSubmissions?: boolean; @Index() - @ManyToOne('User', { fieldName: 'teacherId' }) - creator: User; + @ManyToOne('User', { fieldName: 'teacherId', nullable: true }) + creator?: User; @Index() @ManyToOne('Course', { fieldName: 'courseId', nullable: true }) @@ -128,7 +128,7 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, return finishedIds; } - private getParent(): TaskParent | User { + private getParent(): TaskParent | User | undefined { const parent = this.lesson || this.course || this.creator; return parent; @@ -136,9 +136,11 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, private getMaxSubmissions(): number { const parent = this.getParent(); - // For draft (user as parent) propaly user is not a student, but for maxSubmission one is valid result - const maxSubmissions = parent instanceof User ? 1 : parent.getStudentIds().length; - + let maxSubmissions = 0; + if (parent) { + // For draft (user as parent) propaly user is not a student, but for maxSubmission one is valid result + maxSubmissions = parent instanceof User ? 1 : parent.getStudentIds().length; + } return maxSubmissions; } @@ -321,6 +323,14 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, public unpublish(): void { this.private = true; } + + public removeCreatorId(): void { + this.creator = undefined; + } + + public removeUserFromFinished(userId: EntityId): void { + this.finished.remove((u) => u.id === userId); + } } export function isTask(reference: unknown): reference is Task { diff --git a/apps/server/src/shared/domain/interface/domain-operation.ts b/apps/server/src/shared/domain/interface/domain-operation.ts new file mode 100644 index 00000000000..d4900ed2314 --- /dev/null +++ b/apps/server/src/shared/domain/interface/domain-operation.ts @@ -0,0 +1,9 @@ +import { DomainModel } from '../types'; + +export interface DomainOperation { + domain: DomainModel; + modifiedCount: number; + deletedCount: number; + modifiedRef?: string[]; + deletedRef?: string[]; +} diff --git a/apps/server/src/shared/domain/interface/index.ts b/apps/server/src/shared/domain/interface/index.ts index 6c6b3f9d2db..72b97b4fa75 100644 --- a/apps/server/src/shared/domain/interface/index.ts +++ b/apps/server/src/shared/domain/interface/index.ts @@ -5,3 +5,4 @@ export * from './learnroom'; export * from './permission.enum'; export * from './rolename.enum'; export * from './video-conference-scope.enum'; +export * from './domain-operation'; diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/shared/domain/types/domain.ts similarity index 87% rename from apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts rename to apps/server/src/shared/domain/types/domain.ts index 922187e2a16..babc23631d1 100644 --- a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts +++ b/apps/server/src/shared/domain/types/domain.ts @@ -1,4 +1,4 @@ -export const enum DeletionDomainModel { +export const enum DomainModel { ACCOUNT = 'account', CLASS = 'class', COURSEGROUP = 'courseGroup', @@ -10,6 +10,7 @@ export const enum DeletionDomainModel { PSEUDONYMS = 'pseudonyms', REGISTRATIONPIN = 'registrationPin', ROCKETCHATUSER = 'rocketChatUser', + TASK = 'task', TEAMS = 'teams', USER = 'user', } diff --git a/apps/server/src/shared/domain/types/index.ts b/apps/server/src/shared/domain/types/index.ts index b47a0d4821b..e591c94c825 100644 --- a/apps/server/src/shared/domain/types/index.ts +++ b/apps/server/src/shared/domain/types/index.ts @@ -10,3 +10,4 @@ export * from './school-purpose.enum'; export * from './system.type'; export * from './task.types'; export * from './value-of'; +export * from './domain'; diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts index e3e2949d5c1..a17320075d6 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts @@ -158,6 +158,7 @@ describe('ExternalToolRepo', () => { isHidden: true, openNewTab: true, version: 2, + isDeactivated: false, }); return { diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts index 7740647ea9f..b9113606c7e 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts @@ -40,6 +40,7 @@ export class ExternalToolRepoMapper { config, parameters: this.mapCustomParametersToDOs(entity.parameters || []), isHidden: entity.isHidden, + isDeactivated: entity.isDeactivated, openNewTab: entity.openNewTab, version: entity.version, restrictToContexts: entity.restrictToContexts, @@ -100,6 +101,7 @@ export class ExternalToolRepoMapper { config, parameters: this.mapCustomParameterDOsToEntities(entityDO.parameters ?? []), isHidden: entityDO.isHidden, + isDeactivated: entityDO.isDeactivated, openNewTab: entityDO.openNewTab, version: entityDO.version, restrictToContexts: entityDO.restrictToContexts, diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts index e910856caef..cfe2a61f940 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.ts @@ -71,6 +71,7 @@ export class SchoolExternalToolRepo extends BaseDORepo { + describe('when build scope query', () => { + const setup = () => { + const scope = new TaskScope(); + const creatorId = new ObjectId().toHexString(); + + const expected = { + $and: [ + { + creator: creatorId, + }, + { + $or: [ + { + course: { $ne: null }, + }, + { + lesson: { $ne: null }, + }, + ], + }, + ], + } as FilterQuery; + + return { scope, creatorId, expected }; + }; + it('should create valid query returning no results for empty scope', () => { + const { scope } = setup(); + const result = scope.query; + + expect(result).toBe(EmptyResultQuery); + }); + it('should create correct query for byCreatorIdWithCourseAndLesson', () => { + const { scope, creatorId, expected } = setup(); + scope.byCreatorIdWithCourseAndLesson(creatorId); + const result = scope.query; + + expect(JSON.stringify(result)).toBe(JSON.stringify(expected)); + }); + }); +}); diff --git a/apps/server/src/shared/repo/task/task-scope.ts b/apps/server/src/shared/repo/task/task-scope.ts index 200c40b0aad..0b6446f0953 100644 --- a/apps/server/src/shared/repo/task/task-scope.ts +++ b/apps/server/src/shared/repo/task/task-scope.ts @@ -28,6 +28,14 @@ export class TaskScope extends Scope { return this; } + byCreatorIdWithCourseAndLesson(creatorId: EntityId): TaskScope { + this.addQuery({ + $and: [{ creator: creatorId }, { $or: [{ course: { $ne: null } }, { lesson: { $ne: null } }] }], + }); + + return this; + } + byCourseIds(courseIds: EntityId[]): TaskScope { this.addQuery({ $and: [{ course: { $in: courseIds } }, { lesson: null }], diff --git a/apps/server/src/shared/repo/task/task.repo.integration.spec.ts b/apps/server/src/shared/repo/task/task.repo.integration.spec.ts index ea3b486c399..7b383c427bc 100644 --- a/apps/server/src/shared/repo/task/task.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/task/task.repo.integration.spec.ts @@ -2105,4 +2105,80 @@ describe('TaskRepo', () => { }).rejects.toThrow(); }); }); + + describe('findByOnlyCreatorId', () => { + describe('when searching by creatorId', () => { + const setup = async () => { + const creator = userFactory.build(); + const course = courseFactory.build({ teachers: [creator] }); + const task = taskFactory.build({ creator }); + const taskWithCourse = taskFactory.build({ course, creator }); + + await em.persistAndFlush([task, taskWithCourse]); + em.clear(); + + return { creator }; + }; + + it('should find task where is only creator', async () => { + const { creator } = await setup(); + + const [result] = await repo.findByOnlyCreatorId(creator.id); + + expect(result).toHaveLength(1); + }); + }); + }); + + describe('findByCreatorIdWithCourseAndLesson', () => { + describe('when searching by creatorId', () => { + const setup = async () => { + const creator = userFactory.build(); + const task = taskFactory.build({ creator }); + + const course = courseFactory.build({ teachers: [creator] }); + const taskWithCourse = taskFactory.build({ course, creator }); + + const lesson = lessonFactory.build({ course }); + const taskWithCourseAndLesson = taskFactory.build({ course, creator, lesson }); + + await em.persistAndFlush([task, taskWithCourse, taskWithCourseAndLesson]); + em.clear(); + + return { creator }; + }; + + it('should find task where are lesson or course', async () => { + const { creator } = await setup(); + + const [result] = await repo.findByCreatorIdWithCourseAndLesson(creator.id); + + expect(result).toHaveLength(2); + }); + }); + }); + + describe('findByUserIdInFinished', () => { + describe('when searching by userId', () => { + const setup = async () => { + const creator = userFactory.build(); + const course = courseFactory.build({ teachers: [creator] }); + const taskWithFinished = taskFactory.build({ creator, course, finished: [creator] }); + const taskWithoutFinished = taskFactory.build({ creator, course }); + + await em.persistAndFlush([taskWithFinished, taskWithoutFinished]); + em.clear(); + + return { creator }; + }; + + it('should find task where user is in archive', async () => { + const { creator } = await setup(); + + const [result] = await repo.findByUserIdInFinished(creator.id); + + expect(result).toHaveLength(1); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/task/task.repo.ts b/apps/server/src/shared/repo/task/task.repo.ts index 094d21a8dc5..b3e9d415d64 100644 --- a/apps/server/src/shared/repo/task/task.repo.ts +++ b/apps/server/src/shared/repo/task/task.repo.ts @@ -187,6 +187,33 @@ export class TaskRepo extends BaseRepo { return countedTaskList; } + async findByOnlyCreatorId(creatorId: EntityId): Promise> { + const scope = new TaskScope(); + scope.byOnlyCreatorId(creatorId); + + const countedTaskList = await this.findTasksAndCount(scope.query); + + return countedTaskList; + } + + async findByCreatorIdWithCourseAndLesson(creatorId: EntityId): Promise> { + const scope = new TaskScope(); + scope.byCreatorIdWithCourseAndLesson(creatorId); + + const countedTaskList = await this.findTasksAndCount(scope.query); + + return countedTaskList; + } + + async findByUserIdInFinished(userId: EntityId): Promise> { + const scope = new TaskScope(); + scope.byFinished(userId, true); + + const countedTaskList = await this.findTasksAndCount(scope.query); + + return countedTaskList; + } + private async findTasksAndCount(query: FilterQuery, options?: IFindOptions): Promise> { const pagination = options?.pagination || {}; const order = options?.order || {}; diff --git a/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts b/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts index a2e9ec6c2d2..58dc7a5a01c 100644 --- a/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts +++ b/apps/server/src/shared/testing/factory/context-external-tool-configuration-status-response.factory.ts @@ -6,5 +6,6 @@ export const contextExternalToolConfigurationStatusResponseFactory = return { isOutdatedOnScopeContext: false, isOutdatedOnScopeSchool: false, + isDeactivated: false, }; }); diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts index d5ec2ee7371..ab0ac21c76a 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts @@ -121,6 +121,7 @@ export const externalToolFactory = ExternalToolFactory.define(ExternalTool, ({ s config: basicToolConfigFactory.build(), logoUrl: 'https://logo.com/', isHidden: false, + isDeactivated: false, openNewTab: false, version: 1, }; diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool-configuration-status.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool-configuration-status.factory.ts index 1153fa87da2..fc2fcbc9348 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool-configuration-status.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/school-external-tool-configuration-status.factory.ts @@ -4,5 +4,6 @@ import { Factory } from 'fishery'; export const schoolToolConfigurationStatusFactory = Factory.define(() => { return { isOutdatedOnScopeSchool: false, + isDeactivated: false, }; }); diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts index e9d6e4f25d4..75458a32094 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/tool-configuration-status.factory.ts @@ -5,5 +5,6 @@ export const toolConfigurationStatusFactory = Factory.define(() => { + return { + isOutdatedOnScopeSchool: false, + isDeactivated: false, + }; + }); diff --git a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts index ff8e62fa4bc..e3f8b45cd59 100644 --- a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts @@ -1,6 +1,7 @@ import { SchoolExternalToolEntity, SchoolExternalToolProperties } from '@modules/tool/school-external-tool/entity'; import { BaseFactory } from '@shared/testing/factory/base.factory'; import { externalToolEntityFactory } from './external-tool-entity.factory'; +import { schoolExternalToolConfigurationStatusEntityFactory } from './school-external-tool-configuration-status-entity.factory'; import { schoolFactory } from './school.factory'; export const schoolExternalToolEntityFactory = BaseFactory.define< @@ -12,5 +13,6 @@ export const schoolExternalToolEntityFactory = BaseFactory.define< school: schoolFactory.buildWithId(), schoolParameters: [{ name: 'schoolMockParameter', value: 'mockValue' }], toolVersion: 0, + status: schoolExternalToolConfigurationStatusEntityFactory.build(), }; }); diff --git a/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts b/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts index 6dabc2148c0..480b68a181a 100644 --- a/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts +++ b/apps/server/src/shared/testing/factory/school-tool-configuration-status-response.factory.ts @@ -5,5 +5,6 @@ export const schoolToolConfigurationStatusResponseFactory = Factory.define(() => { return { isOutdatedOnScopeSchool: false, + isDeactivated: false, }; }); diff --git a/apps/server/src/shared/testing/factory/schoolyear.factory.ts b/apps/server/src/shared/testing/factory/schoolyear.factory.ts index 5de956288e6..30b36d0f432 100644 --- a/apps/server/src/shared/testing/factory/schoolyear.factory.ts +++ b/apps/server/src/shared/testing/factory/schoolyear.factory.ts @@ -13,8 +13,14 @@ class SchoolYearFactory extends BaseFactory { - const startYearWithoutSequence = transientParams?.startYear ?? new Date().getFullYear(); - const startYear = startYearWithoutSequence + sequence - 1; + const now = new Date(); + const startYearWithoutSequence = transientParams?.startYear ?? now.getFullYear(); + + let step = 1; + if (now.getMonth() < 7) { + step = 2; + } + const startYear = startYearWithoutSequence + sequence - step; const name = `${startYear}/${(startYear + 1).toString().slice(-2)}`; const startDate = new Date(`${startYear}-08-01`); diff --git a/backup/setup/context-external-tools.json b/backup/setup/context-external-tools.json index 239539c14e9..46fd0272a4d 100644 --- a/backup/setup/context-external-tools.json +++ b/backup/setup/context-external-tools.json @@ -97,6 +97,50 @@ "value": "test" }], "toolVersion": 1 + }, + { + "_id": { + "$oid": "647de3cfab79fd5bd57e68fe" + }, + "createdAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "schoolTool": { + "$oid": "647de374cf6a427b9d39e6be" + }, + "contextId": "5fa3a2f3a9c31a26f4d1d309", + "contextType": "course", + "displayName": "Cypress Test Tool deactivated on External Tool", + "parameters": [{ + "name": "contextparam", + "value": "test" + }], + "toolVersion": 1 + }, + { + "_id": { + "$oid": "647de3cfab79fd5bd57e68ff" + }, + "createdAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:30:08.532Z" + }, + "schoolTool": { + "$oid": "647de374cf6a427b9d39e7be" + }, + "contextId": "5fa3a2f3a9c31a26f4d1d309", + "contextType": "course", + "displayName": "Cypress Test Tool deactivated on School External Tool", + "parameters": [{ + "name": "contextparam", + "value": "test" + }], + "toolVersion": 1 } ] diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index 87764bd724d..f10aecc35a0 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -216,6 +216,117 @@ "isHidden": false, "openNewTab": false, "version": 2 + }, + { + "_id": { + "$oid": "647de247cf6a427b9d39e6c3" + }, + "createdAt": { + "$date": "2023-11-30T15:28:04.733Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:32:42.888Z" + }, + "name": "CY Test Tool deactivated External Tool", + "config_type": "basic", + "config_baseUrl": "https:google.com", + "parameters": [{ + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }], + "isHidden": false, + "openNewTab": false, + "version": 2, + "isDeactivated": true + }, + { + "_id": { + "$oid": "647de247cf6a427b9d39e7c3" + }, + "createdAt": { + "$date": "2023-11-30T15:28:04.733Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:32:42.888Z" + }, + "name": "CY Test Tool deactivated School External Tool", + "config_type": "basic", + "config_baseUrl": "https:google.com", + "parameters": [{ + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }], + "isHidden": false, + "openNewTab": false, + "version": 2, + "isDeactivated": false + }, + { + "_id": { + "$oid": "647de247cf6a427b9d39e8c3" + }, + "createdAt": { + "$date": "2023-11-30T15:28:04.733Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:32:42.888Z" + }, + "name": "CY Test Tool active External Tool", + "config_type": "basic", + "config_baseUrl": "https:google.com", + "parameters": [{ + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }], + "isHidden": false, + "openNewTab": false, + "version": 2, + "isDeactivated": false } ] diff --git a/backup/setup/school-external-tools.json b/backup/setup/school-external-tools.json index 6c39fd86c0a..380cf2d46b8 100644 --- a/backup/setup/school-external-tools.json +++ b/backup/setup/school-external-tools.json @@ -154,5 +154,57 @@ "value": "test" }], "toolVersion": 1 + }, + { + "_id": { + "$oid": "647de374cf6a427b9d39e6be" + }, + "createdAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "tool": { + "$oid": "647de247cf6a427b9d39e6c3" + }, + "school": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "schoolParameters": [{ + "name": "schoolParan", + "value": "test" + }], + "toolVersion": 1, + "status": { + "isDeactivated": false, + "isOutdatedOnScopeSchool": false + } + }, + { + "_id": { + "$oid": "647de374cf6a427b9d39e7be" + }, + "createdAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "updatedAt": { + "$date": "2023-11-30T15:29:00.061Z" + }, + "tool": { + "$oid": "647de247cf6a427b9d39e7c3" + }, + "school": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "schoolParameters": [{ + "name": "schoolParan", + "value": "test" + }], + "toolVersion": 1, + "status": { + "isDeactivated": true, + "isOutdatedOnScopeSchool": false + } } ] diff --git a/src/services/ldap/strategies/general.js b/src/services/ldap/strategies/general.js index b70000e1c94..d6974544eb3 100644 --- a/src/services/ldap/strategies/general.js +++ b/src/services/ldap/strategies/general.js @@ -67,6 +67,7 @@ class GeneralLDAPStrategy extends AbstractLDAPStrategy { ); } + const splittedTeacherRoles = roleAttributeNameMapping.roleTeacher.split(';;'); const results = []; ldapUsers.forEach((obj) => { const roles = []; @@ -77,9 +78,11 @@ class GeneralLDAPStrategy extends AbstractLDAPStrategy { if (obj.memberOf.includes(roleAttributeNameMapping.roleStudent)) { roles.push('student'); } - if (obj.memberOf.includes(roleAttributeNameMapping.roleTeacher)) { - roles.push('teacher'); - } + splittedTeacherRoles.forEach((role) => { + if (obj.memberOf.includes(role)) { + roles.push('teacher'); + } + }); if (obj.memberOf.includes(roleAttributeNameMapping.roleAdmin)) { roles.push('administrator'); } @@ -90,9 +93,11 @@ class GeneralLDAPStrategy extends AbstractLDAPStrategy { if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleStudent) { roles.push('student'); } - if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleTeacher) { - roles.push('teacher'); - } + splittedTeacherRoles.forEach((role) => { + if (obj[userAttributeNameMapping.role].includes(role)) { + roles.push('teacher'); + } + }); if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleAdmin) { roles.push('administrator'); } diff --git a/src/services/lesson/hooks/index.js b/src/services/lesson/hooks/index.js index 458b64785ab..a5923ce154f 100644 --- a/src/services/lesson/hooks/index.js +++ b/src/services/lesson/hooks/index.js @@ -3,7 +3,7 @@ const { Configuration } = require('@hpi-schul-cloud/commons'); const { nanoid } = require('nanoid'); const { iff, isProvider } = require('feathers-hooks-common'); -const { NotFound, BadRequest } = require('../../../errors'); +const { NotFound, BadRequest, Forbidden } = require('../../../errors'); const { equal } = require('../../../helper/compare').ObjectId; const { injectUserId, @@ -204,6 +204,19 @@ const restrictToUsersCoursesLessons = async (context) => { return context; }; +const restrictToUsersDraftLessons = async (context) => { + const user = await context.app.service('users').get(context.params.account.userId, { query: { $populate: 'roles' } }); + const userIsStudent = user.roles.filter((u) => u.name === 'student').length > 0; + const lesson = await context.app.service('lessons').get(context.id); + const isDraft = lesson.hidden; + + if (isDraft && userIsStudent) { + throw new Forbidden(`You don't have permission.`); + } + + return context; +}; + const populateWhitelist = { materialIds: [ '_id', @@ -236,12 +249,12 @@ exports.before = () => { hasPermission('TOPIC_VIEW'), iff(isProvider('external'), validateLessonFind), iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), ], get: [ hasPermission('TOPIC_VIEW'), iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), ], create: [ checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_CREATE', 'TOPIC_CREATE', true), @@ -254,7 +267,7 @@ exports.before = () => { iff(isProvider('external'), preventPopulate), permitGroupOperation, ifNotLocal(checkCorrectCourseOrTeamId), - iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', false), ], patch: [ diff --git a/test/services/ldap/strategies/general.test.js b/test/services/ldap/strategies/general.test.js index b938717cfad..7328811f892 100644 --- a/test/services/ldap/strategies/general.test.js +++ b/test/services/ldap/strategies/general.test.js @@ -23,7 +23,8 @@ const mockLDAPConfig = { }, roleAttributeNameMapping: { roleStudent: 'cn=ROLE_STUDENT,ou=roles,o=school0,dc=de,dc=example,dc=org', - roleTeacher: 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org', + roleTeacher: + 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org;;cn=OTHER_TEACHERS,ou=roles,o=school0,dc=de,dc=example,dc=org', roleAdmin: 'cn=ROLE_ADMIN,ou=roles,o=school0,dc=de,dc=example,dc=org', }, classAttributeNameMapping: { @@ -137,6 +138,18 @@ describe('GeneralLDAPStrategy', () => { mail: 'testington.1@example.org', memberOf: 'cn=ROLE_ADMIN,ou=roles,o=school0,dc=de,dc=example,dc=org', }, + { + dn: 'uid=herr.anwalt,ou=users,o=school0,dc=de,dc=example,dc=org', + givenName: 'Herr', + sn: 'Anwalt', + uid: 'herr.anwalt', + uuid: 'ZDg0Y2ZlMjMtZGYwNi00MWNjLTg3YmUtZjI3NjA1NDJhY2Y4', + mail: 'herr.lempel.1@example.org', + memberOf: [ + 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org', + 'cn=OTHER_TEACHERS,ou=roles,o=school0,dc=de,dc=example,dc=org', + ], + }, ]), }; } @@ -150,7 +163,7 @@ describe('GeneralLDAPStrategy', () => { it('should return all users', async () => { const instance = new GeneralLDAPStrategy(app, mockLDAPConfig); const users = await instance.getUsers(); - expect(users.length).to.equal(4); + expect(users.length).to.equal(5); }); it('should follow the internal interface', async () => { @@ -178,11 +191,15 @@ describe('GeneralLDAPStrategy', () => { }); it('should assign roles based on specific group memberships for group role type', async () => { - const [student1, student2, teacher, admin] = await new GeneralLDAPStrategy(app, mockLDAPConfig).getUsers(); + const [student1, student2, teacher, admin, teacher2] = await new GeneralLDAPStrategy( + app, + mockLDAPConfig + ).getUsers(); expect(student1.roles).to.include('student'); expect(student2.roles).to.include('student'); expect(teacher.roles).to.include('teacher'); expect(admin.roles).to.include('administrator'); + expect(teacher2.roles).to.include('teacher'); }); it('should assign roles based on specific group memberships for non-group role type', async () => { @@ -220,7 +237,7 @@ describe('GeneralLDAPStrategy', () => { }), createLDAPUserResult({ givenName: '', - memberOf: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleTeacher, + memberOf: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleTeacher.split(';;')[0], }), createLDAPUserResult({ givenName: '', diff --git a/test/services/rocketChat/index.test.js b/test/services/rocketChat/index.test.js index 78caf281737..db442894062 100644 --- a/test/services/rocketChat/index.test.js +++ b/test/services/rocketChat/index.test.js @@ -3,7 +3,6 @@ /* eslint-disable no-unused-expressions */ const assert = require('assert'); const chai = require('chai'); -const mockery = require('mockery'); const { ObjectId } = require('mongoose').Types; const appPromise = require('../../../src/app'); @@ -23,19 +22,11 @@ describe('rocket.chat user service', () => { before(async () => { app = await appPromise(); // const rcMock = await rcMockServer({}); - const rocketChatService = { + app.services['nest-rocket-chat'] = { getUserList: () => { return { users: [{ _id: 'someId', username: 'someUsername' }] }; }, }; - mockery.enable({ - warnOnUnregistered: false, - }); - - // ROCKET_CHAT_ADMIN_TOKEN, ROCKET_CHAT_ADMIN_ID - // mockery.registerMock('../../../config/globals', { ROCKET_CHAT_URI: rcMock.url }); - // const rocketChatService = { getUserList: sinon.spy() }; - app.services['nest-rocket-chat'] = rocketChatService; delete require.cache[require.resolve('../../../src/services/rocketChat/services/rocketChatUser.js')]; delete require.cache[require.resolve('../../../src/services/rocketChat/helpers.js')];