diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 64e257c5f59..e64021b5f67 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -37,6 +37,13 @@ template: onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Admin API client secret (from 1Password) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: onepassword-admin-api-client.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: remove old migration Job kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -108,6 +115,12 @@ namespace: "{{ NAMESPACE }}" template: api-delete-s3-files-cronjob.yml.j2 + - name: Data deletion trigger CronJob + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: data-deletion-trigger-cronjob.yml.j2 + - name: AMQPFileStorageDeployment kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 new file mode 100644 index 00000000000..a0807973c43 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/data-deletion-trigger-cronjob.yml.j2 @@ -0,0 +1,57 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + name: data-deletion-trigger-cronjob +spec: + concurrencyPolicy: Forbid + schedule: "{{ SERVER_DATA_DELETION_TRIGGER_CRONJOB_SCHEDULE|default("@hourly", true) }}" + jobTemplate: + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + template: + spec: + containers: + - name: data-deletion-trigger-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - secretRef: + name: admin-api-client-secret + command: ['/bin/sh', '-c'] + args: ['npm run nest:start:deletion-console -- execution trigger'] + resources: + limits: + cpu: {{ API_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ API_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure + metadata: + labels: + app: data-deletion-trigger + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: data-deletion-trigger + app.kubernetes.io/component: data-deletion + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} \ No newline at end of file diff --git a/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 new file mode 100644 index 00000000000..fe2be1d76a8 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/onepassword-admin-api-client.yml.j2 @@ -0,0 +1,7 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: admin-api-client-secret + namespace: {{ NAMESPACE }} +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/admin-api-client" diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts index 096b1f9b082..478f23a2348 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.spec.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { AxiosResponse } from 'axios'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; @@ -51,6 +51,23 @@ describe(DeletionClient.name, () => { }); describe('queueDeletionRequest', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { input }; + }; + + it('should catch and throw an error', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + describe('when received valid response with expected HTTP status code', () => { const setup = () => { const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); @@ -151,4 +168,55 @@ describe(DeletionClient.name, () => { }); }); }); + + describe('executeDeletions', () => { + describe('when sending the HTTP request failed', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + }; + + it('should catch and throw an error', async () => { + setup(); + + await expect(client.executeDeletions()).rejects.toThrow(Error); + }); + }); + + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const limit = 10; + + const response: AxiosResponse = axiosResponseFactory.build({ + status: 204, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { limit }; + }; + + it('should return proper output', async () => { + const { limit } = setup(); + + await expect(client.executeDeletions(limit)).resolves.not.toThrow(); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + }; + + it('should throw an exception', async () => { + setup(); + + await expect(client.executeDeletions()).rejects.toThrow(Error); + }); + }); + }); }); diff --git a/apps/server/src/modules/deletion/client/deletion.client.ts b/apps/server/src/modules/deletion/client/deletion.client.ts index 66bb267d070..a3c47844656 100644 --- a/apps/server/src/modules/deletion/client/deletion.client.ts +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -1,9 +1,9 @@ -import { firstValueFrom } from 'rxjs'; -import { AxiosResponse } from 'axios'; -import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; +import { BadGatewayException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; +import { ErrorUtils } from '@src/core/error/utils'; +import { firstValueFrom } from 'rxjs'; +import { DeletionClientConfig, DeletionRequestInput, DeletionRequestOutput } from './interface'; @Injectable() export class DeletionClient { @@ -13,6 +13,8 @@ export class DeletionClient { private readonly postDeletionRequestsEndpoint: string; + private readonly postDeletionExecutionsEndpoint: string; + constructor( private readonly httpService: HttpService, private readonly configService: ConfigService @@ -22,36 +24,65 @@ export class DeletionClient { // Prepare the POST /deletionRequests endpoint beforehand to not do it on every client call. this.postDeletionRequestsEndpoint = new URL('/admin/api/v1/deletionRequests', this.baseUrl).toString(); + this.postDeletionExecutionsEndpoint = new URL('/admin/api/v1/deletionExecutions', this.baseUrl).toString(); } async queueDeletionRequest(input: DeletionRequestInput): Promise { - const request = this.httpService.post(this.postDeletionRequestsEndpoint, input, this.defaultHeaders()); - - return firstValueFrom(request) - .then((resp: AxiosResponse) => { - // Throw an error if any other status code (other than expected "202 Accepted" is returned). - if (resp.status !== 202) { - throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); - } - - // Throw an error if server didn't return a requestId in a response (and it is - // required as it gives client the reference to the created deletion request). - if (!resp.data.requestId) { - throw new Error('no valid requestId returned from the server'); - } - - // Throw an error if server didn't return a deletionPlannedAt timestamp so the user - // will not be aware after which date the deletion request's execution will begin. - if (!resp.data.deletionPlannedAt) { - throw new Error('no valid deletionPlannedAt returned from the server'); - } - - return resp.data; - }) - .catch((err: Error) => { - // Throw an error if sending/processing deletion request by the client failed in any way. - throw new Error(`failed to send/process a deletion request: ${err.toString()}`); - }); + try { + const request = this.httpService.post( + this.postDeletionRequestsEndpoint, + input, + this.defaultHeaders() + ); + + const resp = await firstValueFrom(request); + + // Throw an error if any other status code (other than expected "202 Accepted" is returned). + if (resp.status !== 202) { + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 202`); + } + + // Throw an error if server didn't return a requestId in a response (and it is + // required as it gives client the reference to the created deletion request). + if (!resp.data.requestId) { + throw new Error('no valid requestId returned from the server'); + } + + // Throw an error if server didn't return a deletionPlannedAt timestamp so the user + // will not be aware after which date the deletion request's execution will begin. + if (!resp.data.deletionPlannedAt) { + throw Error('no valid deletionPlannedAt returned from the server'); + } + + return resp.data; + } catch (err) { + // Throw an error if sending deletion request has failed. + throw new BadGatewayException('DeletionClient:queueDeletionRequest', ErrorUtils.createHttpExceptionOptions(err)); + } + } + + async executeDeletions(limit?: number): Promise { + let requestConfig = {}; + + if (limit && limit > 0) { + requestConfig = { ...this.defaultHeaders(), params: { limit } }; + } else { + requestConfig = { ...this.defaultHeaders() }; + } + + try { + const request = this.httpService.post(this.postDeletionExecutionsEndpoint, null, requestConfig); + + const resp = await firstValueFrom(request); + + if (resp.status !== 204) { + // Throw an error if any other status code (other than expected "204 No Content" is returned). + throw new Error(`invalid HTTP status code in a response from the server - ${resp.status} instead of 204`); + } + } catch (err) { + // Throw an error if sending deletion request(s) execution trigger has failed. + throw new BadGatewayException('DeletionClient:executeDeletions', ErrorUtils.createHttpExceptionOptions(err)); + } } private apiKeyHeader() { diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts new file mode 100644 index 00000000000..b0217e6a2c2 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.spec.ts @@ -0,0 +1,45 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; +import { DeletionExecutionTriggerResultBuilder } from './deletion-execution-trigger-result.builder'; + +describe(DeletionExecutionTriggerResultBuilder.name, () => { + describe(DeletionExecutionTriggerResultBuilder.buildSuccess.name, () => { + describe('when called', () => { + const setup = () => { + const expectedOutput: DeletionExecutionTriggerResult = { status: DeletionExecutionTriggerStatus.SUCCESS }; + + return { expectedOutput }; + }; + + it('should return valid object indicating success', () => { + const { expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildSuccess(); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(DeletionExecutionTriggerResultBuilder.buildFailure.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: DeletionExecutionTriggerResult = { + status: DeletionExecutionTriggerStatus.FAILURE, + error: error.toString(), + }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = DeletionExecutionTriggerResultBuilder.buildFailure(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts new file mode 100644 index 00000000000..e660e6905ea --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/deletion-execution-trigger-result.builder.ts @@ -0,0 +1,21 @@ +import { DeletionExecutionTriggerResult, DeletionExecutionTriggerStatus } from '../interface'; + +export class DeletionExecutionTriggerResultBuilder { + private static build(status: DeletionExecutionTriggerStatus, error?: string): DeletionExecutionTriggerResult { + const output: DeletionExecutionTriggerResult = { status }; + + if (error) { + output.error = error; + } + + return output; + } + + static buildSuccess(): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.SUCCESS); + } + + static buildFailure(err: Error): DeletionExecutionTriggerResult { + return this.build(DeletionExecutionTriggerStatus.FAILURE, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts index 12fd0997ebe..985edf66371 100644 --- a/apps/server/src/modules/deletion/console/builder/index.ts +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -1 +1,3 @@ export * from './push-delete-requests-options.builder'; +export * from './trigger-deletion-execution-options.builder'; +export * from './deletion-execution-trigger-result.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts new file mode 100644 index 00000000000..21171adb405 --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.spec.ts @@ -0,0 +1,24 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; +import { TriggerDeletionExecutionOptionsBuilder } from './trigger-deletion-execution-options.builder'; + +describe(TriggerDeletionExecutionOptionsBuilder.name, () => { + describe(TriggerDeletionExecutionOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const limit = 1000; + + const expectedOutput: TriggerDeletionExecutionOptions = { limit }; + + return { limit, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { limit, expectedOutput } = setup(); + + const output = TriggerDeletionExecutionOptionsBuilder.build(limit); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts new file mode 100644 index 00000000000..ed652006e9d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/trigger-deletion-execution-options.builder.ts @@ -0,0 +1,7 @@ +import { TriggerDeletionExecutionOptions } from '../interface'; + +export class TriggerDeletionExecutionOptionsBuilder { + static build(limit: number): TriggerDeletionExecutionOptions { + return { limit }; + } +} diff --git a/apps/server/src/modules/deletion/console/deletion-console.module.ts b/apps/server/src/modules/deletion/console/deletion-console.module.ts index 0585b3631da..504c1c35885 100644 --- a/apps/server/src/modules/deletion/console/deletion-console.module.ts +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -7,8 +7,9 @@ import { createConfigModuleOptions } from '@src/config'; import { DeletionClient } from '../client'; import { getDeletionClientConfig } from '../client/deletion-client.config'; import { BatchDeletionService } from '../services'; -import { BatchDeletionUc } from '../uc'; +import { BatchDeletionUc, DeletionExecutionUc } from '../uc'; import { DeletionQueueConsole } from './deletion-queue.console'; +import { DeletionExecutionConsole } from './deletion-execution.console'; @Module({ imports: [ @@ -17,6 +18,13 @@ import { DeletionQueueConsole } from './deletion-queue.console'; HttpModule, ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), ], - providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], + providers: [ + DeletionClient, + BatchDeletionService, + BatchDeletionUc, + DeletionExecutionUc, + DeletionQueueConsole, + DeletionExecutionConsole, + ], }) export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts new file mode 100644 index 00000000000..39519bab6a9 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.spec.ts @@ -0,0 +1,109 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionConsole } from './deletion-execution.console'; +import { DeletionExecutionTriggerResultBuilder, TriggerDeletionExecutionOptionsBuilder } from './builder'; + +describe(DeletionExecutionConsole.name, () => { + let module: TestingModule; + let console: DeletionExecutionConsole; + let deletionExecutionUc: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: DeletionExecutionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionExecutionConsole); + deletionExecutionUc = module.get(DeletionExecutionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe('when called with valid options', () => { + const setup = () => { + const limit = 1000; + + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + return { limit, options }; + }; + + it(`should call ${DeletionExecutionUc.name} with proper arguments`, async () => { + const { limit, options } = setup(); + + const spy = jest.spyOn(deletionExecutionUc, 'triggerDeletionExecution'); + + await console.triggerDeletionExecution(options); + + expect(spy).toBeCalledWith(limit); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method doesn't throw an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + deletionExecutionUc.triggerDeletionExecution.mockResolvedValueOnce(undefined); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildSuccess'); + + return { options, spy }; + }; + + it('should prepare result indicating success', async () => { + const { options, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe(`when ${DeletionExecutionUc.name}'s triggerDeletionExecution() method throws an exception`, () => { + const setup = () => { + const options = TriggerDeletionExecutionOptionsBuilder.build(1000); + + const err = new Error('some error occurred...'); + + deletionExecutionUc.triggerDeletionExecution.mockRejectedValueOnce(err); + + // const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); + + const spy = jest.spyOn(DeletionExecutionTriggerResultBuilder, 'buildFailure'); + + return { options, err, spy }; + }; + + it('should prepare result indicating failure', async () => { + const { options, err, spy } = setup(); + + await console.triggerDeletionExecution(options); + + expect(spy).toHaveBeenCalledWith(err); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-execution.console.ts b/apps/server/src/modules/deletion/console/deletion-execution.console.ts new file mode 100644 index 00000000000..d9fdabe0160 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-execution.console.ts @@ -0,0 +1,38 @@ +import { Command, Console } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { DeletionExecutionUc } from '../uc'; +import { DeletionExecutionTriggerResultBuilder } from './builder'; +import { DeletionExecutionTriggerResult, TriggerDeletionExecutionOptions } from './interface'; + +@Console({ command: 'execution', description: 'Console providing an access to the deletion execution(s).' }) +export class DeletionExecutionConsole { + constructor(private consoleWriter: ConsoleWriterService, private deletionExecutionUc: DeletionExecutionUc) {} + + @Command({ + command: 'trigger', + description: 'Trigger execution of deletion requests.', + options: [ + { + flags: '-l, --limit ', + description: 'Limit of the requested deletion executions that should be performed.', + required: false, + }, + ], + }) + async triggerDeletionExecution(options: TriggerDeletionExecutionOptions): Promise { + // Try to trigger the deletion execution(s) via Deletion API client, + // return successful status in case of a success, otherwise return + // a result with a failure status and a proper error message. + let result: DeletionExecutionTriggerResult; + + try { + await this.deletionExecutionUc.triggerDeletionExecution(options.limit ? Number(options.limit) : undefined); + + result = DeletionExecutionTriggerResultBuilder.buildSuccess(); + } catch (err) { + result = DeletionExecutionTriggerResultBuilder.buildFailure(err as Error); + } + + this.consoleWriter.info(JSON.stringify(result)); + } +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts new file mode 100644 index 00000000000..787100ec048 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-result.ts @@ -0,0 +1,6 @@ +import { DeletionExecutionTriggerStatus } from './deletion-execution-trigger-status.enum'; + +export interface DeletionExecutionTriggerResult { + status: DeletionExecutionTriggerStatus; + error?: string; +} diff --git a/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts new file mode 100644 index 00000000000..2b241cf72fc --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/deletion-execution-trigger-status.enum.ts @@ -0,0 +1,4 @@ +export const enum DeletionExecutionTriggerStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts index 2fcb281430f..b15a668b53e 100644 --- a/apps/server/src/modules/deletion/console/interface/index.ts +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -1 +1,4 @@ export * from './push-delete-requests-options.interface'; +export * from './trigger-deletion-execution-options.interface'; +export * from './deletion-execution-trigger-status.enum'; +export * from './deletion-execution-trigger-result'; diff --git a/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts new file mode 100644 index 00000000000..b17aafa1112 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/trigger-deletion-execution-options.interface.ts @@ -0,0 +1,3 @@ +export interface TriggerDeletionExecutionOptions { + limit: number; +} diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts index 1a4f3bcf425..daa4985498d 100644 --- a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts +++ b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts @@ -6,6 +6,7 @@ export const enum DeletionDomainModel { FILE = 'file', LESSONS = 'lessons', PSEUDONYMS = 'pseudonyms', + REGISTRATIONPIN = 'registrationPin', ROCKETCHATUSER = 'rocketChatUser', TEAMS = 'teams', USER = 'user', diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts new file mode 100644 index 00000000000..39c8065645a --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient } from '../client'; +import { DeletionExecutionUc } from './deletion-execution.uc'; + +describe(DeletionExecutionUc.name, () => { + let module: TestingModule; + let uc: DeletionExecutionUc; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionExecutionUc, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(DeletionExecutionUc); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('triggerDeletionExecution', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const limit = 1000; + + deletionClient.executeDeletions.mockResolvedValueOnce(undefined); + + return { limit }; + }; + + it('should also not throw an error', async () => { + const { limit } = setup(); + + await expect(uc.triggerDeletionExecution(limit)).resolves.not.toThrow(); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const error = new Error('connection error'); + + deletionClient.executeDeletions.mockRejectedValueOnce(error); + }; + + it('should also throw an error', async () => { + setup(); + + await expect(uc.triggerDeletionExecution()).rejects.toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts new file mode 100644 index 00000000000..ad4c90c567d --- /dev/null +++ b/apps/server/src/modules/deletion/uc/deletion-execution.uc.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { DeletionClient } from '../client'; + +@Injectable() +export class DeletionExecutionUc { + constructor(private readonly deletionClient: DeletionClient) {} + + async triggerDeletionExecution(limit?: number): Promise { + await this.deletionClient.executeDeletions(limit); + } +} 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 34c34e302f5..69ec72a0db5 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 @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { setupEntities } from '@shared/testing'; +import { setupEntities, userDoFactory } from '@shared/testing'; import { AccountService } from '@modules/account/services'; import { ClassService } from '@modules/class'; import { CourseGroupService, CourseService } from '@modules/learnroom/service'; @@ -12,6 +12,7 @@ import { UserService } from '@modules/user'; import { RocketChatService } from '@modules/rocketchat'; import { rocketChatUserFactory } from '@modules/rocketchat-user/domain/testing'; import { RocketChatUser, RocketChatUserService } from '@modules/rocketchat-user'; +import { RegistrationPinService } from '@modules/registration-pin'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; import { DeletionRequestService } from '../services'; @@ -37,6 +38,7 @@ describe(DeletionRequestUc.name, () => { let userService: DeepMocked; let rocketChatUserService: DeepMocked; let rocketChatService: DeepMocked; + let registrationPinService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -94,6 +96,10 @@ describe(DeletionRequestUc.name, () => { provide: RocketChatService, useValue: createMock(), }, + { + provide: RegistrationPinService, + useValue: createMock(), + }, ], }).compile(); @@ -111,6 +117,7 @@ describe(DeletionRequestUc.name, () => { userService = module.get(UserService); rocketChatUserService = module.get(RocketChatUserService); rocketChatService = module.get(RocketChatService); + registrationPinService = module.get(RegistrationPinService); await setupEntities(); }); @@ -168,10 +175,13 @@ describe(DeletionRequestUc.name, () => { const setup = () => { jest.clearAllMocks(); const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + const user = userDoFactory.buildWithId(); const rocketChatUser: RocketChatUser = rocketChatUserFactory.build({ userId: deletionRequestToExecute.targetRefId, }); + const parentEmail = 'parent@parent.eu'; + registrationPinService.deleteRegistrationPinByEmail.mockResolvedValueOnce(2); classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); @@ -186,6 +196,8 @@ describe(DeletionRequestUc.name, () => { return { deletionRequestToExecute, rocketChatUser, + user, + parentEmail, }; }; @@ -215,6 +227,29 @@ describe(DeletionRequestUc.name, () => { expect(accountService.deleteByUserId).toHaveBeenCalled(); }); + it('should call registrationPinService.deleteRegistrationPinByEmail to delete user data in registrationPin module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(registrationPinService.deleteRegistrationPinByEmail).toHaveBeenCalled(); + }); + + it('should call userService.getParentEmailsFromUser to get parentEmails', async () => { + const { deletionRequestToExecute, user, parentEmail } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + userService.findById.mockResolvedValueOnce(user); + userService.getParentEmailsFromUser.mockRejectedValue([parentEmail]); + registrationPinService.deleteRegistrationPinByEmail.mockRejectedValueOnce(2); + + await uc.executeDeletionRequests(); + + expect(userService.getParentEmailsFromUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + it('should call classService.deleteUserDataFromClasses to delete user data in class module', async () => { const { deletionRequestToExecute } = setup(); 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 abea56fda96..7bacc428310 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -10,6 +10,7 @@ import { FilesService } from '@modules/files/service'; import { AccountService } from '@modules/account/services'; import { RocketChatUserService } from '@modules/rocketchat-user'; import { RocketChatService } from '@modules/rocketchat'; +import { RegistrationPinService } from '@modules/registration-pin'; import { DeletionRequestService } from '../services/deletion-request.service'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; @@ -42,7 +43,8 @@ export class DeletionRequestUc { private readonly teamService: TeamService, private readonly userService: UserService, private readonly rocketChatUserService: RocketChatUserService, - private readonly rocketChatService: RocketChatService + private readonly rocketChatService: RocketChatService, + private readonly registrationPinService: RegistrationPinService ) {} async createDeletionRequest(deletionRequest: DeletionRequestProps): Promise { @@ -101,6 +103,7 @@ export class DeletionRequestUc { this.removeUserFromTeams(deletionRequest), this.removeUser(deletionRequest), this.removeUserFromRocketChat(deletionRequest), + this.removeUserRegistrationPin(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -131,6 +134,25 @@ export class DeletionRequestUc { await this.logDeletion(deletionRequest, DeletionDomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); } + private async removeUserRegistrationPin(deletionRequest: DeletionRequest) { + const userToDeletion = await this.userService.findById(deletionRequest.targetRefId); + const parentEmails = await this.userService.getParentEmailsFromUser(deletionRequest.targetRefId); + const emailsToDeletion: string[] = [userToDeletion.email, ...parentEmails]; + + const result = await Promise.all( + emailsToDeletion.map((email) => this.registrationPinService.deleteRegistrationPinByEmail(email)) + ); + const deletedRegistrationPin = result.filter((res) => res !== 0).length; + + await this.logDeletion( + deletionRequest, + DeletionDomainModel.REGISTRATIONPIN, + DeletionOperationModel.DELETE, + 0, + deletedRegistrationPin + ); + } + private async removeUserFromClasses(deletionRequest: DeletionRequest) { const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); await this.logDeletion( diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts index cf74de969e5..4b1451b563d 100644 --- a/apps/server/src/modules/deletion/uc/index.ts +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -1,2 +1,3 @@ export * from './interface'; export * from './batch-deletion.uc'; +export * from './deletion-execution.uc'; diff --git a/apps/server/src/modules/registration-pin/entity/index.ts b/apps/server/src/modules/registration-pin/entity/index.ts new file mode 100644 index 00000000000..ed20550896f --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.entity'; diff --git a/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts new file mode 100644 index 00000000000..c8570e8d1b2 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.spec.ts @@ -0,0 +1,57 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { RegistrationPinEntity } from '.'; + +describe(RegistrationPinEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + email: 'test@test.eu', + pin: 'test123', + verified: false, + importHash: '02a00804nnQbLbCDEMVuk56pzZ3A0SC2cYnmM9cyY25IVOnf0K3YCKqW6zxC', + }; + + return { props }; + }; + + describe('constructor', () => { + describe('When constructor is called', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new RegistrationPinEntity(); + expect(test).toThrow(); + }); + + it('should create a registrationPins by passing required properties', () => { + const { props } = setup(); + const entity: RegistrationPinEntity = new RegistrationPinEntity(props); + + expect(entity instanceof RegistrationPinEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: RegistrationPinEntity = new RegistrationPinEntity(props); + + const entityProps = { + id: entity.id, + email: entity.email, + pin: entity.pin, + verified: entity.verified, + importHash: entity.importHash, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts new file mode 100644 index 00000000000..ee5ece7a421 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/registration-pin.entity.ts @@ -0,0 +1,40 @@ +import { Entity, Index, Property } from '@mikro-orm/core'; +import { EntityId } from '@shared/domain'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; + +export interface RegistrationPinEntityProps { + id?: EntityId; + email: string; + pin: string; + verified: boolean; + importHash: string; +} + +@Entity({ tableName: 'registrationpins' }) +@Index({ properties: ['email', 'pin'] }) +export class RegistrationPinEntity extends BaseEntityWithTimestamps { + @Property() + @Index() + email: string; + + @Property() + pin: string; + + @Property({ default: false }) + verified: boolean; + + @Property() + @Index() + importHash: string; + + constructor(props: RegistrationPinEntityProps) { + super(); + if (props.id !== undefined) { + this.id = props.id; + } + this.email = props.email; + this.pin = props.pin; + this.verified = props.verified; + this.importHash = props.importHash; + } +} diff --git a/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts b/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts new file mode 100644 index 00000000000..74b1134fc78 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/factory/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.entity.factory'; diff --git a/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts b/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts new file mode 100644 index 00000000000..9a162147bed --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/factory/registration-pin.entity.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RegistrationPinEntity, RegistrationPinEntityProps } from '../../registration-pin.entity'; + +export const registrationPinEntityFactory = BaseFactory.define( + RegistrationPinEntity, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + email: `name-${sequence}@schul-cloud.org`, + pin: `123-${sequence}`, + verified: false, + importHash: `importHash-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/registration-pin/entity/testing/index.ts b/apps/server/src/modules/registration-pin/entity/testing/index.ts new file mode 100644 index 00000000000..d847d7abce6 --- /dev/null +++ b/apps/server/src/modules/registration-pin/entity/testing/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/apps/server/src/modules/registration-pin/index.ts b/apps/server/src/modules/registration-pin/index.ts new file mode 100644 index 00000000000..89a77b2fa2c --- /dev/null +++ b/apps/server/src/modules/registration-pin/index.ts @@ -0,0 +1,2 @@ +export * from './registration-pin.module'; +export { RegistrationPinService } from './service'; diff --git a/apps/server/src/modules/registration-pin/registration-pin.module.ts b/apps/server/src/modules/registration-pin/registration-pin.module.ts new file mode 100644 index 00000000000..76fa8716c94 --- /dev/null +++ b/apps/server/src/modules/registration-pin/registration-pin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { RegistrationPinService } from './service'; +import { RegistrationPinRepo } from './repo'; + +@Module({ + imports: [LoggerModule], + providers: [RegistrationPinService, RegistrationPinRepo], + exports: [RegistrationPinService], +}) +export class RegistrationPinModule {} diff --git a/apps/server/src/modules/registration-pin/repo/index.ts b/apps/server/src/modules/registration-pin/repo/index.ts new file mode 100644 index 00000000000..e32bd34f567 --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.repo'; diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts new file mode 100644 index 00000000000..c357351fa37 --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts @@ -0,0 +1,64 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections, userFactory } from '@shared/testing'; +import { RegistrationPinRepo } from '.'; +import { registrationPinEntityFactory } from '../entity/testing'; + +describe(RegistrationPinRepo.name, () => { + let module: TestingModule; + let repo: RegistrationPinRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [RegistrationPinRepo], + }).compile(); + + repo = module.get(RegistrationPinRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('deleteRegistrationPinByEmail', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const userWithoutRegistrationPin = userFactory.buildWithId(); + const registrationPinForUser = registrationPinEntityFactory.buildWithId({ email: user.email }); + + await em.persistAndFlush(registrationPinForUser); + + return { + user, + userWithoutRegistrationPin, + }; + }; + + describe('when registrationPin exists', () => { + it('should delete registrationPins by email', async () => { + const { user } = await setup(); + + const result: number = await repo.deleteRegistrationPinByEmail(user.email); + + expect(result).toEqual(1); + }); + }); + + describe('when there is no registrationPin', () => { + it('should return empty array', async () => { + const { userWithoutRegistrationPin } = await setup(); + + const result: number = await repo.deleteRegistrationPinByEmail(userWithoutRegistrationPin.email); + expect(result).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts new file mode 100644 index 00000000000..6ca68bc089d --- /dev/null +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts @@ -0,0 +1,14 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { RegistrationPinEntity } from '../entity'; + +@Injectable() +export class RegistrationPinRepo { + constructor(private readonly em: EntityManager) {} + + async deleteRegistrationPinByEmail(email: string): Promise { + const promise: Promise = this.em.nativeDelete(RegistrationPinEntity, { email }); + + return promise; + } +} diff --git a/apps/server/src/modules/registration-pin/service/index.ts b/apps/server/src/modules/registration-pin/service/index.ts new file mode 100644 index 00000000000..c8eea287110 --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/index.ts @@ -0,0 +1 @@ +export * from './registration-pin.service'; diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts new file mode 100644 index 00000000000..b5c6a2f3296 --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts @@ -0,0 +1,66 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities, userDoFactory } from '@shared/testing'; +import { RegistrationPinService } from '.'; +import { RegistrationPinRepo } from '../repo'; + +describe(RegistrationPinService.name, () => { + let module: TestingModule; + let service: RegistrationPinService; + let registrationPinRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RegistrationPinService, + { + provide: RegistrationPinRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RegistrationPinService); + registrationPinRepo = module.get(RegistrationPinRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('deleteRegistrationPinByEmail', () => { + describe('when deleting registrationPin', () => { + const setup = () => { + const user = userDoFactory.buildWithId(); + + registrationPinRepo.deleteRegistrationPinByEmail.mockResolvedValueOnce(1); + + return { + user, + }; + }; + + it('should call registrationPinRep', async () => { + const { user } = setup(); + + await service.deleteRegistrationPinByEmail(user.email); + + expect(registrationPinRepo.deleteRegistrationPinByEmail).toBeCalledWith(user.email); + }); + + it('should delete registrationPin by email', async () => { + const { user } = setup(); + + const result: number = await service.deleteRegistrationPinByEmail(user.email); + + expect(result).toEqual(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts new file mode 100644 index 00000000000..4681b08329c --- /dev/null +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { RegistrationPinRepo } from '../repo'; + +@Injectable() +export class RegistrationPinService { + constructor(private readonly registrationPinRepo: RegistrationPinRepo) {} + + async deleteRegistrationPinByEmail(email: string): Promise { + return this.registrationPinRepo.deleteRegistrationPinByEmail(email); + } +} diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts index dd8ae17667c..57d7c2da254 100644 --- a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts @@ -62,7 +62,7 @@ describe(RocketChatUserService.name, () => { }); }); - describe('deleteUserDataFromClasses', () => { + describe('delete RocketChatUser', () => { describe('when deleting rocketChatUser', () => { const setup = () => { const userId = new ObjectId().toHexString(); diff --git a/apps/server/src/modules/user/service/user.service.spec.ts b/apps/server/src/modules/user/service/user.service.spec.ts index f65d02c13a5..044d169864d 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -361,7 +361,6 @@ describe('UserService', () => { describe('when deleting by userId', () => { const setup = () => { const user1: User = userFactory.asStudent().buildWithId(); - userFactory.asStudent().buildWithId(); userRepo.findById.mockResolvedValue(user1); userRepo.deleteUser.mockResolvedValue(1); @@ -381,4 +380,33 @@ describe('UserService', () => { }); }); }); + + describe('getParentEmailsFromUser', () => { + const setup = () => { + const user: User = userFactory.asStudent().buildWithId(); + const parentEmail = ['test@test.eu']; + + userRepo.getParentEmailsFromUser.mockResolvedValue(parentEmail); + + return { + user, + parentEmail, + }; + }; + + it('should call userRepo.getParentEmailsFromUse', async () => { + const { user } = setup(); + + await service.getParentEmailsFromUser(user.id); + + expect(userRepo.getParentEmailsFromUser).toBeCalledWith(user.id); + }); + + it('should return array with parent emails', async () => { + const { user, parentEmail } = setup(); + + const result = await service.getParentEmailsFromUser(user.id); + expect(result).toEqual(parentEmail); + }); + }); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 6ef014f8696..8f6feca4750 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -120,4 +120,10 @@ export class UserService { return deletedUserNumber; } + + async getParentEmailsFromUser(userId: EntityId): Promise { + const parentEmails = this.userRepo.getParentEmailsFromUser(userId); + + return parentEmails; + } } diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 9dc33c55b78..a7ed0587f54 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -5,6 +5,7 @@ import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { Account } from './account.entity'; import { BoardNode, @@ -100,4 +101,5 @@ export const ALL_ENTITIES = [ UserLoginMigrationEntity, VideoConference, GroupEntity, + RegistrationPinEntity, ]; diff --git a/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts b/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts new file mode 100644 index 00000000000..d5fe53251f9 --- /dev/null +++ b/apps/server/src/shared/domain/entity/user-parents.entity.spec.ts @@ -0,0 +1,23 @@ +import { UserParentsEntity } from './user-parents.entity'; + +describe(UserParentsEntity.name, () => { + describe('constructor', () => { + describe('When a contructor is called', () => { + const setup = () => { + const entity = new UserParentsEntity({ firstName: 'firstName', lastName: 'lastName', email: 'test@test.eu' }); + + return { entity }; + }; + + it('should contain valid tspUid ', () => { + const { entity } = setup(); + + const userParentsEntity: UserParentsEntity = new UserParentsEntity(entity); + + expect(userParentsEntity.firstName).toEqual(entity.firstName); + expect(userParentsEntity.lastName).toEqual(entity.lastName); + expect(userParentsEntity.email).toEqual(entity.email); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/user-parents.entity.ts b/apps/server/src/shared/domain/entity/user-parents.entity.ts new file mode 100644 index 00000000000..a0709396880 --- /dev/null +++ b/apps/server/src/shared/domain/entity/user-parents.entity.ts @@ -0,0 +1,25 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +export interface UserParentsEntityProps { + firstName: string; + lastName: string; + email: string; +} + +@Embeddable() +export class UserParentsEntity { + @Property() + firstName: string; + + @Property() + lastName: string; + + @Property() + email: string; + + constructor(props: UserParentsEntityProps) { + this.firstName = props.firstName; + this.lastName = props.lastName; + this.email = props.email; + } +} diff --git a/apps/server/src/shared/domain/entity/user.entity.ts b/apps/server/src/shared/domain/entity/user.entity.ts index c9a982c3854..dd5c0ec66b3 100644 --- a/apps/server/src/shared/domain/entity/user.entity.ts +++ b/apps/server/src/shared/domain/entity/user.entity.ts @@ -1,8 +1,9 @@ -import { Collection, Entity, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; +import { Collection, Embedded, Entity, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; import { EntityWithSchool } from '../interface'; import { BaseEntityWithTimestamps } from './base.entity'; import { Role } from './role.entity'; import { SchoolEntity } from './school.entity'; +import { UserParentsEntity } from './user-parents.entity'; export enum LanguageType { DE = 'de', @@ -27,6 +28,7 @@ export interface UserProperties { outdatedSince?: Date; previousExternalId?: string; birthday?: Date; + parents?: UserParentsEntity[]; } @Entity({ tableName: 'users' }) @@ -100,6 +102,9 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { @Property({ nullable: true }) birthday?: Date; + @Embedded(() => UserParentsEntity, { array: true, nullable: true }) + parents?: UserParentsEntity[]; + constructor(props: UserProperties) { super(); this.firstName = props.firstName; @@ -117,6 +122,7 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { this.outdatedSince = props.outdatedSince; this.previousExternalId = props.previousExternalId; this.birthday = props.birthday; + this.parents = props.parents; } public resolvePermissions(): string[] { diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index a923b8d128f..1ea116d995a 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -11,6 +11,7 @@ import { systemEntityFactory, userFactory, } from '@shared/testing'; +import { UserParentsEntityProps } from '@shared/domain/entity/user-parents.entity'; import { UserRepo } from './user.repo'; describe('user repo', () => { @@ -70,6 +71,7 @@ describe('user repo', () => { 'externalId', 'forcePasswordChange', 'importHash', + 'parents', 'preferences', 'language', 'deletedAt', @@ -160,6 +162,7 @@ describe('user repo', () => { 'externalId', 'forcePasswordChange', 'importHash', + 'parents', 'preferences', 'language', 'deletedAt', @@ -449,4 +452,36 @@ describe('user repo', () => { }); }); }); + + describe('getParentEmailsFromUser', () => { + const setup = async () => { + const parentOfUser: UserParentsEntityProps = { + firstName: 'firstName', + lastName: 'lastName', + email: 'test@test.eu', + }; + const user = userFactory.asStudent().buildWithId({ + parents: [parentOfUser], + }); + + const expectedParentEmail = [parentOfUser.email]; + + await em.persistAndFlush(user); + em.clear(); + + return { + user, + expectedParentEmail, + }; + }; + + describe('when searching user parent email', () => { + it('should return array witn parent email', async () => { + const { user, expectedParentEmail } = await setup(); + const result = await repo.getParentEmailsFromUser(user.id); + + expect(result).toEqual(expectedParentEmail); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/user/user.repo.ts b/apps/server/src/shared/repo/user/user.repo.ts index 44acafe6a80..2f693ab7124 100644 --- a/apps/server/src/shared/repo/user/user.repo.ts +++ b/apps/server/src/shared/repo/user/user.repo.ts @@ -170,6 +170,13 @@ export class UserRepo extends BaseRepo { return deletedUserNumber; } + async getParentEmailsFromUser(userId: EntityId): Promise { + const user = await this._em.findOneOrFail(User, { id: userId }); + const parentsEmails = user.parents?.map((parent) => parent.email) ?? []; + + return parentsEmails; + } + private async populateRoles(roles: Role[]): Promise { for (let i = 0; i < roles.length; i += 1) { const role = roles[i];