From 88e6c5fdb298bb0646707591753896bdb3e6229d Mon Sep 17 00:00:00 2001 From: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> Date: Wed, 15 Nov 2023 20:31:58 +0100 Subject: [PATCH] BC-5629 batch deletion mechanism (#4521) * first commit * add some tests * add test cases and services * add new (almost empty for now) batch deletion app * refactor config vars * add optional env var for specifying delay between the API calls * add usecases and test cases * fix importing * add type in uc * fix import * add references service that'll load all the references to the data we want to delete * fix most of issue form review * add deletion API client with just a single method for now that allows for sending a deletion request * refactor the env vars for configurting the Admin API * add exporting DeletionClientConfig * move references service to the deletion module * delete unused code * add batch deletion service that makes it possible ot queue deletion for many references at once * move some parts of the interface to the interface subdir * add an interface for the batch deletion summary * move some interfaces to a separate subdir * refactor the batch deletion summary interface * add uc for the batch deletion * remove unused annotation * refactor deletion client implementation * add batch deletion service implementation * add UC for the batch deletion * add a console app for the deletion module and a console command to manage deletion requests queue * remove no longer used app, add param to make it possible to define delay between the client calls for the case one would like to queue many thousands of deletion requests at once * remove no longer used separate batch-deletion module (it became a part of the main deletion module) * fix invalid key * remove no longer used config vars * remove no longer used commands * remove no longer used Nest cli config * remove no longer used code * change name of the method that prepares default headers * add builders for most of the interfaces * add builders for the remaining interfaces * add type in catch clause * do some adjustments, move PushDeletionRequestsOptions interface to a separate file * remove unused import * rollback * remove unnecessary indent * remove unnecessary indents * remove empty line * remove repeated imports * refactor some imports to omit calling Configuration.get() on every subpackage import * add builder for the DeletionRequestOutput class * add unit tests for the batch deletion service * add unit tests for the BatchDeletionUc * modify env keys for the Admin API client configuration, refactor the way the deletion module's console is bootstrapped * fix invalid import, remove unused undefined arg * add comment to ignore console.ts file for coverage * move deletion client config interface to a separate file, refactor function that prepares current config, add unit tests for it * fix invalid import * add more test cases to the deletion client unit tests * change invalid import Co-authored-by: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> * fix invalid import * add builder for the PushDeletionRequestsOptions class, add unit tests for the DeletionQueueConsole * rename the file containing the deletion module console to deletion.console.ts, add coverage exclusion for it for the Sonar coverage analysis * remove deletion.console.ts from the sonar.coverage.exclusions param as it doesn't seem to work anyway * add deletion.console.ts file to the coverage exclusions (another try with different path) * change name of the file containing the deletion console app * fix some imports * move default value for the ADMIN_API_CLIENT object to default.schema.json * move default for the BASE_URL * move Deletion module console app to the apps/ dir * add separate functino to log error and set exit code * add handling of the case that only CR chars are used as a line separators * add use of the BatchDeletionSummaryBuilder in place of an anonymous object creation * fix some imports/exports * refactor console app flow --------- Co-authored-by: WojciechGrancow Co-authored-by: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> --- apps/server/src/apps/deletion-console.app.ts | 31 +++ .../deletion-request-input.builder.spec.ts | 33 +++ .../builder/deletion-request-input.builder.ts | 11 + .../deletion-request-output.builder.spec.ts | 29 +++ .../deletion-request-output.builder.ts | 10 + ...n-request-target-ref-input.builder.spec.ts | 26 +++ ...letion-request-target-ref-input.builder.ts | 7 + .../modules/deletion/client/builder/index.ts | 3 + .../client/deletion-client.config.spec.ts | 41 ++++ .../deletion/client/deletion-client.config.ts | 9 + .../deletion/client/deletion.client.spec.ts | 154 ++++++++++++++ .../deletion/client/deletion.client.ts | 66 ++++++ .../src/modules/deletion/client/index.ts | 3 + .../deletion-client-config.interface.ts | 4 + .../deletion-request-input.interface.ts | 6 + .../deletion-request-output.interface.ts | 4 + ...tion-request-target-ref-input.interface.ts | 4 + .../deletion/client/interface/index.ts | 4 + .../modules/deletion/console/builder/index.ts | 1 + ...sh-delete-requests-options.builder.spec.ts | 43 ++++ .../push-delete-requests-options.builder.ts | 17 ++ .../console/deletion-console.module.ts | 22 ++ .../console/deletion-queue.console.spec.ts | 79 +++++++ .../console/deletion-queue.console.ts | 46 +++++ .../src/modules/deletion/console/index.ts | 1 + .../deletion/console/interface/index.ts | 1 + .../push-delete-requests-options.interface.ts | 6 + apps/server/src/modules/deletion/index.ts | 2 + .../services/batch-deletion.service.spec.ts | 97 +++++++++ .../services/batch-deletion.service.ts | 55 +++++ .../deletion/services/builder/index.ts | 2 + ...eue-deletion-request-input.builder.spec.ts | 27 +++ .../queue-deletion-request-input.builder.ts | 7 + ...ue-deletion-request-output.builder.spec.ts | 46 +++++ .../queue-deletion-request-output.builder.ts | 29 +++ .../src/modules/deletion/services/index.ts | 4 + .../deletion/services/interface/index.ts | 2 + .../queue-deletion-request-input.interface.ts | 5 + ...queue-deletion-request-output.interface.ts | 5 + .../services/references.service.spec.ts | 74 +++++++ .../deletion/services/references.service.ts | 27 +++ .../deletion/uc/batch-deletion.uc.spec.ts | 195 ++++++++++++++++++ .../modules/deletion/uc/batch-deletion.uc.ts | 71 +++++++ ...ch-deletion-summary-detail.builder.spec.ts | 69 +++++++ .../batch-deletion-summary-detail.builder.ts | 8 + .../batch-deletion-summary.builder.spec.ts | 30 +++ .../builder/batch-deletion-summary.builder.ts | 13 ++ .../src/modules/deletion/uc/builder/index.ts | 2 + apps/server/src/modules/deletion/uc/index.ts | 2 + ...batch-deletion-summary-detail.interface.ts | 6 + ...ch-deletion-summary-overall-status.enum.ts | 4 + .../batch-deletion-summary.interface.ts | 9 + .../modules/deletion/uc/interface/index.ts | 3 + config/default.schema.json | 17 ++ nest-cli.json | 9 + package.json | 3 + 56 files changed, 1484 insertions(+) create mode 100644 apps/server/src/apps/deletion-console.app.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts create mode 100644 apps/server/src/modules/deletion/client/builder/index.ts create mode 100644 apps/server/src/modules/deletion/client/deletion-client.config.spec.ts create mode 100644 apps/server/src/modules/deletion/client/deletion-client.config.ts create mode 100644 apps/server/src/modules/deletion/client/deletion.client.spec.ts create mode 100644 apps/server/src/modules/deletion/client/deletion.client.ts create mode 100644 apps/server/src/modules/deletion/client/index.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts create mode 100644 apps/server/src/modules/deletion/client/interface/index.ts create mode 100644 apps/server/src/modules/deletion/console/builder/index.ts create mode 100644 apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-console.module.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts create mode 100644 apps/server/src/modules/deletion/console/deletion-queue.console.ts create mode 100644 apps/server/src/modules/deletion/console/index.ts create mode 100644 apps/server/src/modules/deletion/console/interface/index.ts create mode 100644 apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts create mode 100644 apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts create mode 100644 apps/server/src/modules/deletion/services/batch-deletion.service.ts create mode 100644 apps/server/src/modules/deletion/services/builder/index.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts create mode 100644 apps/server/src/modules/deletion/services/interface/index.ts create mode 100644 apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts create mode 100644 apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts create mode 100644 apps/server/src/modules/deletion/services/references.service.spec.ts create mode 100644 apps/server/src/modules/deletion/services/references.service.ts create mode 100644 apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/batch-deletion.uc.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts create mode 100644 apps/server/src/modules/deletion/uc/builder/index.ts create mode 100644 apps/server/src/modules/deletion/uc/index.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts create mode 100644 apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts diff --git a/apps/server/src/apps/deletion-console.app.ts b/apps/server/src/apps/deletion-console.app.ts new file mode 100644 index 00000000000..cafb137e160 --- /dev/null +++ b/apps/server/src/apps/deletion-console.app.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +import { BootstrapConsole } from 'nestjs-console'; +import { DeletionConsoleModule } from '@modules/deletion'; + +async function run() { + const bootstrap = new BootstrapConsole({ + module: DeletionConsoleModule, + useDecorators: true, + }); + + const app = await bootstrap.init(); + + try { + await app.init(); + + // Execute console application with provided arguments. + await bootstrap.boot(); + } catch (err) { + // eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-call + console.error(err); + + // Set the exit code to 1 to indicate a console app failure. + process.exitCode = 1; + } + + // Always close the app, even if some exception + // has been thrown from the console app. + await app.close(); +} + +void run(); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..bd49bb841e6 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.spec.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestInputBuilder } from './deletion-request-input.builder'; + +describe(DeletionRequestInputBuilder.name, () => { + describe(DeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'school'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 43200; + + const expectedOutput: DeletionRequestInput = { + targetRef: { + domain: targetRefDomain, + id: targetRefId, + }, + deleteInMinutes, + }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = DeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts new file mode 100644 index 00000000000..28091418065 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-input.builder.ts @@ -0,0 +1,11 @@ +import { DeletionRequestInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +export class DeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes?: number): DeletionRequestInput { + return { + targetRef: DeletionRequestTargetRefInputBuilder.build(targetRefDomain, targetRefId), + deleteInMinutes, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..399821f33ff --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.spec.ts @@ -0,0 +1,29 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestOutput } from '../interface'; +import { DeletionRequestOutputBuilder } from './deletion-request-output.builder'; + +describe(DeletionRequestOutputBuilder.name, () => { + describe(DeletionRequestOutputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: DeletionRequestOutput = { + requestId, + deletionPlannedAt, + }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = DeletionRequestOutputBuilder.build(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts new file mode 100644 index 00000000000..9192c1a47ce --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-output.builder.ts @@ -0,0 +1,10 @@ +import { DeletionRequestOutput } from '../interface'; + +export class DeletionRequestOutputBuilder { + static build(requestId: string, deletionPlannedAt: Date): DeletionRequestOutput { + return { + requestId, + deletionPlannedAt, + }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts new file mode 100644 index 00000000000..74b0631e49d --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.spec.ts @@ -0,0 +1,26 @@ +import { ObjectId } from 'bson'; +import { DeletionRequestTargetRefInput } from '../interface'; +import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; + +describe(DeletionRequestTargetRefInputBuilder.name, () => { + describe(DeletionRequestTargetRefInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const domain = 'user'; + const id = new ObjectId().toHexString(); + + const expectedOutput: DeletionRequestTargetRefInput = { domain, id }; + + return { domain, id, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { domain, id, expectedOutput } = setup(); + + const output = DeletionRequestTargetRefInputBuilder.build(domain, id); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts new file mode 100644 index 00000000000..ed3c0219993 --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/deletion-request-target-ref-input.builder.ts @@ -0,0 +1,7 @@ +import { DeletionRequestTargetRefInput } from '../interface'; + +export class DeletionRequestTargetRefInputBuilder { + static build(domain: string, id: string): DeletionRequestTargetRefInput { + return { domain, id }; + } +} diff --git a/apps/server/src/modules/deletion/client/builder/index.ts b/apps/server/src/modules/deletion/client/builder/index.ts new file mode 100644 index 00000000000..85644a6b2ee --- /dev/null +++ b/apps/server/src/modules/deletion/client/builder/index.ts @@ -0,0 +1,3 @@ +export * from './deletion-request-target-ref-input.builder'; +export * from './deletion-request-input.builder'; +export * from './deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts new file mode 100644 index 00000000000..a3cae21e425 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.spec.ts @@ -0,0 +1,41 @@ +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; +import { getDeletionClientConfig } from './deletion-client.config'; + +describe(getDeletionClientConfig.name, () => { + let configBefore: IConfig; + + beforeAll(() => { + configBefore = Configuration.toObject({ plainSecrets: true }); + }); + + afterEach(() => { + Configuration.reset(configBefore); + }); + + describe('when called', () => { + const setup = () => { + const baseUrl = 'http://api-admin:4030'; + const apiKey = '652559c2-93da-42ad-94e1-640e3afbaca0'; + + Configuration.set('ADMIN_API_CLIENT__BASE_URL', baseUrl); + Configuration.set('ADMIN_API_CLIENT__API_KEY', apiKey); + + const expectedConfig: DeletionClientConfig = { + ADMIN_API_CLIENT_BASE_URL: baseUrl, + ADMIN_API_CLIENT_API_KEY: apiKey, + }; + + return { expectedConfig }; + }; + + it('should return config with proper values', () => { + const { expectedConfig } = setup(); + + const config = getDeletionClientConfig(); + + expect(config).toEqual(expectedConfig); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/client/deletion-client.config.ts b/apps/server/src/modules/deletion/client/deletion-client.config.ts new file mode 100644 index 00000000000..db5bf7ff226 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion-client.config.ts @@ -0,0 +1,9 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { DeletionClientConfig } from './interface'; + +export const getDeletionClientConfig = (): DeletionClientConfig => { + return { + ADMIN_API_CLIENT_BASE_URL: Configuration.get('ADMIN_API_CLIENT__BASE_URL') as string, + ADMIN_API_CLIENT_API_KEY: Configuration.get('ADMIN_API_CLIENT__API_KEY') as string, + }; +}; diff --git a/apps/server/src/modules/deletion/client/deletion.client.spec.ts b/apps/server/src/modules/deletion/client/deletion.client.spec.ts new file mode 100644 index 00000000000..096b1f9b082 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.spec.ts @@ -0,0 +1,154 @@ +import { of } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { axiosResponseFactory } from '@shared/testing'; +import { DeletionRequestInputBuilder, DeletionRequestOutputBuilder } from '.'; +import { DeletionRequestOutput } from './interface'; +import { DeletionClient } from './deletion.client'; + +describe(DeletionClient.name, () => { + let module: TestingModule; + let client: DeletionClient; + let httpService: DeepMocked; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionClient, + { + provide: ConfigService, + useValue: createMock({ + get: jest.fn((key: string) => { + if (key === 'ADMIN_API_CLIENT_BASE_URL') { + return 'http://localhost:4030'; + } + + // Default is for the Admin APIs API Key. + return '6b3df003-61e9-467c-9e6b-579634801896'; + }), + }), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + client = module.get(DeletionClient); + httpService = module.get(HttpService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('queueDeletionRequest', () => { + describe('when received valid response with expected HTTP status code', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '6536ce29b595d7c8e5faf200', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input, output }; + }; + + it('should return proper output', async () => { + const { input, output } = setup(); + + const result = await client.queueDeletionRequest(input); + + expect(result).toEqual(output); + }); + }); + + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build('', new Date()); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 200, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no requestId in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const output: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + '', + new Date('2024-10-15T12:42:50.521Z') + ); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: output, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).rejects.toThrow(Error); + }); + }); + + describe('when received no deletionPlannedAt in a response', () => { + const setup = () => { + const input = DeletionRequestInputBuilder.build('user', '652f1625e9bc1a13bdaae48b'); + + const response: AxiosResponse = axiosResponseFactory.build({ + data: { + requestId: '6536ce29b595d7c8e5faf200', + }, + status: 202, + }); + + httpService.post.mockReturnValueOnce(of(response)); + + return { input }; + }; + + it('should throw an exception', async () => { + const { input } = setup(); + + await expect(client.queueDeletionRequest(input)).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 new file mode 100644 index 00000000000..66bb267d070 --- /dev/null +++ b/apps/server/src/modules/deletion/client/deletion.client.ts @@ -0,0 +1,66 @@ +import { firstValueFrom } from 'rxjs'; +import { AxiosResponse } from 'axios'; +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { DeletionRequestInput, DeletionRequestOutput, DeletionClientConfig } from './interface'; + +@Injectable() +export class DeletionClient { + private readonly baseUrl: string; + + private readonly apiKey: string; + + private readonly postDeletionRequestsEndpoint: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) { + this.baseUrl = this.configService.get('ADMIN_API_CLIENT_BASE_URL'); + this.apiKey = this.configService.get('ADMIN_API_CLIENT_API_KEY'); + + // 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(); + } + + 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()}`); + }); + } + + private apiKeyHeader() { + return { 'X-Api-Key': this.apiKey }; + } + + private defaultHeaders() { + return { + headers: this.apiKeyHeader(), + }; + } +} diff --git a/apps/server/src/modules/deletion/client/index.ts b/apps/server/src/modules/deletion/client/index.ts new file mode 100644 index 00000000000..fde3db98f3b --- /dev/null +++ b/apps/server/src/modules/deletion/client/index.ts @@ -0,0 +1,3 @@ +export * from './interface'; +export * from './builder'; +export * from './deletion.client'; diff --git a/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts new file mode 100644 index 00000000000..8178515b6d4 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-client-config.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionClientConfig { + ADMIN_API_CLIENT_BASE_URL: string; + ADMIN_API_CLIENT_API_KEY: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts new file mode 100644 index 00000000000..4879ce4d972 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-input.interface.ts @@ -0,0 +1,6 @@ +import { DeletionRequestTargetRefInput } from './deletion-request-target-ref-input.interface'; + +export interface DeletionRequestInput { + targetRef: DeletionRequestTargetRefInput; + deleteInMinutes?: number; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts new file mode 100644 index 00000000000..b61a372d5a5 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-output.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestOutput { + requestId: string; + deletionPlannedAt: Date; +} diff --git a/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts new file mode 100644 index 00000000000..603bb0c13ec --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/deletion-request-target-ref-input.interface.ts @@ -0,0 +1,4 @@ +export interface DeletionRequestTargetRefInput { + domain: string; + id: string; +} diff --git a/apps/server/src/modules/deletion/client/interface/index.ts b/apps/server/src/modules/deletion/client/interface/index.ts new file mode 100644 index 00000000000..38f0f639731 --- /dev/null +++ b/apps/server/src/modules/deletion/client/interface/index.ts @@ -0,0 +1,4 @@ +export * from './deletion-client-config.interface'; +export * from './deletion-request-target-ref-input.interface'; +export * from './deletion-request-input.interface'; +export * from './deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/console/builder/index.ts b/apps/server/src/modules/deletion/console/builder/index.ts new file mode 100644 index 00000000000..12fd0997ebe --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.builder'; diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts new file mode 100644 index 00000000000..5c83defdd1e --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.spec.ts @@ -0,0 +1,43 @@ +import { PushDeletionRequestsOptions } from '../interface'; +import { PushDeleteRequestsOptionsBuilder } from './push-delete-requests-options.builder'; + +describe(PushDeleteRequestsOptionsBuilder.name, () => { + describe(PushDeleteRequestsOptionsBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const expectedOutput: PushDeletionRequestsOptions = { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + expectedOutput, + }; + }; + + it('should return valid object with expected values', () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, expectedOutput } = setup(); + + const output = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts new file mode 100644 index 00000000000..f8ceae9263d --- /dev/null +++ b/apps/server/src/modules/deletion/console/builder/push-delete-requests-options.builder.ts @@ -0,0 +1,17 @@ +import { PushDeletionRequestsOptions } from '../interface'; + +export class PushDeleteRequestsOptionsBuilder { + static build( + refsFilePath: string, + targetRefDomain: string, + deleteInMinutes: number, + callsDelayMs: number + ): PushDeletionRequestsOptions { + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + }; + } +} diff --git a/apps/server/src/modules/deletion/console/deletion-console.module.ts b/apps/server/src/modules/deletion/console/deletion-console.module.ts new file mode 100644 index 00000000000..0585b3631da --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-console.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { ConsoleModule } from 'nestjs-console'; +import { ConsoleWriterModule } from '@infra/console'; +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 { DeletionQueueConsole } from './deletion-queue.console'; + +@Module({ + imports: [ + ConsoleModule, + ConsoleWriterModule, + HttpModule, + ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), + ], + providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole], +}) +export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts new file mode 100644 index 00000000000..61f9cf0ff53 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConsoleWriterService } from '@infra/console'; +import { createMock } from '@golevelup/ts-jest'; +import { BatchDeletionUc } from '../uc'; +import { DeletionQueueConsole } from './deletion-queue.console'; +import { PushDeleteRequestsOptionsBuilder } from './builder'; + +describe(DeletionQueueConsole.name, () => { + let module: TestingModule; + let console: DeletionQueueConsole; + let batchDeletionUc: BatchDeletionUc; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionQueueConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: BatchDeletionUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(DeletionQueueConsole); + batchDeletionUc = module.get(BatchDeletionUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('pushDeletionRequests', () => { + describe('when called with valid options', () => { + const setup = () => { + const refsFilePath = '/tmp/ids.txt'; + const targetRefDomain = 'school'; + const deleteInMinutes = 43200; + const callsDelayMs = 100; + + const options = PushDeleteRequestsOptionsBuilder.build( + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs + ); + + return { + refsFilePath, + targetRefDomain, + deleteInMinutes, + callsDelayMs, + options, + }; + }; + + it(`should call ${BatchDeletionUc.name} with proper arguments`, async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs, options } = setup(); + + const spy = jest.spyOn(batchDeletionUc, 'deleteRefsFromTxtFile'); + + await console.pushDeletionRequests(options); + + expect(spy).toBeCalledWith(refsFilePath, targetRefDomain, deleteInMinutes, callsDelayMs); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/console/deletion-queue.console.ts b/apps/server/src/modules/deletion/console/deletion-queue.console.ts new file mode 100644 index 00000000000..c8b133dba84 --- /dev/null +++ b/apps/server/src/modules/deletion/console/deletion-queue.console.ts @@ -0,0 +1,46 @@ +import { Console, Command } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { BatchDeletionUc } from '../uc'; +import { PushDeletionRequestsOptions } from './interface'; + +@Console({ command: 'queue', description: 'Console providing an access to the deletion queue.' }) +export class DeletionQueueConsole { + constructor(private consoleWriter: ConsoleWriterService, private batchDeletionUc: BatchDeletionUc) {} + + @Command({ + command: 'push', + description: 'Push new deletion requests to the deletion queue.', + options: [ + { + flags: '-rfp, --refsFilePath ', + description: 'Path of the file containing all the references to the data that should be deleted.', + required: true, + }, + { + flags: '-trd, --targetRefDomain ', + description: 'Name of the target ref domain.', + required: false, + }, + { + flags: '-dim, --deleteInMinutes ', + description: 'Number of minutes after which the data deletion process should begin.', + required: false, + }, + { + flags: '-cdm, --callsDelayMs ', + description: 'Delay between all the performed client calls, in milliseconds.', + required: false, + }, + ], + }) + async pushDeletionRequests(options: PushDeletionRequestsOptions): Promise { + const summary = await this.batchDeletionUc.deleteRefsFromTxtFile( + options.refsFilePath, + options.targetRefDomain, + options.deleteInMinutes ? Number(options.deleteInMinutes) : undefined, + options.callsDelayMs ? Number(options.callsDelayMs) : undefined + ); + + this.consoleWriter.info(JSON.stringify(summary)); + } +} diff --git a/apps/server/src/modules/deletion/console/index.ts b/apps/server/src/modules/deletion/console/index.ts new file mode 100644 index 00000000000..db47bd8c99f --- /dev/null +++ b/apps/server/src/modules/deletion/console/index.ts @@ -0,0 +1 @@ +export * from './deletion-console.module'; diff --git a/apps/server/src/modules/deletion/console/interface/index.ts b/apps/server/src/modules/deletion/console/interface/index.ts new file mode 100644 index 00000000000..2fcb281430f --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/index.ts @@ -0,0 +1 @@ +export * from './push-delete-requests-options.interface'; diff --git a/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts new file mode 100644 index 00000000000..a54f652cf94 --- /dev/null +++ b/apps/server/src/modules/deletion/console/interface/push-delete-requests-options.interface.ts @@ -0,0 +1,6 @@ +export interface PushDeletionRequestsOptions { + refsFilePath: string; + targetRefDomain: string; + deleteInMinutes: number; + callsDelayMs: number; +} diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts index bd89c1e8d84..793f306e7a0 100644 --- a/apps/server/src/modules/deletion/index.ts +++ b/apps/server/src/modules/deletion/index.ts @@ -1,2 +1,4 @@ export * from './deletion.module'; export * from './services'; +export * from './client'; +export * from './console'; diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts new file mode 100644 index 00000000000..640e290af1a --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.spec.ts @@ -0,0 +1,97 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DeletionClient, DeletionRequestOutput, DeletionRequestOutputBuilder } from '../client'; +import { QueueDeletionRequestInputBuilder, QueueDeletionRequestOutputBuilder } from './builder'; +import { BatchDeletionService } from './batch-deletion.service'; + +describe(BatchDeletionService.name, () => { + let module: TestingModule; + let service: BatchDeletionService; + let deletionClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionService, + { + provide: DeletionClient, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(BatchDeletionService); + deletionClient = module.get(DeletionClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('queueDeletionRequests', () => { + describe('when called with valid inputs array and a requested delay between the client calls', () => { + describe("when client doesn't throw any error", () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const queueDeletionRequestOutput: DeletionRequestOutput = DeletionRequestOutputBuilder.build( + requestId, + deletionPlannedAt + ); + + deletionClient.queueDeletionRequest.mockResolvedValueOnce(queueDeletionRequestOutput); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with successful status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs, 1); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + + describe('when client throws an error', () => { + const setup = () => { + const inputs = [QueueDeletionRequestInputBuilder.build('user', new ObjectId().toHexString(), 60)]; + + const error = new Error('connection error'); + + deletionClient.queueDeletionRequest.mockRejectedValueOnce(error); + + const expectedOutput = QueueDeletionRequestOutputBuilder.buildError(error); + + const expectedOutputs = [expectedOutput]; + + return { inputs, expectedOutputs }; + }; + + it('should return an output object with failure status info', async () => { + const { inputs, expectedOutputs } = setup(); + + const outputs = await service.queueDeletionRequests(inputs); + + expect(outputs).toStrictEqual(expectedOutputs); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/batch-deletion.service.ts b/apps/server/src/modules/deletion/services/batch-deletion.service.ts new file mode 100644 index 00000000000..d8c14f315a0 --- /dev/null +++ b/apps/server/src/modules/deletion/services/batch-deletion.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { QueueDeletionRequestOutputBuilder } from './builder'; +import { DeletionClient, DeletionRequestInputBuilder } from '../client'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from './interface'; + +@Injectable() +export class BatchDeletionService { + constructor(private readonly deletionClient: DeletionClient) {} + + async queueDeletionRequests( + inputs: QueueDeletionRequestInput[], + callsDelayMilliseconds?: number + ): Promise { + const outputs: QueueDeletionRequestOutput[] = []; + + // For every provided deletion request input, try to queue it via deletion client. + // In any case, add the result of the trial to the outputs - it will be either a valid + // response in a form of a requestId + deletionPlannedAt values pair or some error + // returned from the client. In any case, every input should be processed. + for (const input of inputs) { + const deletionRequestInput = DeletionRequestInputBuilder.build( + input.targetRefDomain, + input.targetRefId, + input.deleteInMinutes + ); + + try { + // eslint-disable-next-line no-await-in-loop + const deletionRequestOutput = await this.deletionClient.queueDeletionRequest(deletionRequestInput); + + // In case of a successful client response, add the + // requestId + deletionPlannedAt values pair to the outputs. + outputs.push( + QueueDeletionRequestOutputBuilder.buildSuccess( + deletionRequestOutput.requestId, + deletionRequestOutput.deletionPlannedAt + ) + ); + } catch (err) { + // In case of a failure client response, add the full error message to the outputs. + outputs.push(QueueDeletionRequestOutputBuilder.buildError(err as Error)); + } + + // If any delay between the client calls has been requested, "sleep" for the specified amount of time. + if (callsDelayMilliseconds && callsDelayMilliseconds > 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, callsDelayMilliseconds); + }); + } + } + + return outputs; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/index.ts b/apps/server/src/modules/deletion/services/builder/index.ts new file mode 100644 index 00000000000..acd85a37989 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.builder'; +export * from './queue-deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts new file mode 100644 index 00000000000..e5d87858156 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.spec.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestInput } from '../interface'; +import { QueueDeletionRequestInputBuilder } from './queue-deletion-request-input.builder'; + +describe(QueueDeletionRequestInputBuilder.name, () => { + describe(QueueDeletionRequestInputBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const targetRefDomain = 'user'; + const targetRefId = new ObjectId().toHexString(); + const deleteInMinutes = 60; + + const expectedOutput: QueueDeletionRequestInput = { targetRefDomain, targetRefId, deleteInMinutes }; + + return { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { targetRefDomain, targetRefId, deleteInMinutes, expectedOutput } = setup(); + + const output = QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefId, deleteInMinutes); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts new file mode 100644 index 00000000000..a7fff2152b9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-input.builder.ts @@ -0,0 +1,7 @@ +import { QueueDeletionRequestInput } from '../interface'; + +export class QueueDeletionRequestInputBuilder { + static build(targetRefDomain: string, targetRefId: string, deleteInMinutes: number): QueueDeletionRequestInput { + return { targetRefDomain, targetRefId, deleteInMinutes }; + } +} diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts new file mode 100644 index 00000000000..cd835a9cf4a --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.spec.ts @@ -0,0 +1,46 @@ +import { ObjectId } from 'bson'; +import { QueueDeletionRequestOutput } from '../interface'; +import { QueueDeletionRequestOutputBuilder } from './queue-deletion-request-output.builder'; + +describe(QueueDeletionRequestOutputBuilder.name, () => { + describe(QueueDeletionRequestOutputBuilder.buildSuccess.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const requestId = new ObjectId().toHexString(); + const deletionPlannedAt = new Date(); + + const expectedOutput: QueueDeletionRequestOutput = { requestId, deletionPlannedAt }; + + return { requestId, deletionPlannedAt, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { requestId, deletionPlannedAt, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildSuccess(requestId, deletionPlannedAt); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + + describe(QueueDeletionRequestOutputBuilder.buildError.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const error = new Error('test error message'); + + const expectedOutput: QueueDeletionRequestOutput = { error: error.toString() }; + + return { error, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { error, expectedOutput } = setup(); + + const output = QueueDeletionRequestOutputBuilder.buildError(error); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts new file mode 100644 index 00000000000..c44e503cbaf --- /dev/null +++ b/apps/server/src/modules/deletion/services/builder/queue-deletion-request-output.builder.ts @@ -0,0 +1,29 @@ +import { QueueDeletionRequestOutput } from '../interface'; + +export class QueueDeletionRequestOutputBuilder { + private static build(requestId?: string, deletionPlannedAt?: Date, error?: string): QueueDeletionRequestOutput { + const output: QueueDeletionRequestOutput = {}; + + if (requestId) { + output.requestId = requestId; + } + + if (deletionPlannedAt) { + output.deletionPlannedAt = deletionPlannedAt; + } + + if (error) { + output.error = error.toString(); + } + + return output; + } + + static buildSuccess(requestId: string, deletionPlannedAt: Date): QueueDeletionRequestOutput { + return this.build(requestId, deletionPlannedAt); + } + + static buildError(err: Error): QueueDeletionRequestOutput { + return this.build(undefined, undefined, err.toString()); + } +} diff --git a/apps/server/src/modules/deletion/services/index.ts b/apps/server/src/modules/deletion/services/index.ts index 9661354718c..52e0b6ba22d 100644 --- a/apps/server/src/modules/deletion/services/index.ts +++ b/apps/server/src/modules/deletion/services/index.ts @@ -1 +1,5 @@ +export * from './interface'; +export * from './builder'; +export * from './references.service'; +export * from './batch-deletion.service'; export * from './deletion-request.service'; diff --git a/apps/server/src/modules/deletion/services/interface/index.ts b/apps/server/src/modules/deletion/services/interface/index.ts new file mode 100644 index 00000000000..8a455440798 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/index.ts @@ -0,0 +1,2 @@ +export * from './queue-deletion-request-input.interface'; +export * from './queue-deletion-request-output.interface'; diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts new file mode 100644 index 00000000000..b421943bce9 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-input.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestInput { + targetRefDomain: string; + targetRefId: string; + deleteInMinutes: number; +} diff --git a/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts new file mode 100644 index 00000000000..375ff811857 --- /dev/null +++ b/apps/server/src/modules/deletion/services/interface/queue-deletion-request-output.interface.ts @@ -0,0 +1,5 @@ +export interface QueueDeletionRequestOutput { + requestId?: string; + deletionPlannedAt?: Date; + error?: string; +} diff --git a/apps/server/src/modules/deletion/services/references.service.spec.ts b/apps/server/src/modules/deletion/services/references.service.spec.ts new file mode 100644 index 00000000000..26eb6e0c8d7 --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.spec.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import { ReferencesService } from './references.service'; + +describe(ReferencesService.name, () => { + describe(ReferencesService.loadFromTxtFile.name, () => { + const setup = (mockedFileContent: string) => { + jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockedFileContent); + }; + + describe('when passed a completely empty file (without any content)', () => { + it('should return an empty references array', () => { + setup(''); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file without any references (just some empty lines)', () => { + it('should return an empty references array', () => { + setup('\n\n \n \n\n\n \n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([]); + }); + }); + + describe('when passed a file with 3 references on a few separate lines', () => { + describe('split with CRs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r653fd3b784ca851b17e9857a\r653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with LFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\n653fd3b784ca851b17e9857a\n653fd3b784ca851b17e9857b\n\n\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + + describe('split with CRLFs', () => { + it('should return an array with all the references present in a file', () => { + setup('653fd3b784ca851b17e98579\r\n653fd3b784ca851b17e9857a\r\n653fd3b784ca851b17e9857b\r\n\r\n\r\n'); + + const references = ReferencesService.loadFromTxtFile('references.txt'); + + expect(references).toEqual([ + '653fd3b784ca851b17e98579', + '653fd3b784ca851b17e9857a', + '653fd3b784ca851b17e9857b', + ]); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/services/references.service.ts b/apps/server/src/modules/deletion/services/references.service.ts new file mode 100644 index 00000000000..991c36c02af --- /dev/null +++ b/apps/server/src/modules/deletion/services/references.service.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; + +export class ReferencesService { + static loadFromTxtFile(filePath: string): string[] { + let fileContent = fs.readFileSync(filePath).toString(); + + // Replace all the CRLF occurrences with just a LF. + fileContent = fileContent.replace(/\r\n?/g, '\n'); + + // Split the whole file content by a line feed (LF) char (\n). + const fileLines = fileContent.split('\n'); + + const references: string[] = []; + + // Iterate over all the file lines and if it contains a valid id (which is + // basically any non-empty string), add it to the loaded references array. + fileLines.forEach((fileLine) => { + const reference = fileLine.trim(); + + if (reference && reference.length > 0) { + references.push(reference); + } + }); + + return references; + } +} diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts new file mode 100644 index 00000000000..7292f36efb0 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.spec.ts @@ -0,0 +1,195 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { + BatchDeletionService, + QueueDeletionRequestInputBuilder, + QueueDeletionRequestOutput, + QueueDeletionRequestOutputBuilder, + ReferencesService, +} from '../services'; +import { BatchDeletionSummaryDetail, BatchDeletionSummaryOverallStatus } from './interface'; +import { BatchDeletionSummaryDetailBuilder } from './builder'; +import { BatchDeletionUc } from './batch-deletion.uc'; + +describe(BatchDeletionUc.name, () => { + let module: TestingModule; + let uc: BatchDeletionUc; + let batchDeletionService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + BatchDeletionUc, + { + provide: BatchDeletionService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(BatchDeletionUc); + batchDeletionService = module.get(BatchDeletionService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('uc should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('deleteRefsFromTxtFile', () => { + describe('when called with valid arguments', () => { + describe('when batch deletion service returns an expected amount of outputs', () => { + describe('when only successful executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.SUCCESS, + successCount: 3, + failureCount: 0, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and a successful overall status', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + + describe('when both successful and failed executions took place', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + const targetRefDomain = 'school'; + const deleteInMinutes = 60; + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs = [ + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + QueueDeletionRequestOutputBuilder.buildError(new Error('some error occurred...')), + QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date()), + ]; + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce([outputs[0], outputs[1], outputs[2]]); + + const expectedSummaryFieldsDetails: BatchDeletionSummaryDetail[] = []; + + for (let i = 0; i < targetRefIds.length; i += 1) { + expectedSummaryFieldsDetails.push( + BatchDeletionSummaryDetailBuilder.build( + QueueDeletionRequestInputBuilder.build(targetRefDomain, targetRefIds[i], deleteInMinutes), + outputs[i] + ) + ); + } + + const expectedSummaryFields = { + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 2, + failureCount: 1, + details: expectedSummaryFieldsDetails, + }; + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields }; + }; + + it('should return proper summary with all the successes and failures', async () => { + const { refsFilePath, targetRefDomain, deleteInMinutes, expectedSummaryFields } = setup(); + + const summary = await uc.deleteRefsFromTxtFile(refsFilePath, targetRefDomain, deleteInMinutes); + + expect(summary.executionTimeMilliseconds).toBeGreaterThan(0); + expect(summary).toMatchObject(expectedSummaryFields); + }); + }); + }); + + describe('when batch deletion service returns an invalid amount of outputs', () => { + const setup = () => { + const targetRefsCount = 3; + + const targetRefIds: string[] = []; + + for (let i = 0; i < targetRefsCount; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + } + + ReferencesService.loadFromTxtFile = jest.fn().mockReturnValueOnce(targetRefIds); + + const outputs: QueueDeletionRequestOutput[] = []; + + for (let i = 0; i < targetRefsCount - 1; i += 1) { + targetRefIds.push(new ObjectId().toHexString()); + outputs.push(QueueDeletionRequestOutputBuilder.buildSuccess(new ObjectId().toHexString(), new Date())); + } + + batchDeletionService.queueDeletionRequests.mockResolvedValueOnce(outputs); + + const refsFilePath = '/tmp/ids.txt'; + + return { refsFilePath }; + }; + + it('should throw an error', async () => { + const { refsFilePath } = setup(); + + const func = () => uc.deleteRefsFromTxtFile(refsFilePath); + + await expect(func()).rejects.toThrow(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts new file mode 100644 index 00000000000..258b1b53f65 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/batch-deletion.uc.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { BatchDeletionSummaryBuilder, BatchDeletionSummaryDetailBuilder } from './builder'; +import { + ReferencesService, + BatchDeletionService, + QueueDeletionRequestInput, + QueueDeletionRequestInputBuilder, +} from '../services'; +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from './interface'; + +@Injectable() +export class BatchDeletionUc { + constructor(private readonly batchDeletionService: BatchDeletionService) {} + + async deleteRefsFromTxtFile( + refsFilePath: string, + targetRefDomain = 'user', + deleteInMinutes = 43200, // 43200 minutes = 720 hours = 30 days + callsDelayMilliseconds?: number + ): Promise { + // First, load all the references from the provided text file (with given path). + const refsFromTxtFile = ReferencesService.loadFromTxtFile(refsFilePath); + + const inputs: QueueDeletionRequestInput[] = []; + + // For each reference found in a given file, add it to the inputs + // array (with added targetRefDomain and deleteInMinutes fields). + refsFromTxtFile.forEach((ref) => + inputs.push(QueueDeletionRequestInputBuilder.build(targetRefDomain, ref, deleteInMinutes)) + ); + + // Measure the overall queueing execution time by setting the start... + const startTime = performance.now(); + + const outputs = await this.batchDeletionService.queueDeletionRequests(inputs, callsDelayMilliseconds); + + // ...and end timestamps before and after the batch deletion service method execution. + const endTime = performance.now(); + + // Throw an error if the returned outputs number doesn't match the returned inputs number. + if (outputs.length !== inputs.length) { + throw new Error( + 'invalid result from the batch deletion service - expected to ' + + 'receive the same amount of outputs as the provided inputs, ' + + `instead received ${outputs.length} outputs for ${inputs.length} inputs` + ); + } + + const summary: BatchDeletionSummary = BatchDeletionSummaryBuilder.build(endTime - startTime); + + // Go through every received output and, in case of an error presence increase + // a failure count or, in case of no error, increase a success count. + for (let i = 0; i < outputs.length; i += 1) { + if (outputs[i].error) { + summary.failureCount += 1; + } else { + summary.successCount += 1; + } + + // Also add all the processed inputs and outputs details to the overall summary. + summary.details.push(BatchDeletionSummaryDetailBuilder.build(inputs[i], outputs[i])); + } + + // If no failure has been spotted, assume an overall success. + if (summary.failureCount === 0) { + summary.overallStatus = BatchDeletionSummaryOverallStatus.SUCCESS; + } + + return summary; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts new file mode 100644 index 00000000000..43ea82e86d5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.spec.ts @@ -0,0 +1,69 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BatchDeletionSummaryDetail } from '..'; +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetailBuilder } from './batch-deletion-summary-detail.builder'; + +describe(BatchDeletionSummaryDetailBuilder.name, () => { + describe(BatchDeletionSummaryDetailBuilder.build.name, () => { + describe('when called with proper arguments for', () => { + describe('a successful output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + requestId: new ObjectId().toHexString(), + deletionPlannedAt: new Date(), + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + + describe('an error output case', () => { + const setup = () => { + const deletionRequestInput: QueueDeletionRequestInput = { + targetRefDomain: 'user', + targetRefId: new ObjectId().toHexString(), + deleteInMinutes: 1440, + }; + + const deletionRequestOutput: QueueDeletionRequestOutput = { + error: 'some error occurred...', + }; + + const expectedOutput: BatchDeletionSummaryDetail = { + input: deletionRequestInput, + output: deletionRequestOutput, + }; + + return { deletionRequestInput, deletionRequestOutput, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { deletionRequestInput, deletionRequestOutput, expectedOutput } = setup(); + + const output = BatchDeletionSummaryDetailBuilder.build(deletionRequestInput, deletionRequestOutput); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts new file mode 100644 index 00000000000..9ebbce66171 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary-detail.builder.ts @@ -0,0 +1,8 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; +import { BatchDeletionSummaryDetail } from '../interface'; + +export class BatchDeletionSummaryDetailBuilder { + static build(input: QueueDeletionRequestInput, output: QueueDeletionRequestOutput): BatchDeletionSummaryDetail { + return { input, output }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts new file mode 100644 index 00000000000..a2b534602d4 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.spec.ts @@ -0,0 +1,30 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; +import { BatchDeletionSummaryBuilder } from './batch-deletion-summary.builder'; + +describe(BatchDeletionSummaryBuilder.name, () => { + describe(BatchDeletionSummaryBuilder.build.name, () => { + describe('when called with proper arguments', () => { + const setup = () => { + const executionTimeMilliseconds = 142; + + const expectedOutput: BatchDeletionSummary = { + executionTimeMilliseconds: 142, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + + return { executionTimeMilliseconds, expectedOutput }; + }; + + it('should return valid object with expected values', () => { + const { executionTimeMilliseconds, expectedOutput } = setup(); + + const output = BatchDeletionSummaryBuilder.build(executionTimeMilliseconds); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts new file mode 100644 index 00000000000..57fa2bcccd9 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/batch-deletion-summary.builder.ts @@ -0,0 +1,13 @@ +import { BatchDeletionSummary, BatchDeletionSummaryOverallStatus } from '../interface'; + +export class BatchDeletionSummaryBuilder { + static build(executionTimeMilliseconds: number): BatchDeletionSummary { + return { + executionTimeMilliseconds, + overallStatus: BatchDeletionSummaryOverallStatus.FAILURE, + successCount: 0, + failureCount: 0, + details: [], + }; + } +} diff --git a/apps/server/src/modules/deletion/uc/builder/index.ts b/apps/server/src/modules/deletion/uc/builder/index.ts new file mode 100644 index 00000000000..46733980f94 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/builder/index.ts @@ -0,0 +1,2 @@ +export * from './batch-deletion-summary-detail.builder'; +export * from './batch-deletion-summary.builder'; diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/uc/index.ts new file mode 100644 index 00000000000..cf74de969e5 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/index.ts @@ -0,0 +1,2 @@ +export * from './interface'; +export * from './batch-deletion.uc'; diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts new file mode 100644 index 00000000000..4fe99c13fad --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-detail.interface.ts @@ -0,0 +1,6 @@ +import { QueueDeletionRequestInput, QueueDeletionRequestOutput } from '../../services'; + +export interface BatchDeletionSummaryDetail { + input: QueueDeletionRequestInput; + output: QueueDeletionRequestOutput; +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts new file mode 100644 index 00000000000..4ae91bdf70e --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary-overall-status.enum.ts @@ -0,0 +1,4 @@ +export const enum BatchDeletionSummaryOverallStatus { + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts new file mode 100644 index 00000000000..ce633e164f1 --- /dev/null +++ b/apps/server/src/modules/deletion/uc/interface/batch-deletion-summary.interface.ts @@ -0,0 +1,9 @@ +import { BatchDeletionSummaryDetail } from './batch-deletion-summary-detail.interface'; + +export interface BatchDeletionSummary { + executionTimeMilliseconds: number; + overallStatus: string; + successCount: number; + failureCount: number; + details: BatchDeletionSummaryDetail[]; +} diff --git a/apps/server/src/modules/deletion/uc/interface/index.ts b/apps/server/src/modules/deletion/uc/interface/index.ts index 95786098275..9dcf644f410 100644 --- a/apps/server/src/modules/deletion/uc/interface/index.ts +++ b/apps/server/src/modules/deletion/uc/interface/index.ts @@ -1 +1,4 @@ +export * from './batch-deletion-summary-overall-status.enum'; +export * from './batch-deletion-summary-detail.interface'; +export * from './batch-deletion-summary.interface'; export * from './interfaces'; diff --git a/config/default.schema.json b/config/default.schema.json index b971968a4ae..89d0a328a59 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1343,6 +1343,23 @@ "description": "Number of simultaneously synchronized students, teachers and classes" } } + }, + "ADMIN_API_CLIENT": { + "type": "object", + "description": "Configuration of the schulcloud-server's admin API client.", + "properties": { + "BASE_URL": { + "type": "string", + "description": "Base URL of the Admin API." + }, + "API_KEY": { + "type": "string", + "description": "API key for accessing the Admin API." + } + }, + "default": { + "BASE_URL": "http://localhost:4030" + } } }, "required": [], diff --git a/nest-cli.json b/nest-cli.json index 73dea03c093..8ce5461bb6f 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -45,6 +45,15 @@ "tsConfigPath": "apps/server/tsconfig.app.json" } }, + "deletion-console": { + "type": "application", + "root": "apps/server", + "entryFile": "apps/deletion-console.app", + "sourceRoot": "apps/server/src", + "compilerOptions": { + "tsConfigPath": "apps/server/tsconfig.app.json" + } + }, "files-storage": { "type": "application", "root": "apps/server", diff --git a/package.json b/package.json index 53a0752ee10..4153b7636e7 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "nest:start:console": "nest start console --", "nest:start:console:dev": "nest start console --watch --", "nest:start:console:debug": "nest start console --debug --watch --", + "nest:start:deletion-console": "nest start deletion-console --", + "nest:start:deletion-console:dev": "nest start deletion-console --watch --", + "nest:start:deletion-console:debug": "nest start deletion-console --debug --watch --", "nest:test": "npm run nest:test:cov && npm run nest:lint", "nest:test:all": "jest", "nest:test:unit": "jest \"^((?!\\.api\\.spec\\.ts).)*\\.spec\\.ts$\"",