From 17de936de29d811fd2fa0373181a7d147351f624 Mon Sep 17 00:00:00 2001 From: Haby-Phael Mouko <130637379+phaelcg@users.noreply.github.com> Date: Tue, 17 Dec 2024 23:54:31 +0100 Subject: [PATCH] Spsh-1553: Implemented the new async workflow for the import & refactoring (#834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * SPSH-1286: Implemented the repository, entity & aggregate for the history of imports and import transactions updates. * SPSH-1286: Refactored the import workflow to update the status of importvorgang. * SPSH-1286: Implemented unit tests and integration tests * SPSH-1286: Fixed test coverage * SPSH-1286: Fixed code coverage * SPSH-1286: Added a reference to the ImportDataItem table and refactored the integration tests. * First draft in progress * SPSH-1553: Workaround for import-workflow * SPSH-1553: Fixed issues with the import-event.handler and added migration file * SPSH-1553: Fixed issued after merge with main * SPSH-1553: More code refactoring and foxed tests for the new import workflow. * SPSH-1553: Fixed the config loader tests * SPSH-1553: Fixed integration tests * SPSH-1553: Fixed code coverage * SPSH-1553: Begrenzung der Anzahl der Nutzer beim Import über die Konfiguration. * SPSH-1553: Fixed issue with last empty line in the CSV file when the max number of users is checked * SPSH-1553: PR Review * SPSH-1553: Fixed test coverage * SPSH-1553: Merged two migration scripts * SPSH-1553: Reversed mistake in the values.yaml * SPSH-1553: Added chart configuration and renamed import config variables for the pipeline * SPSH-1553: Changed the response type for the import status --------- Co-authored-by: Caspar Neumann <146704428+casparneumann-cap@users.noreply.github.com> Co-authored-by: Youssef Bouchara <101522419+YoussefBouch@users.noreply.github.com> --- .../dbildungs-iam-server/config/config.json | 5 +- .../templates/configmap.yaml | 2 + .../templates/secret.yaml | 2 + charts/dbildungs-iam-server/values.yaml | 6 + config/config.json | 5 +- .../.snapshot-dbildungs-iam-server.json | 747 ++++++++++-------- migrations/Migration20241212134515-S.ts | 25 + src/modules/import/api/dbiam-import.error.ts | 2 + .../import/api/import-exception-filter.ts | 16 + .../api/import.controller.integration-spec.ts | 131 ++- .../import/api/import.controller.spec.ts | 8 +- src/modules/import/api/import.controller.ts | 121 ++- .../api/importvorgang-by-id.body.params.ts | 15 +- .../api/importvorgang-status.response.ts | 12 + ...import-csv-file-contains-no-users.error.ts | 7 + .../domain/import-csv-file-max-users.error.ts | 11 + src/modules/import/domain/import-data-item.ts | 6 + .../domain/import-event-handler.spec.ts | 265 +++++++ .../import/domain/import-event-handler.ts | 147 ++++ .../domain/import-password-encryptor.spec.ts | 85 ++ .../domain/import-password-encryptor.ts | 57 ++ src/modules/import/domain/import-vorgang.ts | 4 + .../import/domain/import-workflow.factory.ts | 19 +- .../import/domain/import-workflow.spec.ts | 408 ++++++---- src/modules/import/domain/import-workflow.ts | 231 +++--- src/modules/import/domain/import.enums.ts | 9 +- src/modules/import/import-api.module.ts | 3 +- src/modules/import/import.module.ts | 19 +- .../persistence/import-data-item.entity.ts | 6 + ...import-data.repository.integration-spec.ts | 25 +- .../persistence/import-data.repository.ts | 36 +- ...ort-vorgang.repository.integration-spec.ts | 2 +- src/shared/config/config.env.spec.ts | 32 + src/shared/config/config.env.ts | 12 + src/shared/config/config.loader.spec.ts | 12 + src/shared/config/import.config.ts | 16 +- src/shared/events/import-executed.event.ts | 13 + src/shared/http/http.headers.ts | 2 + test/config.test.json | 5 +- 39 files changed, 1836 insertions(+), 693 deletions(-) create mode 100644 migrations/Migration20241212134515-S.ts create mode 100644 src/modules/import/api/importvorgang-status.response.ts create mode 100644 src/modules/import/domain/import-csv-file-contains-no-users.error.ts create mode 100644 src/modules/import/domain/import-csv-file-max-users.error.ts create mode 100644 src/modules/import/domain/import-event-handler.spec.ts create mode 100644 src/modules/import/domain/import-event-handler.ts create mode 100644 src/modules/import/domain/import-password-encryptor.spec.ts create mode 100644 src/modules/import/domain/import-password-encryptor.ts create mode 100644 src/shared/events/import-executed.event.ts create mode 100644 src/shared/http/http.headers.ts diff --git a/charts/dbildungs-iam-server/config/config.json b/charts/dbildungs-iam-server/config/config.json index c2fac1396..fe0c083bf 100644 --- a/charts/dbildungs-iam-server/config/config.json +++ b/charts/dbildungs-iam-server/config/config.json @@ -75,7 +75,10 @@ "REALM": "defrealm" }, "IMPORT": { - "IMPORT_FILE_MAXGROESSE_IN_MB": 10 + "CSV_FILE_MAX_SIZE_IN_MB": 10, + "CSV_MAX_NUMBER_OF_USERS": 2000, + "PASSPHRASE_SECRET": "44abDqJk2qgwRbpGfO0VZx7DpXeFsm7R", + "PASSPHRASE_SALT": "YDp6fYkbUcj4ZkyAOnbAHGQ9O72htc5M" }, "SYSTEM": { "RENAME_WAITING_TIME_IN_SECONDS": 3, diff --git a/charts/dbildungs-iam-server/templates/configmap.yaml b/charts/dbildungs-iam-server/templates/configmap.yaml index 67068141c..efd132aeb 100644 --- a/charts/dbildungs-iam-server/templates/configmap.yaml +++ b/charts/dbildungs-iam-server/templates/configmap.yaml @@ -29,3 +29,5 @@ data: ITSLEARNING_ROOT_OEFFENTLICH: '{{ .Values.itslearning.rootOeffentlich }}' ITSLEARNING_ROOT_ERSATZ: '{{ .Values.itslearning.rootErsatz }}' NODE_OPTIONS: "--max-old-space-size={{ .Values.backend.env.maxOldSpaceSize }}" + IMPORT_CSV_FILE_MAX_SIZE_IN_MB: '{{ .Values.import.csvFileMaxSizeInMB }}' + IMPORT_CSV_MAX_NUMBER_OF_USERS: '{{ .Values.import.csvMaxNumberOfUsers }}' diff --git a/charts/dbildungs-iam-server/templates/secret.yaml b/charts/dbildungs-iam-server/templates/secret.yaml index 4b0c37d22..1083931d2 100644 --- a/charts/dbildungs-iam-server/templates/secret.yaml +++ b/charts/dbildungs-iam-server/templates/secret.yaml @@ -30,4 +30,6 @@ data: vidis-region-name: {{ .Values.auth.vidis_region_name }} vidis-keycloak-group: {{ .Values.auth.vidis_keycloak_group }} vidis-keycloak-role: {{ .Values.auth.vidis_keycloak_role }} + import-passphrase-secret: {{ .Values.auth.import_passphrase_secret }} + import-passphrase-salt: {{ .Values.auth.import_passphrase_salt }} {{- end }} diff --git a/charts/dbildungs-iam-server/values.yaml b/charts/dbildungs-iam-server/values.yaml index 889ee89ba..064890176 100644 --- a/charts/dbildungs-iam-server/values.yaml +++ b/charts/dbildungs-iam-server/values.yaml @@ -64,6 +64,8 @@ auth: vidis_region_name: '' vidis_keycloak_group: '' vidis_keycloak_role: '' + import_passphrase_secret: '' + import_passphrase_salt: '' backend: replicaCount: 1 @@ -193,3 +195,7 @@ cronjobs: endpoint: '/api/cron/unlock' httpMethod: 'PUT' script: 'cron_trigger.sh' + +import: + csvFileMaxSizeInMB: 10 + csvMaxNumberOfUsers: 2000 diff --git a/config/config.json b/config/config.json index eb5e2d854..db96232ea 100644 --- a/config/config.json +++ b/config/config.json @@ -95,7 +95,10 @@ "KEYCLOAK_ROLE": "VIDIS-user" }, "IMPORT": { - "IMPORT_FILE_MAXGROESSE_IN_MB": 10 + "CSV_FILE_MAX_SIZE_IN_MB": 10, + "CSV_MAX_NUMBER_OF_USERS": 200, + "PASSPHRASE_SECRET": "44abDqJk2qgwRbpGfO0VZx7DpXeFsm7R", + "PASSPHRASE_SALT": "YDp6fYkbUcj4ZkyAOnbAHGQ9O72htc5M" }, "SYSTEM": { "RENAME_WAITING_TIME_IN_SECONDS": 3, diff --git a/migrations/.snapshot-dbildungs-iam-server.json b/migrations/.snapshot-dbildungs-iam-server.json index fe20f80a4..deb59c550 100644 --- a/migrations/.snapshot-dbildungs-iam-server.json +++ b/migrations/.snapshot-dbildungs-iam-server.json @@ -94,13 +94,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -322,13 +323,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -572,13 +574,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -814,13 +817,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -1048,13 +1052,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -1296,7 +1301,6 @@ "autoincrement": false, "primary": false, "nullable": false, - "default": "0", "mappedType": "integer" }, "status": { @@ -1308,13 +1312,14 @@ "nullable": false, "nativeEnumName": "import_status_enum", "enumItems": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ], "mappedType": "enum" } @@ -1359,13 +1364,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -1599,6 +1605,25 @@ "primary": false, "nullable": true, "mappedType": "array" + }, + "username": { + "name": "username", + "type": "varchar(50)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 50, + "mappedType": "string" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "string" } }, "name": "importdataitem", @@ -1654,13 +1679,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -1810,248 +1836,6 @@ } } }, - { - "columns": { - "id": { - "name": "id", - "type": "uuid", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "uuid" - }, - "created_at": { - "name": "created_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "mappedType": "datetime" - }, - "email": { - "name": "email", - "type": "varchar(255)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "string" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "string" - }, - "username": { - "name": "username", - "type": "varchar(255)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "string" - } - }, - "name": "ox_user_blacklist", - "schema": "public", - "indexes": [ - { - "keyName": "ox_user_blacklist_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": {}, - "nativeEnums": { - "db_seed_status_enum": { - "name": "db_seed_status_enum", - "schema": "public", - "items": [ - "STARTED", - "DONE", - "FAILED" - ] - }, - "referenced_entity_type_enum": { - "name": "referenced_entity_type_enum", - "schema": "public", - "items": [ - "PERSON", - "ORGANISATION", - "ROLLE", - "SERVICE_PROVIDER" - ] - }, - "organisations_typ_enum": { - "name": "organisations_typ_enum", - "schema": "public", - "items": [ - "ROOT", - "LAND", - "TRAEGER", - "SCHULE", - "KLASSE", - "ANBIETER", - "SONSTIGE ORGANISATION / EINRICHTUNG", - "UNBESTAETIGT" - ] - }, - "traegerschaft_enum": { - "name": "traegerschaft_enum", - "schema": "public", - "items": [ - "01", - "02", - "03", - "04", - "05", - "06" - ] - }, - "geschlecht_enum": { - "name": "geschlecht_enum", - "schema": "public", - "items": [ - "m", - "w", - "d", - "x" - ] - }, - "vertrauensstufe_enum": { - "name": "vertrauensstufe_enum", - "schema": "public", - "items": [ - "KEIN", - "UNBE", - "TEIL", - "VOLL" - ] - }, - "email_address_status_enum": { - "name": "email_address_status_enum", - "schema": "public", - "items": [ - "ENABLED", - "DISABLED", - "REQUESTED", - "FAILED" - ] - }, - "rollen_art_enum": { - "name": "rollen_art_enum", - "schema": "public", - "items": [ - "LERN", - "LEHR", - "EXTERN", - "ORGADMIN", - "LEIT", - "SYSADMIN" - ] - }, - "personenstatus_enum": { - "name": "personenstatus_enum", - "schema": "public", - "items": [ - "AKTIV" - ] - }, - "jahrgangsstufe_enum": { - "name": "jahrgangsstufe_enum", - "schema": "public", - "items": [ - "01", - "02", - "03", - "04", - "05", - "06", - "07", - "08", - "09", - "10" - ] - }, - "rollen_merkmal_enum": { - "name": "rollen_merkmal_enum", - "schema": "public", - "items": [ - "BEFRISTUNG_PFLICHT", - "KOPERS_PFLICHT" - ] - }, - "rollen_system_recht_enum": { - "name": "rollen_system_recht_enum", - "schema": "public", - "items": [ - "ROLLEN_VERWALTEN", - "PERSONEN_SOFORT_LOESCHEN", - "PERSONEN_VERWALTEN", - "SCHULEN_VERWALTEN", - "KLASSEN_VERWALTEN", - "SCHULTRAEGER_VERWALTEN", - "MIGRATION_DURCHFUEHREN", - "PERSON_SYNCHRONISIEREN", - "CRON_DURCHFUEHREN", - "PERSONEN_ANLEGEN", - "IMPORT_DURCHFUEHREN" - ] - }, - "service_provider_target_enum": { - "name": "service_provider_target_enum", - "schema": "public", - "items": [ - "URL", - "EMAIL", - "SCHULPORTAL_ADMINISTRATION" - ] - }, - "service_provider_kategorie_enum": { - "name": "service_provider_kategorie_enum", - "schema": "public", - "items": [ - "EMAIL", - "UNTERRICHT", - "VERWALTUNG", - "HINWEISE", - "ANGEBOTE" - ] - }, - "service_provider_system_enum": { - "name": "service_provider_system_enum", - "schema": "public", - "items": [ - "NONE", - "EMAIL", - "ITSLEARNING" - ] - } - } - }, { "columns": { "id": { @@ -2274,15 +2058,272 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" + ] + }, + "organisations_typ_enum": { + "name": "organisations_typ_enum", + "schema": "public", + "items": [ + "ROOT", + "LAND", + "TRAEGER", + "SCHULE", + "KLASSE", + "ANBIETER", + "SONSTIGE ORGANISATION / EINRICHTUNG", + "UNBESTAETIGT" + ] + }, + "traegerschaft_enum": { + "name": "traegerschaft_enum", + "schema": "public", + "items": [ + "01", + "02", + "03", + "04", + "05", + "06" + ] + }, + "geschlecht_enum": { + "name": "geschlecht_enum", + "schema": "public", + "items": [ + "m", + "w", + "d", + "x" + ] + }, + "vertrauensstufe_enum": { + "name": "vertrauensstufe_enum", + "schema": "public", + "items": [ + "KEIN", + "UNBE", + "TEIL", + "VOLL" + ] + }, + "email_address_status_enum": { + "name": "email_address_status_enum", + "schema": "public", + "items": [ + "ENABLED", + "DISABLED", + "REQUESTED", + "FAILED" + ] + }, + "rollen_art_enum": { + "name": "rollen_art_enum", + "schema": "public", + "items": [ + "LERN", + "LEHR", + "EXTERN", + "ORGADMIN", + "LEIT", + "SYSADMIN" + ] + }, + "personenstatus_enum": { + "name": "personenstatus_enum", + "schema": "public", + "items": [ + "AKTIV" + ] + }, + "jahrgangsstufe_enum": { + "name": "jahrgangsstufe_enum", + "schema": "public", + "items": [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "10" + ] + }, + "rollen_merkmal_enum": { + "name": "rollen_merkmal_enum", + "schema": "public", + "items": [ + "BEFRISTUNG_PFLICHT", + "KOPERS_PFLICHT" + ] + }, + "rollen_system_recht_enum": { + "name": "rollen_system_recht_enum", + "schema": "public", + "items": [ + "ROLLEN_VERWALTEN", + "PERSONEN_SOFORT_LOESCHEN", + "PERSONEN_VERWALTEN", + "SCHULEN_VERWALTEN", + "KLASSEN_VERWALTEN", + "SCHULTRAEGER_VERWALTEN", + "MIGRATION_DURCHFUEHREN", + "PERSON_SYNCHRONISIEREN", + "CRON_DURCHFUEHREN", + "PERSONEN_ANLEGEN", + "IMPORT_DURCHFUEHREN" + ] + }, + "service_provider_target_enum": { + "name": "service_provider_target_enum", + "schema": "public", + "items": [ + "URL", + "EMAIL", + "SCHULPORTAL_ADMINISTRATION" + ] + }, + "service_provider_kategorie_enum": { + "name": "service_provider_kategorie_enum", + "schema": "public", + "items": [ + "EMAIL", + "UNTERRICHT", + "VERWALTUNG", + "HINWEISE", + "ANGEBOTE" + ] + }, + "service_provider_system_enum": { + "name": "service_provider_system_enum", + "schema": "public", + "items": [ + "NONE", + "EMAIL", + "ITSLEARNING" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + } + }, + "name": "ox_user_blacklist", + "schema": "public", + "indexes": [ + { + "keyName": "ox_user_blacklist_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": { + "db_seed_status_enum": { + "name": "db_seed_status_enum", + "schema": "public", + "items": [ + "STARTED", + "DONE", "FAILED" ] }, + "referenced_entity_type_enum": { + "name": "referenced_entity_type_enum", + "schema": "public", + "items": [ + "PERSON", + "ORGANISATION", + "ROLLE", + "SERVICE_PROVIDER" + ] + }, + "import_status_enum": { + "name": "import_status_enum", + "schema": "public", + "items": [ + "CANCELLED", + "COMPLETED", + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" + ] + }, "organisations_typ_enum": { "name": "organisations_typ_enum", "schema": "public", @@ -2815,13 +2856,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -3121,13 +3163,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -3405,13 +3448,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -3810,13 +3854,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -4056,13 +4101,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -4311,13 +4357,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -4635,13 +4682,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -4982,13 +5030,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -5225,13 +5274,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -5468,13 +5518,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -5757,13 +5808,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { @@ -5938,13 +5990,14 @@ "name": "import_status_enum", "schema": "public", "items": [ - "STARTED", - "VALID", - "INVALID", - "INPROGRESS", "CANCELLED", "COMPLETED", - "FAILED" + "FAILED", + "FINISHED", + "INPROGRESS", + "INVALID", + "STARTED", + "VALID" ] }, "organisations_typ_enum": { diff --git a/migrations/Migration20241212134515-S.ts b/migrations/Migration20241212134515-S.ts new file mode 100644 index 000000000..aaaa4cd23 --- /dev/null +++ b/migrations/Migration20241212134515-S.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20241212134515 extends Migration { + public async up(): Promise { + this.addSql('alter type "import_status_enum" add value if not exists \'FINISHED\';'); + + this.addSql('alter table "importvorgang" alter column "data_item_count" drop default;'); + this.addSql( + 'alter table "importvorgang" alter column "data_item_count" type int using ("data_item_count"::int);', + ); + + this.addSql( + 'alter table "importdataitem" add column "username" varchar(50) null, add column "password" varchar(255) null;', + ); + } + + public override async down(): Promise { + this.addSql( + 'alter table "importvorgang" alter column "data_item_count" type int using ("data_item_count"::int);', + ); + this.addSql('alter table "importvorgang" alter column "data_item_count" set default 0;'); + + this.addSql('alter table "importdataitem" drop column "username", drop column "password";'); + } +} diff --git a/src/modules/import/api/dbiam-import.error.ts b/src/modules/import/api/dbiam-import.error.ts index c0a70cad8..c0a7a214a 100644 --- a/src/modules/import/api/dbiam-import.error.ts +++ b/src/modules/import/api/dbiam-import.error.ts @@ -8,6 +8,8 @@ export enum ImportErrorI18nTypes { IMPORT_TEXT_FILE_CREATION_ERROR = 'IMPORT_TEXT_FILE_CREATION_ERROR', IMPORT_NUR_LERN_AN_SCHULE_ERROR = 'IMPORT_NUR_LERN_AN_SCHULE_ERROR', CSV_FILE_INVALID_HEADER_ERROR = 'CSV_FILE_INVALID_HEADER_ERROR', + IMPORT_MAX_USERS_LIMIT_ERROR = 'IMPORT_MAX_USERS_LIMIT_ERROR', + CSV_FILE_NO_USERS_ERROR = 'CSV_FILE_NO_USERS_ERROR', } export type DbiamImportErrorProps = DbiamErrorProps & { diff --git a/src/modules/import/api/import-exception-filter.ts b/src/modules/import/api/import-exception-filter.ts index 76eb39252..cf268a491 100644 --- a/src/modules/import/api/import-exception-filter.ts +++ b/src/modules/import/api/import-exception-filter.ts @@ -8,6 +8,8 @@ import { ImportTextFileCreationError } from '../domain/import-text-file-creation import { ImportCSVFileEmptyError } from '../domain/import-csv-file-empty.error.js'; import { ImportNurLernAnSchuleUndKlasseError } from '../domain/import-nur-lern-an-schule-und-klasse.error.js'; import { ImportCSVFileInvalidHeaderError } from '../domain/import-csv-file-invalid-header.error.js'; +import { ImportCSVFileMaxUsersError } from '../domain/import-csv-file-max-users.error.js'; +import { ImportCSVFileContainsNoUsersError } from '../domain/import-csv-file-contains-no-users.error.js'; @Catch(ImportDomainError) export class ImportExceptionFilter implements ExceptionFilter { @@ -47,6 +49,20 @@ export class ImportExceptionFilter implements ExceptionFilter i18nKey: ImportErrorI18nTypes.CSV_FILE_INVALID_HEADER_ERROR, }), ], + [ + ImportCSVFileMaxUsersError.name, + new DbiamImportError({ + code: 400, + i18nKey: ImportErrorI18nTypes.IMPORT_MAX_USERS_LIMIT_ERROR, + }), + ], + [ + ImportCSVFileContainsNoUsersError.name, + new DbiamImportError({ + code: 400, + i18nKey: ImportErrorI18nTypes.CSV_FILE_NO_USERS_ERROR, + }), + ], ]); public catch(exception: ImportDomainError, host: ArgumentsHost): void { diff --git a/src/modules/import/api/import.controller.integration-spec.ts b/src/modules/import/api/import.controller.integration-spec.ts index 3c4dc0daf..50daaed0b 100644 --- a/src/modules/import/api/import.controller.integration-spec.ts +++ b/src/modules/import/api/import.controller.integration-spec.ts @@ -41,6 +41,8 @@ import { PagedResponse } from '../../../shared/paging/paged.response.js'; import { ImportVorgangResponse } from './importvorgang.response.js'; import { ImportStatus } from '../domain/import.enums.js'; import { StepUpGuard } from '../../authentication/api/steup-up.guard.js'; +import { KeycloakAdministrationService } from '../../keycloak-administration/domain/keycloak-admin-client.service.js'; +import { ImportVorgangStatusResponse } from './importvorgang-status.response.js'; describe('Import API', () => { let app: INestApplication; @@ -98,6 +100,16 @@ describe('Import API', () => { }), }), }, + { + provide: KeycloakAdministrationService, + useValue: createMock({ + getAuthedKcAdminClient: () => + Promise.resolve({ + ok: true, + value: createMock(), + }), + }), + }, ], }) .overrideModule(KeycloakConfigModule) @@ -118,6 +130,7 @@ describe('Import API', () => { personpermissionsRepoMock.loadPersonPermissions.mockResolvedValue(personPermissionsMock); personPermissionsMock.getOrgIdsWithSystemrecht.mockResolvedValue({ all: false, orgaIds: [] }); personPermissionsMock.personFields.username = faker.internet.userName(); + await DatabaseTestModule.setupDatabase(module.get(MikroORM)); app = module.createNestApplication(); await app.init(); @@ -501,7 +514,7 @@ describe('Import API', () => { }); describe('/POST execute', () => { - it('should return 200 OK with the file', async () => { + it('should return 204 No Content', async () => { const schule: OrganisationEntity = new OrganisationEntity(); schule.typ = OrganisationsTyp.SCHULE; schule.name = 'Import Schule'; @@ -534,9 +547,9 @@ describe('Import API', () => { if (sus instanceof DomainError) throw sus; const importVorgang: ImportVorgang = await importVorgangRepository.save( - DoFactory.createImportVorgang(false), + DoFactory.createImportVorgang(false, { organisationId: schule.id, rolleId: sus.id }), ); - const importDataItem: ImportDataItem = await importDataRepository.save( + await importDataRepository.save( DoFactory.createImportDataItem(false, { importvorgangId: importVorgang.id, klasse: klasse.name, @@ -546,14 +559,89 @@ describe('Import API', () => { const params: ImportvorgangByIdBodyParams = { importvorgangId: importVorgang.id, - organisationId: schule.id, - rolleId: sus.id, }; const response: Response = await request(app.getHttpServer() as App) .post('/import/execute') .send(params); + expect(response.status).toBe(204); + }); + + it('should return 404 if the import transaction is not found', async () => { + const params: ImportvorgangByIdBodyParams = { + importvorgangId: faker.string.uuid(), + }; + + const executeResponse: Response = await request(app.getHttpServer() as App) + .post('/import/execute') + .send(params); + + expect(executeResponse.status).toBe(404); + }); + + it('should return 500 if the import vorgang has no organisation ID', async () => { + const importVorgang: ImportVorgang = await importVorgangRepository.save( + DoFactory.createImportVorgang(false, { organisationId: undefined, rolleId: faker.string.uuid() }), + ); + const params: ImportvorgangByIdBodyParams = { + importvorgangId: importVorgang.id, + }; + + const executeResponse: Response = await request(app.getHttpServer() as App) + .post('/import/execute') + .send(params); + + expect(executeResponse.status).toBe(500); + }); + }); + + describe('/GET doownload', () => { + it('should return 200 OK with the file', async () => { + const schule: OrganisationEntity = new OrganisationEntity(); + schule.typ = OrganisationsTyp.SCHULE; + schule.name = 'Import Schule'; + await em.persistAndFlush(schule); + await em.findOneOrFail(OrganisationEntity, { id: schule.id }); + + const klasse: OrganisationEntity = new OrganisationEntity(); + klasse.typ = OrganisationsTyp.KLASSE; + klasse.name = '1a'; + klasse.administriertVon = schule.id; + klasse.zugehoerigZu = schule.id; + await em.persistAndFlush(klasse); + await em.findOneOrFail(OrganisationEntity, { id: klasse.id }); + + const sus: Rolle | DomainError = await rolleRepo.save( + DoFactory.createRolle(false, { + rollenart: RollenArt.LERN, + administeredBySchulstrukturknoten: schule.id, + merkmale: [], + }), + ); + if (sus instanceof DomainError) throw sus; + + const importVorgang: ImportVorgang = await importVorgangRepository.save( + DoFactory.createImportVorgang(false, { + organisationId: schule.id, + rolleId: sus.id, + status: ImportStatus.FINISHED, + }), + ); + const importDataItem: ImportDataItem = await importDataRepository.save( + DoFactory.createImportDataItem(false, { + importvorgangId: importVorgang.id, + klasse: klasse.name, + personalnummer: undefined, + username: faker.internet.userName(), + password: '5ba56bceb34c5b84|6ad72f7a8fa8d98daa7e3f0dc6aa2a82', + }), + ); + + const response: Response = await request(app.getHttpServer() as App) + .get(`/import/${importVorgang.id}/download`) + .send(); + expect(response.status).toBe(200); expect(response.type).toBe('text/plain'); @@ -566,15 +654,9 @@ describe('Import API', () => { }); it('should return 404 if the import transaction is not found', async () => { - const params: ImportvorgangByIdBodyParams = { - importvorgangId: faker.string.uuid(), - organisationId: faker.string.uuid(), - rolleId: faker.string.uuid(), - }; - const executeResponse: Response = await request(app.getHttpServer() as App) - .post('/import/execute') - .send(params); + .get(`/import/${faker.string.uuid()}/download`) + .send(); expect(executeResponse.status).toBe(404); }); @@ -700,4 +782,27 @@ describe('Import API', () => { expect(pagedResponse.items).toHaveLength(1); }); }); + + describe('/GET importstatus by id', () => { + it('should return 200 OK with import ststus', async () => { + const importVorgang: ImportVorgang = await importVorgangRepository.save( + DoFactory.createImportVorgang(false, { status: ImportStatus.COMPLETED }), + ); + + const response: Response = await request(app.getHttpServer() as App) + .get(`/import/${importVorgang.id}/status`) + .send(); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: ImportStatus.COMPLETED } as ImportVorgangStatusResponse); + }); + + it('should return 404 if importvorgang does not exist', async () => { + const response: Response = await request(app.getHttpServer() as App) + .get(`/import/${faker.string.uuid()}/status`) + .send(); + + expect(response.status).toBe(404); + }); + }); }); diff --git a/src/modules/import/api/import.controller.spec.ts b/src/modules/import/api/import.controller.spec.ts index 6eed638ee..d104ece42 100644 --- a/src/modules/import/api/import.controller.spec.ts +++ b/src/modules/import/api/import.controller.spec.ts @@ -59,26 +59,24 @@ describe('Import API with mocked ImportWorkflow', () => { jest.resetAllMocks(); }); - describe('/POST execute the import transaction', () => { + describe('/POST download the import result', () => { describe('if the import result file cannot be created', () => { it('should throw an ImportTextFileCreationError', async () => { const params: ImportvorgangByIdBodyParams = { importvorgangId: faker.string.uuid(), - organisationId: faker.string.uuid(), - rolleId: faker.string.uuid(), }; const responseMock: DeepMocked = createMock(); const personpermissionsMock: DeepMocked = createMock(); personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValueOnce(true); - ImportWorkflowMock.executeImport.mockResolvedValueOnce({ + ImportWorkflowMock.downloadFile.mockResolvedValueOnce({ ok: false, error: new ImportTextFileCreationError(['Reason']), }); importWorkflowFactoryMock.createNew.mockReturnValueOnce(ImportWorkflowMock); - await expect(sut.executeImport(params, responseMock, personpermissionsMock)).rejects.toThrow( + await expect(sut.downloadFile(params, responseMock, personpermissionsMock)).rejects.toThrow( new ImportTextFileCreationError(['Reason']), ); }); diff --git a/src/modules/import/api/import.controller.ts b/src/modules/import/api/import.controller.ts index 246e548b2..1c1a8063f 100644 --- a/src/modules/import/api/import.controller.ts +++ b/src/modules/import/api/import.controller.ts @@ -58,6 +58,9 @@ import { ImportVorgangRepository } from '../persistence/import-vorgang.repositor import { ImportVorgang } from '../domain/import-vorgang.js'; import { Paged } from '../../../shared/paging/paged.js'; import { StepUpGuard } from '../../authentication/api/steup-up.guard.js'; +import { EntityNotFoundError } from '../../../shared/error/entity-not-found.error.js'; +import { ContentDisposition, ContentType } from '../../../shared/http/http.headers.js'; +import { ImportVorgangStatusResponse } from './importvorgang-status.response.js'; @UseFilters(SchulConnexValidationErrorFilter, new AuthenticationExceptionFilter(), new ImportExceptionFilter()) @ApiTags('import') @@ -96,8 +99,12 @@ export class ImportController { @Permissions() permissions: PersonPermissions, ): Promise { const importWorkflow: ImportWorkflow = this.importWorkflowFactory.createNew(); - importWorkflow.initialize(body.organisationId, body.rolleId); - const result: DomainError | ImportUploadResultFields = await importWorkflow.validateImport(file, permissions); + const result: DomainError | ImportUploadResultFields = await importWorkflow.validateImport( + file, + body.organisationId, + body.rolleId, + permissions, + ); if (result instanceof DomainError) { if (result instanceof ImportDomainError) { throw result; @@ -119,13 +126,10 @@ export class ImportController { @UseGuards(StepUpGuard) @ApiProduces('text/plain') @Post('execute') - @HttpCode(HttpStatus.OK) - @ApiOkResponse({ - description: 'Import transaction was executed successfully. The text file can be downloaded', - schema: { - type: 'string', - format: 'binary', - }, + @HttpCode(HttpStatus.NO_CONTENT) + @ApiNoContentResponse({ + description: 'The execution of the import transaction was initiated successfully.', + type: undefined, }) @ApiNotFoundResponse({ description: 'The import transaction does not exist.' }) @ApiBadRequestResponse({ @@ -139,17 +143,15 @@ export class ImportController { }) public async executeImport( @Body() body: ImportvorgangByIdBodyParams, - @Res({ passthrough: true }) res: Response, @Permissions() permissions: PersonPermissions, - ): Promise { + ): Promise { const importWorkflow: ImportWorkflow = this.importWorkflowFactory.createNew(); - importWorkflow.initialize(body.organisationId, body.rolleId); - const result: Result = await importWorkflow.executeImport(body.importvorgangId, permissions); + const result: Result = await importWorkflow.executeImport(body.importvorgangId, permissions); if (!result.ok) { if (result.error instanceof ImportDomainError) { this.logger.error( - `Admin ${permissions.personFields.username} (AdminId: ${permissions.personFields.id}) hat versucht für Schule: ${body.organisationId} einen CSV Import durchzuführen. Fehler: ${result.error.message}`, + `Admin ${permissions.personFields.username} (AdminId: ${permissions.personFields.id}) hat versucht mit dem Importvorgang: ${body.importvorgangId} einen CSV Import durchzuführen. Fehler: ${result.error.message}`, ); throw result.error; } @@ -158,21 +160,14 @@ export class ImportController { SchulConnexErrorMapper.mapDomainErrorToSchulConnexError(result.error as DomainError), ); this.logger.error( - `Admin: ${permissions.personFields.id}) hat versucht für Schule: ${body.organisationId} einen CSV Import durchzuführen. Fehler: ${schulConnexError.message}`, + `Admin: ${permissions.personFields.id}) hat versucht mit dem Importvorgang: ${body.importvorgangId} einen CSV Import durchzuführen. Fehler: ${schulConnexError.message}`, ); throw schulConnexError; - } else { - this.logger.info( - `Admin: ${permissions.personFields.id}) hat für Schule: ${body.organisationId} einen CSV Import durchgeführt.`, - ); - const fileName: string = importWorkflow.getFileName(body.importvorgangId); - const contentDisposition: string = `attachment; filename="${fileName}"`; - res.set({ - 'Content-Type': 'text/plain', - 'Content-Disposition': contentDisposition, - }); - return new StreamableFile(result.value); } + + this.logger.info( + `Admin: ${permissions.personFields.id}) hat mit dem Importvorgang: ${body.importvorgangId} einen CSV Import durchgeführt.`, + ); } @UseGuards(StepUpGuard) @@ -201,7 +196,6 @@ export class ImportController { } } - @UseGuards(StepUpGuard) @Get('history') @ApiOperation({ description: 'Get the history of import.' }) @ApiOkResponse({ @@ -237,4 +231,77 @@ export class ImportController { return new PagedResponse(pagedImportVorgangResponse); } + + @ApiProduces('text/plain') + @Get(':importvorgangId/download') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + description: 'The import result file was generated and downloaded successfully.', + schema: { + type: 'string', + format: 'binary', + }, + }) + @ApiNotFoundResponse({ description: 'The import transaction does not exist.' }) + @ApiBadRequestResponse({ + description: 'Something went wrong with the found import transaction.', + type: DbiamImportError, + }) + @ApiUnauthorizedResponse({ description: 'Not authorized to download the import result.' }) + @ApiForbiddenResponse({ description: 'Insufficient permissions to download the import result.' }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error while generating the import result file.', + }) + public async downloadFile( + @Param() params: ImportvorgangByIdBodyParams, + @Res({ passthrough: true }) res: Response, + @Permissions() permissions: PersonPermissions, + ): Promise { + const importWorkflow: ImportWorkflow = this.importWorkflowFactory.createNew(); + const result: Result = await importWorkflow.downloadFile(params.importvorgangId, permissions); + + if (!result.ok) { + if (result.error instanceof ImportDomainError) { + throw result.error; + } + + throw SchulConnexErrorMapper.mapSchulConnexErrorToHttpException( + SchulConnexErrorMapper.mapDomainErrorToSchulConnexError(result.error as DomainError), + ); + } else { + const fileName: string = importWorkflow.getFileName(params.importvorgangId); + const contentDisposition: string = `attachment; filename="${fileName}"`; + + const headers: Record = {} as Record; + headers[ContentType] = 'text/plain'; + headers[ContentDisposition] = contentDisposition; + res.set(headers); + + return new StreamableFile(result.value); + } + } + + @Get(':importvorgangId/status') + @ApiOperation({ description: 'Get status for the import transaction by id.' }) + @ApiOkResponse({ + description: 'The status for the import transaction was successfully returned.', + type: ImportVorgangStatusResponse, + }) + @ApiUnauthorizedResponse({ description: 'Not authorized to get the status for the import transaction by id.' }) + @ApiForbiddenResponse({ description: 'Insufficient permission to get status for the import transaction by id.' }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error while getting status for the import transaction by id.', + }) + public async getImportStatus(@Param() params: ImportvorgangByIdParams): Promise { + const result: Option> = await this.importVorgangRepository.findById(params.importvorgangId); + if (!result) { + throw SchulConnexErrorMapper.mapSchulConnexErrorToHttpException( + SchulConnexErrorMapper.mapDomainErrorToSchulConnexError( + new EntityNotFoundError('ImportVorgang', params.importvorgangId), + ), + ); + } + + return new ImportVorgangStatusResponse(result); + } } diff --git a/src/modules/import/api/importvorgang-by-id.body.params.ts b/src/modules/import/api/importvorgang-by-id.body.params.ts index e89b92ad5..88131db52 100644 --- a/src/modules/import/api/importvorgang-by-id.body.params.ts +++ b/src/modules/import/api/importvorgang-by-id.body.params.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; -import { OrganisationID, RolleID } from '../../../shared/types/aggregate-ids.types.js'; +import { IsUUID } from 'class-validator'; export class ImportvorgangByIdBodyParams { @IsUUID() @@ -10,16 +9,4 @@ export class ImportvorgangByIdBodyParams { nullable: false, }) public importvorgangId!: string; - - @IsString() - @IsNotEmpty() - @IsUUID() - @ApiProperty({ type: String }) - public readonly organisationId!: OrganisationID; - - @IsString() - @IsNotEmpty() - @IsUUID() - @ApiProperty({ type: String }) - public readonly rolleId!: RolleID; } diff --git a/src/modules/import/api/importvorgang-status.response.ts b/src/modules/import/api/importvorgang-status.response.ts new file mode 100644 index 000000000..1d1023687 --- /dev/null +++ b/src/modules/import/api/importvorgang-status.response.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ImportStatus, ImportStatusName } from '../domain/import.enums.js'; +import { ImportVorgang } from '../domain/import-vorgang.js'; + +export class ImportVorgangStatusResponse { + @ApiProperty({ enum: ImportStatus, enumName: ImportStatusName }) + public status: ImportStatus; + + public constructor(importVorgang: ImportVorgang) { + this.status = importVorgang.status; + } +} diff --git a/src/modules/import/domain/import-csv-file-contains-no-users.error.ts b/src/modules/import/domain/import-csv-file-contains-no-users.error.ts new file mode 100644 index 000000000..eb336bc2e --- /dev/null +++ b/src/modules/import/domain/import-csv-file-contains-no-users.error.ts @@ -0,0 +1,7 @@ +import { ImportDomainError } from './import-domain.error.js'; + +export class ImportCSVFileContainsNoUsersError extends ImportDomainError { + public constructor(details?: unknown[] | Record) { + super(`CSV File is invalid because the file contains no users`, undefined, details); + } +} diff --git a/src/modules/import/domain/import-csv-file-max-users.error.ts b/src/modules/import/domain/import-csv-file-max-users.error.ts new file mode 100644 index 000000000..b70beaf74 --- /dev/null +++ b/src/modules/import/domain/import-csv-file-max-users.error.ts @@ -0,0 +1,11 @@ +import { ImportDomainError } from './import-domain.error.js'; + +export class ImportCSVFileMaxUsersError extends ImportDomainError { + public constructor(details?: unknown[] | Record) { + super( + `CSV File could not be parsed because the file exceeds the maximum allowed number of users`, + undefined, + details, + ); + } +} diff --git a/src/modules/import/domain/import-data-item.ts b/src/modules/import/domain/import-data-item.ts index 9c27f0276..80c9212bb 100644 --- a/src/modules/import/domain/import-data-item.ts +++ b/src/modules/import/domain/import-data-item.ts @@ -9,6 +9,8 @@ export class ImportDataItem { public klasse?: string, public personalnummer?: string, public validationErrors?: string[], + public username?: string, + public password?: string, ) {} public static construct( @@ -21,6 +23,8 @@ export class ImportDataItem { klasse?: string, personalnummer?: string, validationErrors?: string[], + username?: string, + password?: string, ): ImportDataItem { return new ImportDataItem( id, @@ -32,6 +36,8 @@ export class ImportDataItem { klasse, personalnummer, validationErrors, + username, + password, ); } diff --git a/src/modules/import/domain/import-event-handler.spec.ts b/src/modules/import/domain/import-event-handler.spec.ts new file mode 100644 index 000000000..77d3f2c77 --- /dev/null +++ b/src/modules/import/domain/import-event-handler.spec.ts @@ -0,0 +1,265 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { OrganisationRepository } from '../../organisation/persistence/organisation.repository.js'; +import { PersonPermissions } from '../../authentication/domain/person-permissions.js'; +import { ImportDataRepository } from '../persistence/import-data.repository.js'; +import { + PersonenkontextCreationService, + PersonPersonenkontext, +} from '../../personenkontext/domain/personenkontext-creation.service.js'; +import { DomainError } from '../../../shared/error/domain.error.js'; +import { EntityNotFoundError } from '../../../shared/error/entity-not-found.error.js'; +import { faker } from '@faker-js/faker'; +import { DoFactory } from '../../../../test/utils/do-factory.js'; +import { OrganisationsTyp } from '../../organisation/domain/organisation.enums.js'; +import { Organisation } from '../../organisation/domain/organisation.js'; +import { Person } from '../../person/domain/person.js'; +import { LoggingTestModule } from '../../../../test/utils/logging-test.module.js'; +import { ImportDataItem } from './import-data-item.js'; +import { ImportVorgangRepository } from '../persistence/import-vorgang.repository.js'; +import { ImportVorgang } from './import-vorgang.js'; +import { ImportEventHandler } from './import-event-handler.js'; +import { ClassLogger } from '../../../core/logging/class-logger.js'; +import { ImportExecutedEvent } from '../../../shared/events/import-executed.event.js'; +import { RolleNurAnPassendeOrganisationError } from '../../personenkontext/specification/error/rolle-nur-an-passende-organisation.js'; +import { ImportPasswordEncryptor } from './import-password-encryptor.js'; + +describe('ImportEventHandler', () => { + let module: TestingModule; + let sut: ImportEventHandler; + + let organisationRepoMock: DeepMocked; + let importDataRepositoryMock: DeepMocked; + let personenkontextCreationServiceMock: DeepMocked; + let importVorgangRepositoryMock: DeepMocked; + let loggerMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [LoggingTestModule], + providers: [ + ImportEventHandler, + { + provide: OrganisationRepository, + useValue: createMock(), + }, + { + provide: ImportDataRepository, + useValue: createMock(), + }, + { + provide: PersonenkontextCreationService, + useValue: createMock(), + }, + { + provide: PersonPermissions, + useValue: createMock(), + }, + { + provide: ImportVorgangRepository, + useValue: createMock(), + }, + { + provide: ImportPasswordEncryptor, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(ImportEventHandler); + organisationRepoMock = module.get(OrganisationRepository); + importDataRepositoryMock = module.get(ImportDataRepository); + personenkontextCreationServiceMock = module.get(PersonenkontextCreationService); + importVorgangRepositoryMock = module.get(ImportVorgangRepository); + loggerMock = module.get(ClassLogger); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('handleExecuteImport', () => { + const importvorgangId: string = faker.string.uuid(); + let event: ImportExecutedEvent; + + beforeEach(() => { + event = createMock({ importVorgangId: importvorgangId }); + }); + + it('should return EntityNotFoundError if a ImportVorgang does not exist', async () => { + organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([ + DoFactory.createOrganisation(true, { typ: OrganisationsTyp.KLASSE, name: '1A' }), + ]); + importVorgangRepositoryMock.findById.mockResolvedValueOnce(null); + const error: DomainError = new EntityNotFoundError('ImportVorgang', importvorgangId); + + await expect(sut.handleExecuteImport(event)).rejects.toThrowError(error); + + expect(personenkontextCreationServiceMock.createPersonWithPersonenkontexte).not.toHaveBeenCalled(); + }); + + it('should log error if the import transaction does not have any import data items', async () => { + organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([ + DoFactory.createOrganisation(true, { typ: OrganisationsTyp.KLASSE }), + ]); + importVorgangRepositoryMock.findById.mockResolvedValueOnce( + DoFactory.createImportVorgang(true, { id: importvorgangId }), + ); + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[], 0]); + + await sut.handleExecuteImport(event); + + expect(loggerMock.error).toHaveBeenCalledWith( + `No import data itemns found for Importvorgang:${importvorgangId}`, + ); + expect(personenkontextCreationServiceMock.createPersonWithPersonenkontexte).not.toHaveBeenCalled(); + }); + + it('should return EntityNotFoundError if a klasse during the import execution was deleted', async () => { + organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([ + DoFactory.createOrganisation(true, { typ: OrganisationsTyp.KLASSE, name: '1A' }), + ]); + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); + const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { + importvorgangId: importvorgang.id, + klasse: '1B', + }); + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); + + const error: DomainError = new EntityNotFoundError('Organisation', importDataItem.klasse, [ + `Klasse=${importDataItem.klasse} for ${importDataItem.vorname} ${importDataItem.nachname} was not found`, + ]); + + await expect(sut.handleExecuteImport(event)).rejects.toThrowError(error); + expect(personenkontextCreationServiceMock.createPersonWithPersonenkontexte).not.toHaveBeenCalled(); + expect(importVorgangRepositoryMock.save).toHaveBeenCalledTimes(1); + }); + + it('should log error if the person and PKs were not saved successfully', async () => { + const schule: Organisation = DoFactory.createOrganisation(true, { + typ: OrganisationsTyp.SCHULE, + }); + const klasse: Organisation = DoFactory.createOrganisation(true, { + typ: OrganisationsTyp.KLASSE, + name: '1A', + }); + organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([klasse]); + + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); + const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { + importvorgangId: importvorgang.id, + klasse: '1A', + }); + + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); + + const error: DomainError = new RolleNurAnPassendeOrganisationError(); + personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce(error); + organisationRepoMock.findById.mockResolvedValueOnce(schule); + + await sut.handleExecuteImport(event); + + expect(loggerMock.error).toHaveBeenCalledWith( + `System hat versucht einen neuen Benutzer für ${importDataItem.vorname} ${importDataItem.nachname} anzulegen. Fehler: ${error.message}`, + ); + expect(importDataRepositoryMock.save).not.toHaveBeenCalled(); + expect(importVorgangRepositoryMock.save).not.toHaveBeenCalled(); + }); + + it('should log error if the person has no start password', async () => { + const schule: Organisation = DoFactory.createOrganisation(true, { + typ: OrganisationsTyp.SCHULE, + }); + const klasse: Organisation = DoFactory.createOrganisation(true, { + typ: OrganisationsTyp.KLASSE, + name: '1A', + }); + organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([klasse]); + + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); + const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { + importvorgangId: importvorgang.id, + klasse: '1A', + }); + + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); + + const person: Person = DoFactory.createPerson(true); + const pks: PersonPersonenkontext = { + person: person, + personenkontexte: [ + DoFactory.createPersonenkontext(true, { organisationId: schule.id }), + DoFactory.createPersonenkontext(true, { organisationId: klasse.id }), + ], + }; + personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce(pks); + organisationRepoMock.findById.mockResolvedValueOnce(schule); + + await sut.handleExecuteImport(event); + + expect(person.newPassword).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith(`Person with ID ${person.id} has no start password!`); + expect(importDataRepositoryMock.save).not.toHaveBeenCalled(); + expect(importVorgangRepositoryMock.save).not.toHaveBeenCalled(); + }); + + it('should log info if the person and PKs were saved successfully', async () => { + const schule: Organisation = DoFactory.createOrganisation(true, { + typ: OrganisationsTyp.SCHULE, + }); + const klasse: Organisation = DoFactory.createOrganisation(true, { + typ: OrganisationsTyp.KLASSE, + name: '1A', + }); + organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([klasse]); + + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); + const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { + importvorgangId: importvorgang.id, + klasse: '1A', + }); + + const person: Person = Person.construct( + faker.string.uuid(), + faker.date.past(), + faker.date.recent(), + faker.person.lastName(), + faker.person.firstName(), + '5', + faker.lorem.word(), + faker.lorem.word(), + faker.string.uuid(), + ); + person.resetPassword(); + const pks: PersonPersonenkontext = { + person: person, + personenkontexte: [ + DoFactory.createPersonenkontext(true, { organisationId: schule.id }), + DoFactory.createPersonenkontext(true, { organisationId: klasse.id }), + ], + }; + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); + personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce(pks); + + await sut.handleExecuteImport(event); + + expect(loggerMock.info).toHaveBeenCalledWith( + `System hat einen neuen Benutzer ${person.referrer} (${person.id}) angelegt.`, + ); + expect(importDataRepositoryMock.save).toHaveBeenCalled(); + expect(importVorgangRepositoryMock.save).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/modules/import/domain/import-event-handler.ts b/src/modules/import/domain/import-event-handler.ts new file mode 100644 index 000000000..b8c31fa2d --- /dev/null +++ b/src/modules/import/domain/import-event-handler.ts @@ -0,0 +1,147 @@ +import { DomainError } from '../../../shared/error/domain.error.js'; +import { EntityNotFoundError } from '../../../shared/error/entity-not-found.error.js'; +import { OrganisationRepository } from '../../organisation/persistence/organisation.repository.js'; +import { Organisation } from '../../organisation/domain/organisation.js'; +import { ImportDataRepository } from '../persistence/import-data.repository.js'; +import { + PersonenkontextCreationService, + PersonPersonenkontext, +} from '../../personenkontext/domain/personenkontext-creation.service.js'; +import { DbiamCreatePersonenkontextBodyParams } from '../../personenkontext/api/param/dbiam-create-personenkontext.body.params.js'; +import { OrganisationsTyp } from '../../organisation/domain/organisation.enums.js'; +import { ImportDataItem } from './import-data-item.js'; +import { ImportVorgang } from './import-vorgang.js'; +import { ImportVorgangRepository } from '../persistence/import-vorgang.repository.js'; +import { ImportExecutedEvent } from '../../../shared/events/import-executed.event.js'; +import { EventHandler } from '../../../core/eventbus/decorators/event-handler.decorator.js'; +import { OrganisationByIdAndName } from './import-workflow.js'; +import { Injectable } from '@nestjs/common'; +import { ClassLogger } from '../../../core/logging/class-logger.js'; +import { ImportPasswordEncryptor } from './import-password-encryptor.js'; +@Injectable() +export class ImportEventHandler { + public selectedOrganisationId!: string; + + public selectedRolleId!: string; + + public constructor( + private readonly organisationRepository: OrganisationRepository, + private readonly importDataRepository: ImportDataRepository, + private readonly personenkontextCreationService: PersonenkontextCreationService, + private readonly importVorgangRepository: ImportVorgangRepository, + private readonly importPasswordEncryptor: ImportPasswordEncryptor, + private readonly logger: ClassLogger, + ) {} + + @EventHandler(ImportExecutedEvent) + public async handleExecuteImport(event: ImportExecutedEvent): Promise { + this.selectedOrganisationId = event.organisationId; + this.selectedRolleId = event.rolleId; + + const klassenByIDandName: OrganisationByIdAndName[] = []; + const klassen: Organisation[] = await this.organisationRepository.findChildOrgasForIds([ + this.selectedOrganisationId, + ]); + klassen.forEach((value: Organisation) => { + if (value.typ === OrganisationsTyp.KLASSE) { + klassenByIDandName.push({ + id: value.id, + name: value.name, + }); + } + }); + + const importDataItemsWithLoginInfo: ImportDataItem[] = []; + const importvorgangId: string = event.importVorgangId; + const importVorgang: Option> = await this.importVorgangRepository.findById(importvorgangId); + if (!importVorgang) { + throw new EntityNotFoundError('ImportVorgang', importvorgangId); + } + + const [importDataItems, total]: Counted> = + await this.importDataRepository.findByImportVorgangId(importvorgangId); + if (total === 0) { + return this.logger.error(`No import data itemns found for Importvorgang:${importvorgangId}`); + } + + //We must create every peron individually otherwise it cannot assign the correct username when we have multiple users with the same name + /* eslint-disable no-await-in-loop */ + for (const importDataItem of importDataItems) { + const klasse: OrganisationByIdAndName | undefined = klassenByIDandName.find( + (organisationByIdAndName: OrganisationByIdAndName) => + organisationByIdAndName.name === importDataItem.klasse, + ); + if (!klasse) { + importVorgang.fail(); + await this.importVorgangRepository.save(importVorgang); + + throw new EntityNotFoundError('Organisation', importDataItem.klasse, [ + `Klasse=${importDataItem.klasse} for ${importDataItem.vorname} ${importDataItem.nachname} was not found`, + ]); + } + + const createPersonenkontexte: DbiamCreatePersonenkontextBodyParams[] = [ + { + organisationId: this.selectedOrganisationId, + rolleId: this.selectedRolleId, + }, + { + organisationId: klasse.id, + rolleId: this.selectedRolleId, + }, + ]; + + const savedPersonWithPersonenkontext: DomainError | PersonPersonenkontext = + await this.personenkontextCreationService.createPersonWithPersonenkontexte( + event.permissions, + importDataItem.vorname, + importDataItem.nachname, + createPersonenkontexte, + ); + + if (!(savedPersonWithPersonenkontext instanceof DomainError)) { + this.logger.info( + `System hat einen neuen Benutzer ${savedPersonWithPersonenkontext.person.referrer} (${savedPersonWithPersonenkontext.person.id}) angelegt.`, + ); + } else { + return this.logger.error( + `System hat versucht einen neuen Benutzer für ${importDataItem.vorname} ${importDataItem.nachname} anzulegen. Fehler: ${savedPersonWithPersonenkontext.message}`, + ); + } + + if (!savedPersonWithPersonenkontext.person.newPassword) { + return this.logger.error( + `Person with ID ${savedPersonWithPersonenkontext.person.id} has no start password!`, + ); + } + + importDataItemsWithLoginInfo.push( + ImportDataItem.construct( + importDataItem.id, + importDataItem.createdAt, + importDataItem.updatedAt, + importDataItem.importvorgangId, + importDataItem.nachname, + importDataItem.vorname, + importDataItem.klasse, + importDataItem.personalnummer, + importDataItem.validationErrors, + savedPersonWithPersonenkontext.person.referrer, + await this.importPasswordEncryptor.encryptPassword( + savedPersonWithPersonenkontext.person.newPassword, + ), + ), + ); + } + /* eslint-disable no-await-in-loop */ + + await Promise.allSettled( + importDataItemsWithLoginInfo.map(async (importDataItem: ImportDataItem) => + this.importDataRepository.save(importDataItem), + ), + ); + + importVorgang.finish(); + await this.importVorgangRepository.save(importVorgang); + } +} diff --git a/src/modules/import/domain/import-password-encryptor.spec.ts b/src/modules/import/domain/import-password-encryptor.spec.ts new file mode 100644 index 000000000..1a8c8e83d --- /dev/null +++ b/src/modules/import/domain/import-password-encryptor.spec.ts @@ -0,0 +1,85 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ConfigTestModule } from '../../../../test/utils/config-test.module.js'; +import { ImportPasswordEncryptor } from './import-password-encryptor.js'; +import { ConfigService } from '@nestjs/config'; +import { ServerConfig } from '../../../shared/config/server.config.js'; +import { faker } from '@faker-js/faker'; +import { DomainError } from '../../../shared/error/domain.error.js'; +import { ImportDomainError } from './import-domain.error.js'; + +describe('ImportPasswordEncryptor', () => { + let module: TestingModule; + let sut: ImportPasswordEncryptor; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigTestModule], + providers: [], + }).compile(); + sut = new ImportPasswordEncryptor(module.get(ConfigService)); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('encryptPassword', () => { + describe('when encrypt password', () => { + it('should return encrypted data containing iv and encrypted password', async () => { + const password: string = 'password'; + + const result: string = await sut.encryptPassword(password); + + const [encryptedPassword, iv]: string[] = result.split('|'); + expect(encryptedPassword).toBeDefined(); + expect(iv).toBeDefined(); + expect(encryptedPassword).not.toEqual(password); + }); + }); + }); + + describe('decryptPassword', () => { + describe('when decrypt encrypted password data does not contain iv', () => { + it('should throw ImportDomainError', async () => { + const invalidEncryptedPasswordData: string = '5ba56bceb34c5b84'; + const importvorgangId: string = faker.string.uuid(); + const error: DomainError = new ImportDomainError('iv for decryption not found', importvorgangId); + + await expect(sut.decryptPassword(invalidEncryptedPasswordData, importvorgangId)).rejects.toThrowError( + error, + ); + }); + }); + + describe('when decrypt encrypted password data does not contain encrypted password', () => { + it('should throw ImportDomainError', async () => { + const invalidEncryptedPasswordData: string = '|6ad72f7a8fa8d98daa7e3f0dc6aa2a82'; + const importvorgangId: string = faker.string.uuid(); + const error: DomainError = new ImportDomainError( + 'encryptedPassword for decryption not found', + importvorgangId, + ); + + await expect(sut.decryptPassword(invalidEncryptedPasswordData, importvorgangId)).rejects.toThrowError( + error, + ); + }); + }); + + describe('when decrypt encrypted password data', () => { + it('should return user password', async () => { + const password: string = 'password'; + const encryptedPasswordData: string = await sut.encryptPassword(password); + + const result: string = await sut.decryptPassword(encryptedPasswordData, faker.string.uuid()); + + expect(result).toEqual(password); + }); + }); + }); +}); diff --git a/src/modules/import/domain/import-password-encryptor.ts b/src/modules/import/domain/import-password-encryptor.ts new file mode 100644 index 000000000..4e5cd6d74 --- /dev/null +++ b/src/modules/import/domain/import-password-encryptor.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { Cipher, createCipheriv, createDecipheriv, Encoding, randomBytes, scrypt } from 'crypto'; +import { ImportConfig } from '../../../shared/config/import.config.js'; +import { ServerConfig } from '../../../shared/config/server.config.js'; +import { ConfigService } from '@nestjs/config'; +import { promisify } from 'util'; +import { ImportDomainError } from './import-domain.error.js'; + +@Injectable() +export class ImportPasswordEncryptor { + private PASSPHRASE_SECRET!: string; + + private PASSPHRASE_SALT!: string; + + private readonly ALGORITHM: string = 'aes-256-ctr'; + + private readonly INPUT_ENCODING: Encoding = 'utf8'; + + private readonly OUTPUT_ENCODING: Encoding = 'hex'; + + public constructor(private readonly config: ConfigService) { + this.PASSPHRASE_SECRET = this.config.getOrThrow('IMPORT').PASSPHRASE_SECRET; + this.PASSPHRASE_SALT = this.config.getOrThrow('IMPORT').PASSPHRASE_SALT; + } + + public async encryptPassword(password: string): Promise { + const iv: Buffer = randomBytes(16); + + const key: Buffer = await this.generateKey(); + const cipher: Cipher = createCipheriv(this.ALGORITHM, key, iv); + + const encryptedPassword: string = + cipher.update(password, this.INPUT_ENCODING, this.OUTPUT_ENCODING) + cipher.final(this.OUTPUT_ENCODING); + const ivString: string = iv.toString(this.OUTPUT_ENCODING); + + return [encryptedPassword, ivString].join('|'); + } + + public async decryptPassword(encryptedPasswordData: string, importvorgangId: string): Promise { + const [encryptedPassword, iv]: string[] = encryptedPasswordData.split('|'); + + if (!iv) throw new ImportDomainError('iv for decryption not found', importvorgangId); + const key: Buffer = await this.generateKey(); + const decipher: Cipher = createDecipheriv(this.ALGORITHM, key, Buffer.from(iv, this.OUTPUT_ENCODING)); + + if (!encryptedPassword) + throw new ImportDomainError('encryptedPassword for decryption not found', importvorgangId); + const password: string = + decipher.update(encryptedPassword, this.OUTPUT_ENCODING, this.INPUT_ENCODING) + + decipher.final(this.INPUT_ENCODING); + return password; + } + + private async generateKey(): Promise { + return (await promisify(scrypt)(this.PASSPHRASE_SECRET, this.PASSPHRASE_SALT, 32)) as Buffer; + } +} diff --git a/src/modules/import/domain/import-vorgang.ts b/src/modules/import/domain/import-vorgang.ts index bcc9ee8ab..263301ae9 100644 --- a/src/modules/import/domain/import-vorgang.ts +++ b/src/modules/import/domain/import-vorgang.ts @@ -86,4 +86,8 @@ export class ImportVorgang { public cancel(): void { this.status = ImportStatus.CANCELLED; } + + public finish(): void { + this.status = ImportStatus.FINISHED; + } } diff --git a/src/modules/import/domain/import-workflow.factory.ts b/src/modules/import/domain/import-workflow.factory.ts index edf35ae98..c2e942a03 100644 --- a/src/modules/import/domain/import-workflow.factory.ts +++ b/src/modules/import/domain/import-workflow.factory.ts @@ -3,9 +3,12 @@ import { RolleRepo } from '../../rolle/repo/rolle.repo.js'; import { OrganisationRepository } from '../../organisation/persistence/organisation.repository.js'; import { ImportWorkflow } from './import-workflow.js'; import { ImportDataRepository } from '../persistence/import-data.repository.js'; -import { PersonenkontextCreationService } from '../../personenkontext/domain/personenkontext-creation.service.js'; -import { ClassLogger } from '../../../core/logging/class-logger.js'; import { ImportVorgangRepository } from '../persistence/import-vorgang.repository.js'; +import { EventService } from '../../../core/eventbus/index.js'; +import { ClassLogger } from '../../../core/logging/class-logger.js'; +import { ImportPasswordEncryptor } from './import-password-encryptor.js'; +import { ConfigService } from '@nestjs/config'; +import { ServerConfig } from '../../../shared/config/server.config.js'; @Injectable() export class ImportWorkflowFactory { @@ -13,9 +16,11 @@ export class ImportWorkflowFactory { private readonly rolleRepo: RolleRepo, private readonly organisationRepository: OrganisationRepository, private readonly importDataRepository: ImportDataRepository, - private readonly personenkontextCreationService: PersonenkontextCreationService, - private readonly logger: ClassLogger, private readonly importVorgangRepository: ImportVorgangRepository, + private readonly importPasswordEncryptor: ImportPasswordEncryptor, + private readonly eventService: EventService, + private readonly logger: ClassLogger, + private readonly config: ConfigService, ) {} public createNew(): ImportWorkflow { @@ -23,9 +28,11 @@ export class ImportWorkflowFactory { this.rolleRepo, this.organisationRepository, this.importDataRepository, - this.personenkontextCreationService, - this.logger, this.importVorgangRepository, + this.importPasswordEncryptor, + this.eventService, + this.logger, + this.config, ); } } diff --git a/src/modules/import/domain/import-workflow.spec.ts b/src/modules/import/domain/import-workflow.spec.ts index 2eca5efd7..eda8625da 100644 --- a/src/modules/import/domain/import-workflow.spec.ts +++ b/src/modules/import/domain/import-workflow.spec.ts @@ -5,10 +5,6 @@ import { OrganisationRepository } from '../../organisation/persistence/organisat import { PersonPermissions } from '../../authentication/domain/person-permissions.js'; import { ImportDataRepository } from '../persistence/import-data.repository.js'; import { ImportUploadResultFields, ImportWorkflow } from './import-workflow.js'; -import { - PersonenkontextCreationService, - PersonPersonenkontext, -} from '../../personenkontext/domain/personenkontext-creation.service.js'; import { ImportWorkflowFactory } from './import-workflow.factory.js'; import { DomainError } from '../../../shared/error/domain.error.js'; import { EntityNotFoundError } from '../../../shared/error/entity-not-found.error.js'; @@ -23,27 +19,33 @@ import internal from 'stream'; import { Organisation } from '../../organisation/domain/organisation.js'; import { ImportTextFileCreationError } from './import-text-file-creation.error.js'; import { RolleNurAnPassendeOrganisationError } from '../../personenkontext/specification/error/rolle-nur-an-passende-organisation.js'; -import { Person } from '../../person/domain/person.js'; import { ImportCSVFileEmptyError } from './import-csv-file-empty.error.js'; import { ImportNurLernAnSchuleUndKlasseError } from './import-nur-lern-an-schule-und-klasse.error.js'; import { ImportCSVFileParsingError } from './import-csv-file-parsing.error.js'; import { ImportCSVFileInvalidHeaderError } from './import-csv-file-invalid-header.error.js'; -import { VornameForPersonWithTrailingSpaceError } from '../../person/domain/vorname-with-trailing-space.error.js'; import { LoggingTestModule } from '../../../../test/utils/logging-test.module.js'; import { ImportDataItem } from './import-data-item.js'; import { ImportVorgangRepository } from '../persistence/import-vorgang.repository.js'; import { ImportVorgang } from './import-vorgang.js'; +import { ImportPasswordEncryptor } from './import-password-encryptor.js'; +import { ImportDomainError } from './import-domain.error.js'; +import { ImportStatus } from './import.enums.js'; +import { EventModule } from '../../../core/eventbus/event.module.js'; +import { ConfigTestModule } from '../../../../test/utils/config-test.module.js'; +import { ImportCSVFileMaxUsersError } from './import-csv-file-max-users.error.js'; +import { ImportCSVFileContainsNoUsersError } from './import-csv-file-contains-no-users.error.js'; describe('ImportWorkflow', () => { let module: TestingModule; + let sut: ImportWorkflow; + let importWorkflowFactory: ImportWorkflowFactory; + let rolleRepoMock: DeepMocked; let organisationRepoMock: DeepMocked; let importDataRepositoryMock: DeepMocked; - let personenkontextCreationServiceMock: DeepMocked; let importVorgangRepositoryMock: DeepMocked; - let sut: ImportWorkflow; - let importWorkflowFactory: ImportWorkflowFactory; let personpermissionsMock: DeepMocked; + let importPasswordEncryptorMock: DeepMocked; const SELECTED_ORGANISATION_ID: string = faker.string.uuid(); const SELECTED_ROLLE_ID: string = faker.string.uuid(); @@ -51,7 +53,7 @@ describe('ImportWorkflow', () => { beforeAll(async () => { module = await Test.createTestingModule({ - imports: [LoggingTestModule], + imports: [LoggingTestModule, EventModule, ConfigTestModule], providers: [ ImportWorkflowFactory, { @@ -62,32 +64,32 @@ describe('ImportWorkflow', () => { provide: OrganisationRepository, useValue: createMock(), }, + { + provide: ImportVorgangRepository, + useValue: createMock(), + }, { provide: ImportDataRepository, useValue: createMock(), }, { - provide: PersonenkontextCreationService, - useValue: createMock(), + provide: ImportPasswordEncryptor, + useValue: createMock(), }, { provide: PersonPermissions, useValue: createMock(), }, - { - provide: ImportVorgangRepository, - useValue: createMock(), - }, ], }).compile(); rolleRepoMock = module.get(RolleRepo); organisationRepoMock = module.get(OrganisationRepository); importDataRepositoryMock = module.get(ImportDataRepository); - personenkontextCreationServiceMock = module.get(PersonenkontextCreationService); importVorgangRepositoryMock = module.get(ImportVorgangRepository); importWorkflowFactory = module.get(ImportWorkflowFactory); sut = importWorkflowFactory.createNew(); personpermissionsMock = module.get(PersonPermissions); + importPasswordEncryptorMock = module.get(ImportPasswordEncryptor); }); afterAll(async () => { @@ -102,31 +104,25 @@ describe('ImportWorkflow', () => { expect(sut).toBeDefined(); }); - describe('initialize', () => { - it('should initialize the aggregate with the selected Organisation and Rolle', () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); - expect(sut.selectedOrganisationId).toBe(SELECTED_ORGANISATION_ID); - expect(sut.selectedRolleId).toBe(SELECTED_ROLLE_ID); - }); - }); - describe('validateImport', () => { it('should return EntityNotFoundError if the organisation does not exist', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); organisationRepoMock.findById.mockResolvedValueOnce(undefined); const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(EntityNotFoundError); }); it('should return EntityNotFoundError if the rolle does not exist', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); organisationRepoMock.findById.mockResolvedValueOnce(DoFactory.createOrganisation(true)); rolleRepoMock.findById.mockResolvedValueOnce(undefined); const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(EntityNotFoundError); @@ -134,7 +130,6 @@ describe('ImportWorkflow', () => { }); it('should return EntityNotFoundError if the rolle can not be assigned to organisation', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); organisationRepoMock.findById.mockResolvedValueOnce(DoFactory.createOrganisation(true)); const rolleMock: DeepMocked> = createMock>(); rolleMock.rollenart = RollenArt.LERN; @@ -142,6 +137,8 @@ describe('ImportWorkflow', () => { rolleRepoMock.findById.mockResolvedValueOnce(rolleMock); const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(EntityNotFoundError); @@ -149,7 +146,6 @@ describe('ImportWorkflow', () => { }); it('should return ImportNurLernAnSchuleUndKlasseError if the rolle is not rollenart LERN', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); organisationRepoMock.findById.mockResolvedValueOnce( DoFactory.createOrganisation(true, { typ: OrganisationsTyp.SCHULE }), ); @@ -159,6 +155,8 @@ describe('ImportWorkflow', () => { rolleRepoMock.findById.mockResolvedValueOnce(rolleMock); const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(ImportNurLernAnSchuleUndKlasseError); @@ -167,7 +165,6 @@ describe('ImportWorkflow', () => { }); it('should return RolleNurAnPassendeOrganisationError if the rolle does not pass to organisation', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); organisationRepoMock.findById.mockResolvedValueOnce( DoFactory.createOrganisation(true, { typ: OrganisationsTyp.LAND }), ); @@ -177,6 +174,8 @@ describe('ImportWorkflow', () => { rolleRepoMock.findById.mockResolvedValueOnce(rolleMock); const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(RolleNurAnPassendeOrganisationError); @@ -185,7 +184,6 @@ describe('ImportWorkflow', () => { }); it('should return MissingPermissionsError if the admin does not permissions to import data', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); organisationRepoMock.findById.mockResolvedValueOnce( DoFactory.createOrganisation(true, { typ: OrganisationsTyp.SCHULE }), ); @@ -198,6 +196,8 @@ describe('ImportWorkflow', () => { const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(MissingPermissionsError); @@ -207,7 +207,6 @@ describe('ImportWorkflow', () => { const file: Express.Multer.File = createMock(); file.buffer = Buffer.from(''); - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); const rolleMock: DeepMocked> = createMock>(); rolleMock.rollenart = RollenArt.LERN; rolleMock.canBeAssignedToOrga.mockResolvedValueOnce(true); @@ -220,14 +219,63 @@ describe('ImportWorkflow', () => { const result: DomainError | ImportUploadResultFields = await sut.validateImport( file, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(ImportCSVFileEmptyError); expect(importDataRepositoryMock.save).not.toHaveBeenCalled(); }); + it('should return ImportCSVFileMaxUsersError if the csv file exceeds the number of maximum allowed users', async () => { + const file: Express.Multer.File = createMock(); + file.buffer = Buffer.from('Nachname;Vorname;Klasse\r\nTest;Hans;1A\r\nTest;Marie;1B\r\n'); + + const rolleMock: DeepMocked> = createMock>(); + rolleMock.rollenart = RollenArt.LERN; + rolleMock.canBeAssignedToOrga.mockResolvedValueOnce(true); + organisationRepoMock.findById.mockResolvedValueOnce( + DoFactory.createOrganisation(true, { typ: OrganisationsTyp.SCHULE }), + ); + rolleRepoMock.findById.mockResolvedValueOnce(rolleMock); + + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + + const result: DomainError | ImportUploadResultFields = await sut.validateImport( + file, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, + personpermissionsMock, + ); + expect(result).toBeInstanceOf(ImportCSVFileMaxUsersError); + expect(importDataRepositoryMock.save).not.toHaveBeenCalled(); + }); + + it('should return ImportCSVFileContainsNoUsersError if the csv file contains no data items', async () => { + const file: Express.Multer.File = createMock(); + file.buffer = Buffer.from('Nachname;Vorname;Klasse'); + + const rolleMock: DeepMocked> = createMock>(); + rolleMock.rollenart = RollenArt.LERN; + rolleMock.canBeAssignedToOrga.mockResolvedValueOnce(true); + organisationRepoMock.findById.mockResolvedValueOnce( + DoFactory.createOrganisation(true, { typ: OrganisationsTyp.SCHULE }), + ); + rolleRepoMock.findById.mockResolvedValueOnce(rolleMock); + + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + + const result: DomainError | ImportUploadResultFields = await sut.validateImport( + file, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, + personpermissionsMock, + ); + expect(result).toBeInstanceOf(ImportCSVFileContainsNoUsersError); + expect(importDataRepositoryMock.save).not.toHaveBeenCalled(); + }); + it('should return ImportCSVFileParsingError if the parser cannot parse', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); const rolleMock: DeepMocked> = createMock>(); rolleMock.rollenart = RollenArt.LERN; rolleMock.canBeAssignedToOrga.mockResolvedValueOnce(true); @@ -248,6 +296,8 @@ describe('ImportWorkflow', () => { const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(ImportCSVFileParsingError); @@ -255,7 +305,6 @@ describe('ImportWorkflow', () => { }); it('should return ImportCSVFileInvalidHeaderError if the parser cannot parse headers', async () => { - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); const rolleMock: DeepMocked> = createMock>(); rolleMock.rollenart = RollenArt.LERN; rolleMock.canBeAssignedToOrga.mockResolvedValueOnce(true); @@ -276,6 +325,8 @@ describe('ImportWorkflow', () => { const result: DomainError | ImportUploadResultFields = await sut.validateImport( FILE_MOCK, + SELECTED_ORGANISATION_ID, + SELECTED_ROLLE_ID, personpermissionsMock, ); expect(result).toBeInstanceOf(ImportCSVFileInvalidHeaderError); @@ -287,7 +338,7 @@ describe('ImportWorkflow', () => { it('should return MissingPermissionsError if the admin does not have permissions to execute the import transaction', async () => { personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValueOnce(false); - const result: Result = await sut.executeImport(faker.string.uuid(), personpermissionsMock); + const result: Result = await sut.executeImport(faker.string.uuid(), personpermissionsMock); expect(result).toEqual({ ok: false, @@ -295,95 +346,190 @@ describe('ImportWorkflow', () => { }); }); - it('should return EntityNotFoundError if the import transaction does not exist', async () => { + it('should return EntityNotFoundError if a ImportVorgang does not exist', async () => { personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); - organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([ - DoFactory.createOrganisation(true, { typ: OrganisationsTyp.KLASSE }), - ]); - importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[], 0]); + importVorgangRepositoryMock.findById.mockResolvedValueOnce(null); const importvorgangId: string = faker.string.uuid(); - const result: Result = await sut.executeImport(importvorgangId, personpermissionsMock); + const result: Result = await sut.executeImport(importvorgangId, personpermissionsMock); expect(result).toEqual({ ok: false, - error: new EntityNotFoundError('ImportDataItem', importvorgangId), + error: new EntityNotFoundError('ImportVorgang', importvorgangId), }); + expect(importVorgangRepositoryMock.save).not.toHaveBeenCalled(); }); - it('should return EntityNotFoundError if a ImportVorgang does not exist', async () => { + it('should return ImportDomainError if a ImportVorgang does not have an organisation id', async () => { personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); - organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([ - DoFactory.createOrganisation(true, { typ: OrganisationsTyp.KLASSE, name: '1A' }), - ]); - importVorgangRepositoryMock.findById.mockResolvedValueOnce(null); + importVorgangRepositoryMock.findById.mockResolvedValueOnce( + DoFactory.createImportVorgang(true, { organisationId: undefined }), + ); const importvorgangId: string = faker.string.uuid(); - const result: Result = await sut.executeImport(importvorgangId, personpermissionsMock); + const result: Result = await sut.executeImport(importvorgangId, personpermissionsMock); expect(result).toEqual({ ok: false, - error: new EntityNotFoundError('ImportVorgang', importvorgangId), + error: new ImportDomainError('ImportVorgang is missing an organisazion id', importvorgangId), + }); + expect(importVorgangRepositoryMock.save).not.toHaveBeenCalled(); + }); + + it('should return ImportDomainError if a ImportVorgang does not have a rolle id', async () => { + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce( + DoFactory.createImportVorgang(true, { organisationId: faker.string.uuid(), rolleId: undefined }), + ); + const importvorgangId: string = faker.string.uuid(); + + const result: Result = await sut.executeImport(importvorgangId, personpermissionsMock); + + expect(result).toEqual({ + ok: false, + error: new ImportDomainError('ImportVorgang is missing a rolle id', importvorgangId), }); - expect(personenkontextCreationServiceMock.createPersonWithPersonenkontexte).not.toHaveBeenCalled(); + expect(importVorgangRepositoryMock.save).not.toHaveBeenCalled(); }); - it('should return EntityNotFoundError if a klasse during the import execution was deleted', async () => { + it('should publish ImportExecutedEvent and return the undefined', async () => { personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); - organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([ - DoFactory.createOrganisation(true, { typ: OrganisationsTyp.KLASSE, name: '1A' }), - ]); const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { importvorgangId: importvorgang.id, - klasse: '1B', + klasse: '1A', }); + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); - const error: DomainError = new EntityNotFoundError('Organisation', importDataItem.klasse, [ - `Klasse=${importDataItem.klasse} for ${importDataItem.vorname} ${importDataItem.nachname} was not found`, - ]); + const rolle: Rolle = DoFactory.createRolle(true); + rolleRepoMock.findById.mockResolvedValueOnce(rolle); + + const result: Result = await sut.executeImport(importvorgang.id, personpermissionsMock); - await expect(sut.executeImport(importvorgang.id, personpermissionsMock)).rejects.toThrowError(error); - expect(personenkontextCreationServiceMock.createPersonWithPersonenkontexte).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true, value: undefined }); + expect(importVorgangRepositoryMock.save).toHaveBeenCalled(); }); + }); - it('should return the file buffer if the import transaction was executed successfully', async () => { - const schule: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.SCHULE, + describe('downloadFile', () => { + it('should return MissingPermissionsError if the admin does not have permissions to execute the import transaction', async () => { + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValueOnce(false); + + const result: Result = await sut.downloadFile(faker.string.uuid(), personpermissionsMock); + + expect(result).toEqual({ + ok: false, + error: new MissingPermissionsError('Unauthorized to import data'), }); + }); + + it('should return EntityNotFoundError if a ImportVorgang does not exist', async () => { personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); - const klasse: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.KLASSE, - name: '1A', + importVorgangRepositoryMock.findById.mockResolvedValueOnce(null); + const importvorgangId: string = faker.string.uuid(); + + const result: Result = await sut.downloadFile(importvorgangId, personpermissionsMock); + + expect(result).toEqual({ + ok: false, + error: new EntityNotFoundError('ImportVorgang', importvorgangId), }); - organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([klasse]); + expect(importDataRepositoryMock.deleteByImportVorgangId).not.toHaveBeenCalled(); + }); - const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); + it('should return ImportDomainError if a ImportVorgang does not have an organisation id', async () => { + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce( + DoFactory.createImportVorgang(true, { organisationId: undefined }), + ); + const importvorgangId: string = faker.string.uuid(); + + const result: Result = await sut.downloadFile(importvorgangId, personpermissionsMock); + + expect(result).toEqual({ + ok: false, + error: new ImportDomainError('ImportVorgang is missing an organisazion id', importvorgangId), + }); + expect(importVorgangRepositoryMock.save).not.toHaveBeenCalled(); + }); + + it('should return ImportDomainError if a ImportVorgang does not have a rolle id', async () => { + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce( + DoFactory.createImportVorgang(true, { organisationId: faker.string.uuid(), rolleId: undefined }), + ); + const importvorgangId: string = faker.string.uuid(); + + const result: Result = await sut.downloadFile(importvorgangId, personpermissionsMock); + + expect(result).toEqual({ + ok: false, + error: new ImportDomainError('ImportVorgang is missing a rolle id', importvorgangId), + }); + expect(importVorgangRepositoryMock.save).not.toHaveBeenCalled(); + }); + + it('should return ImportDomainError if the ImportVorgang is not finsihed', async () => { + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce( + DoFactory.createImportVorgang(true, { status: ImportStatus.INPROGRESS }), + ); + const importvorgangId: string = faker.string.uuid(); + + const result: Result = await sut.downloadFile(importvorgangId, personpermissionsMock); + + expect(result).toEqual({ + ok: false, + error: new ImportDomainError('ImportVorgang is still in progress', importvorgangId), + }); + expect(importDataRepositoryMock.deleteByImportVorgangId).not.toHaveBeenCalled(); + }); + + it('should return EntityNotFoundError if the ImportVorgang gas import data items', async () => { + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + importVorgangRepositoryMock.findById.mockResolvedValueOnce( + DoFactory.createImportVorgang(true, { status: ImportStatus.FINISHED }), + ); + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[], 0]); + const importvorgangId: string = faker.string.uuid(); + + const result: Result = await sut.downloadFile(importvorgangId, personpermissionsMock); + + expect(result).toEqual({ + ok: false, + error: new EntityNotFoundError('ImportDataItem', importvorgangId), + }); + expect(importDataRepositoryMock.deleteByImportVorgangId).not.toHaveBeenCalled(); + }); + + it('should return the file buffer if the import transaction was executed successfully', async () => { + personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true, { + status: ImportStatus.FINISHED, + }); importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { importvorgangId: importvorgang.id, klasse: '1A', + nachname: 'Mustermann', + vorname: 'Max', + username: 'max.mustermann', + password: 'encrpytedpassword|iv', }); - - const person: Person = DoFactory.createPerson(true); - const pks: PersonPersonenkontext = { - person: person, - personenkontexte: [ - DoFactory.createPersonenkontext(true, { organisationId: schule.id }), - DoFactory.createPersonenkontext(true, { organisationId: klasse.id }), - ], - }; importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); - personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce(pks); + + const schule: Organisation = DoFactory.createOrganisation(true, { + typ: OrganisationsTyp.SCHULE, + }); organisationRepoMock.findById.mockResolvedValueOnce(schule); const rolle: Rolle = DoFactory.createRolle(true); rolleRepoMock.findById.mockResolvedValueOnce(rolle); + importPasswordEncryptorMock.decryptPassword.mockResolvedValueOnce('password'); - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); - const result: Result = await sut.executeImport(importvorgang.id, personpermissionsMock); + const result: Result = await sut.downloadFile(importvorgang.id, personpermissionsMock); if (!result.ok) { throw new Error(result.error.message); @@ -393,42 +539,32 @@ describe('ImportWorkflow', () => { const resultString: string = result.value.toString('utf8'); expect(resultString).toContain(schule.name); expect(resultString).toContain(rolle.name); - expect(resultString).toContain(person.vorname); - expect(resultString).toContain(person.familienname); - expect(resultString).toContain(klasse.name); + expect(resultString).toContain(importDataItem.vorname); + expect(resultString).toContain(importDataItem.nachname); + expect(resultString).toContain(importDataItem.klasse); expect(importDataRepositoryMock.deleteByImportVorgangId).toHaveBeenCalledWith(importvorgang.id); }); it('should return EntityNotFoundError if the schule is not found', async () => { - const schule: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.SCHULE, - }); personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); - const klasse: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.KLASSE, - name: '1A', + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true, { + status: ImportStatus.FINISHED, + organisationId: SELECTED_ORGANISATION_ID, + rolleId: SELECTED_ROLLE_ID, }); - organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([klasse]); - - const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { importvorgangId: importvorgang.id, klasse: '1A', + nachname: 'Mustermann', + vorname: 'Max', + username: 'max.mustermann', + password: 'password', }); - const pks: PersonPersonenkontext = { - person: DoFactory.createPerson(true), - personenkontexte: [ - DoFactory.createPersonenkontext(true, { organisationId: schule.id }), - DoFactory.createPersonenkontext(true, { organisationId: klasse.id }), - ], - }; importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); - personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce(pks); organisationRepoMock.findById.mockResolvedValueOnce(undefined); - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); - const result: Result = await sut.executeImport(importvorgang.id, personpermissionsMock); + const result: Result = await sut.downloadFile(importvorgang.id, personpermissionsMock); expect(result).toEqual({ ok: false, @@ -437,36 +573,26 @@ describe('ImportWorkflow', () => { }); it('should return EntityNotFoundError if the rolle is not found', async () => { - const schule: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.SCHULE, - }); personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); - const klasse: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.KLASSE, - name: '1A', + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true, { + status: ImportStatus.FINISHED, + organisationId: SELECTED_ORGANISATION_ID, + rolleId: SELECTED_ROLLE_ID, }); - organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([klasse]); - - const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { importvorgangId: importvorgang.id, klasse: '1A', + nachname: 'Mustermann', + vorname: 'Max', + username: 'max.mustermann', + password: 'password', }); - const pks: PersonPersonenkontext = { - person: DoFactory.createPerson(true), - personenkontexte: [ - DoFactory.createPersonenkontext(true, { organisationId: schule.id }), - DoFactory.createPersonenkontext(true, { organisationId: klasse.id }), - ], - }; importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); - personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce(pks); organisationRepoMock.findById.mockResolvedValueOnce(DoFactory.createOrganisation(true)); rolleRepoMock.findById.mockResolvedValueOnce(undefined); - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); - const result: Result = await sut.executeImport(importvorgang.id, personpermissionsMock); + const result: Result = await sut.downloadFile(importvorgang.id, personpermissionsMock); expect(result).toEqual({ ok: false, @@ -475,49 +601,27 @@ describe('ImportWorkflow', () => { }); it('should return ImportTextFileCreationError if the text file cannot be created', async () => { - const schule: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.SCHULE, - }); personpermissionsMock.hasSystemrechteAtRootOrganisation.mockResolvedValue(true); - const klasse: Organisation = DoFactory.createOrganisation(true, { - typ: OrganisationsTyp.KLASSE, - name: '1A', + const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true, { + status: ImportStatus.FINISHED, }); - organisationRepoMock.findChildOrgasForIds.mockResolvedValueOnce([klasse]); - - const importvorgang: ImportVorgang = DoFactory.createImportVorgang(true); importVorgangRepositoryMock.findById.mockResolvedValueOnce(importvorgang); - const importDataItem1: ImportDataItem = DoFactory.createImportDataItem(true, { - importvorgangId: importvorgang.id, - klasse: '1A', - }); - const importDataItem2: ImportDataItem = DoFactory.createImportDataItem(true, { + const importDataItem: ImportDataItem = DoFactory.createImportDataItem(true, { importvorgangId: importvorgang.id, klasse: '1A', + nachname: 'Mustermann', + vorname: 'Max', + username: 'max.mustermann', + password: 'password', }); - const pks: PersonPersonenkontext = { - person: DoFactory.createPerson(true), - personenkontexte: [ - DoFactory.createPersonenkontext(true, { organisationId: schule.id }), - DoFactory.createPersonenkontext(true, { organisationId: klasse.id }), - ], - }; - importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([ - [importDataItem1, importDataItem2], - 2, - ]); - personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce(pks); - personenkontextCreationServiceMock.createPersonWithPersonenkontexte.mockResolvedValueOnce( - new VornameForPersonWithTrailingSpaceError(), - ); + importDataRepositoryMock.findByImportVorgangId.mockResolvedValueOnce([[importDataItem], 1]); organisationRepoMock.findById.mockResolvedValueOnce(DoFactory.createOrganisation(true)); rolleRepoMock.findById.mockResolvedValueOnce(DoFactory.createRolle(true)); jest.spyOn(Buffer, 'from').mockImplementationOnce(() => { throw new Error('Error details'); }); - sut.initialize(SELECTED_ORGANISATION_ID, SELECTED_ROLLE_ID); - const result: Result = await sut.executeImport(importvorgang.id, personpermissionsMock); + const result: Result = await sut.downloadFile(importvorgang.id, personpermissionsMock); expect(result).toEqual({ ok: false, diff --git a/src/modules/import/domain/import-workflow.ts b/src/modules/import/domain/import-workflow.ts index ae2ece7ee..1c465eef3 100644 --- a/src/modules/import/domain/import-workflow.ts +++ b/src/modules/import/domain/import-workflow.ts @@ -13,13 +13,7 @@ import Papa, { ParseResult } from 'papaparse'; import { CSVImportDataItemDTO } from './csv-import-data-item.dto.js'; import { ImportCSVFileParsingError } from './import-csv-file-parsing.error.js'; import { ImportDataRepository } from '../persistence/import-data.repository.js'; -import { - PersonenkontextCreationService, - PersonPersonenkontext, -} from '../../personenkontext/domain/personenkontext-creation.service.js'; -import { DbiamCreatePersonenkontextBodyParams } from '../../personenkontext/api/param/dbiam-create-personenkontext.body.params.js'; import { OrganisationsTyp } from '../../organisation/domain/organisation.enums.js'; -import { Personenkontext } from '../../personenkontext/domain/personenkontext.js'; import { ImportTextFileCreationError } from './import-text-file-creation.error.js'; import { ImportCSVFileEmptyError } from './import-csv-file-empty.error.js'; import { ImportNurLernAnSchuleUndKlasseError } from './import-nur-lern-an-schule-und-klasse.error.js'; @@ -31,6 +25,16 @@ import { ClassLogger } from '../../../core/logging/class-logger.js'; import { ImportDataItem } from './import-data-item.js'; import { ImportVorgang } from './import-vorgang.js'; import { ImportVorgangRepository } from '../persistence/import-vorgang.repository.js'; +import { ImportExecutedEvent } from '../../../shared/events/import-executed.event.js'; +import { EventService } from '../../../core/eventbus/index.js'; +import { ImportStatus } from './import.enums.js'; +import { ImportDomainError } from './import-domain.error.js'; +import { ImportPasswordEncryptor } from './import-password-encryptor.js'; +import { ConfigService } from '@nestjs/config'; +import { ServerConfig } from '../../../shared/config/server.config.js'; +import { ImportConfig } from '../../../shared/config/import.config.js'; +import { ImportCSVFileMaxUsersError } from './import-csv-file-max-users.error.js'; +import { ImportCSVFileContainsNoUsersError } from './import-csv-file-contains-no-users.error.js'; export type ImportUploadResultFields = { importVorgangId: string; @@ -58,39 +62,49 @@ export class ImportWorkflow { public readonly CSV_FILE_VALID_HEADERS: string[] = ['nachname', 'vorname', 'klasse']; - public selectedOrganisationId!: string; + private CSV_MAX_NUMBER_OF_USERS!: number; - public selectedRolleId!: string; + private selectedOrganisationId!: string; + + private selectedRolleId!: string; private constructor( private readonly rolleRepo: RolleRepo, private readonly organisationRepository: OrganisationRepository, private readonly importDataRepository: ImportDataRepository, - private readonly personenkontextCreationService: PersonenkontextCreationService, - private readonly logger: ClassLogger, private readonly importVorgangRepository: ImportVorgangRepository, - ) {} + private readonly importPasswordEncryptor: ImportPasswordEncryptor, + private readonly eventService: EventService, + private readonly logger: ClassLogger, + private readonly config: ConfigService, + ) { + this.CSV_MAX_NUMBER_OF_USERS = this.config.getOrThrow('IMPORT').CSV_MAX_NUMBER_OF_USERS; + } public static createNew( rolleRepo: RolleRepo, organisationRepository: OrganisationRepository, importDataRepository: ImportDataRepository, - personenkontextCreationService: PersonenkontextCreationService, - logger: ClassLogger, importVorgangRepository: ImportVorgangRepository, + importPasswordEncryptor: ImportPasswordEncryptor, + eventService: EventService, + logger: ClassLogger, + config: ConfigService, ): ImportWorkflow { return new ImportWorkflow( rolleRepo, organisationRepository, importDataRepository, - personenkontextCreationService, - logger, importVorgangRepository, + importPasswordEncryptor, + eventService, + logger, + config, ); } // Initialize the aggregate with the selected Organisation and Rolle - public initialize(organisationId: string, rolleId: string): void { + private initialize(organisationId: string, rolleId: string): void { this.selectedOrganisationId = organisationId; this.selectedRolleId = rolleId; } @@ -98,8 +112,11 @@ export class ImportWorkflow { // Check References and Permissions public async validateImport( file: Express.Multer.File, + organisationId: string, + rolleId: string, permissions: PersonPermissions, ): Promise { + this.initialize(organisationId, rolleId); const referenceCheck: DomainError | RolleAndOrganisationByName = await this.checkReferences( this.selectedOrganisationId, this.selectedRolleId, @@ -125,6 +142,10 @@ export class ImportWorkflow { return new ImportCSVFileParsingError(parsedData.errors); } + if (parsedData.data.length === 0) { + return new ImportCSVFileContainsNoUsersError(); + } + parsedDataItems = plainToInstance(CSVImportDataItemDTO, parsedData.data); } catch (error) { if (error instanceof ImportCSVFileInvalidHeaderError) { @@ -205,8 +226,7 @@ export class ImportWorkflow { return this.importDataRepository.save(importDataItem); }); - //Datensätze persistieren - //TODO: 50 ImportDataItems per call direkt einmail persistieren + await Promise.all(promises); savedImportvorgang.validate(invalidImportDataItems.length); @@ -221,7 +241,7 @@ export class ImportWorkflow { }; } - public async executeImport(importvorgangId: string, permissions: PersonPermissions): Promise> { + public async executeImport(importvorgangId: string, permissions: PersonPermissions): Promise> { const permissionCheckError: Option = await this.checkPermissions(permissions); if (permissionCheckError) { return { @@ -229,122 +249,106 @@ export class ImportWorkflow { error: permissionCheckError, }; } - //Optimierung: private methode gibt eine map zurück - const klassenByIDandName: OrganisationByIdAndName[] = []; - const klassen: Organisation[] = await this.organisationRepository.findChildOrgasForIds([ - this.selectedOrganisationId, - ]); - klassen.forEach((value: Organisation) => { - if (value.typ === OrganisationsTyp.KLASSE) { - klassenByIDandName.push({ - id: value.id, - name: value.name, - }); - } - }); + // Get all import data items with importvorgangId - const textFilePersonFieldsList: TextFilePersonFields[] = []; //Optimierung: für das folgeTicket mit z.B. 800 Lehrer , muss der thread so manipuliert werden (sobald ein Resultat da ist, wird der nächste request abgeschickt) //Optimierung: Process 10 dataItems at time for createPersonWithPersonenkontexte // const offset: number = 0; // const limit: number = 10; const importVorgang: Option> = await this.importVorgangRepository.findById(importvorgangId); if (!importVorgang) { + this.logger.warning(`Importvorgang: ${importvorgangId} not found`); return { ok: false, error: new EntityNotFoundError('ImportVorgang', importvorgangId), }; } - - const [importDataItems, total]: Counted> = - await this.importDataRepository.findByImportVorgangId(importvorgangId); - if (total === 0) { + //Will never happen + if (!importVorgang.organisationId) { + this.logger.error(`Importvorgang:${importvorgangId} does not have an organisation id`); return { ok: false, - error: new EntityNotFoundError('ImportDataItem', importvorgangId), + error: new ImportDomainError('ImportVorgang is missing an organisazion id', importvorgangId), + }; + } + if (!importVorgang.rolleId) { + this.logger.error(`Importvorgang:${importvorgangId} does not have a rolle id`); + return { + ok: false, + error: new ImportDomainError('ImportVorgang is missing a rolle id', importvorgangId), }; } importVorgang.execute(); await this.importVorgangRepository.save(importVorgang); - //create Person With PKs - //We must create every peron individually otherwise it cannot assign the correct username when we have multiple users with the same name - const savedPersonenWithPersonenkontext: (DomainError | PersonPersonenkontext)[] = []; - /* eslint-disable no-await-in-loop */ - for (const importDataItem of importDataItems) { - const klasse: OrganisationByIdAndName | undefined = klassenByIDandName.find( - (organisationByIdAndName: OrganisationByIdAndName) => - organisationByIdAndName.name === importDataItem.klasse, //Klassennamen sind case sensitive - ); - if (!klasse) { - importVorgang.fail(); - await this.importVorgangRepository.save(importVorgang); + this.eventService.publish( + new ImportExecutedEvent(importvorgangId, importVorgang.organisationId, importVorgang.rolleId, permissions), + ); - throw new EntityNotFoundError('Organisation', importDataItem.klasse, [ - `Klasse=${importDataItem.klasse} for ${importDataItem.vorname} ${importDataItem.nachname} was not found`, - ]); - } + return { + ok: true, + value: undefined, + }; + } - const createPersonenkontexte: DbiamCreatePersonenkontextBodyParams[] = [ - { - organisationId: this.selectedOrganisationId, - rolleId: this.selectedRolleId, - }, - { - organisationId: klasse.id, - rolleId: this.selectedRolleId, - }, - ]; - - // TODO: Refactor this. We want to save the persons in bulk, and not get bogged down with checks. - // We should not use the CreationService here - const savedPersonWithPersonenkontext: DomainError | PersonPersonenkontext = - await this.personenkontextCreationService.createPersonWithPersonenkontexte( - permissions, - importDataItem.vorname, - importDataItem.nachname, - createPersonenkontexte, - ); - if (!(savedPersonWithPersonenkontext instanceof DomainError)) { - this.logger.info( - `System hat einen neuen Benutzer ${savedPersonWithPersonenkontext.person.referrer} (${savedPersonWithPersonenkontext.person.id}) angelegt.`, - ); - } else { - this.logger.info( - `System hat versucht einen neuen Benutzer für ${importDataItem.vorname} ${importDataItem.nachname} anzulegen. Fehler: ${savedPersonWithPersonenkontext.message}`, - ); - } - savedPersonenWithPersonenkontext.push(savedPersonWithPersonenkontext); + public async downloadFile(importvorgangId: string, permissions: PersonPermissions): Promise> { + const permissionCheckError: Option = await this.checkPermissions(permissions); + if (permissionCheckError) { + return { + ok: false, + error: permissionCheckError, + }; } - /* eslint-disable no-await-in-loop */ - //Save Benutzer + Passwort in the Liste - savedPersonenWithPersonenkontext.forEach((personPersonenkontext: DomainError | PersonPersonenkontext) => { - if (!(personPersonenkontext instanceof DomainError)) { - const klasse: OrganisationByIdAndName | undefined = klassenByIDandName.find( - (klasseByIDandName: OrganisationByIdAndName) => - personPersonenkontext.personenkontexte.some( - (pk: Personenkontext) => pk.organisationId === klasseByIDandName.id, - ), - ); + const importVorgang: Option> = await this.importVorgangRepository.findById(importvorgangId); + if (!importVorgang) { + return { + ok: false, + error: new EntityNotFoundError('ImportVorgang', importvorgangId), + }; + } + //Will never happen + if (!importVorgang.organisationId) { + return { + ok: false, + error: new ImportDomainError('ImportVorgang is missing an organisazion id', importvorgangId), + }; + } + if (!importVorgang.rolleId) { + return { + ok: false, + error: new ImportDomainError('ImportVorgang is missing a rolle id', importvorgangId), + }; + } - textFilePersonFieldsList.push({ - klasse: klasse?.name, - vorname: personPersonenkontext.person.vorname, - nachname: personPersonenkontext.person.familienname, - username: personPersonenkontext.person.referrer, - password: personPersonenkontext.person.newPassword, - }); - } - }); + if (importVorgang.status !== ImportStatus.FINISHED) { + return { + ok: false, + error: new ImportDomainError('ImportVorgang is still in progress', importvorgangId), + }; + } + + this.initialize(importVorgang.organisationId, importVorgang.rolleId); + + const [importDataItems, total]: Counted> = + await this.importDataRepository.findByImportVorgangId(importvorgangId); + if (total === 0) { + return { + ok: false, + error: new EntityNotFoundError('ImportDataItem', importvorgangId), + }; + } //Create text file. - const result: Result = await this.createTextFile(textFilePersonFieldsList); + const result: Result = await this.createTextFile(importDataItems); if (result.ok) { importVorgang.complete(); await this.importDataRepository.deleteByImportVorgangId(importvorgangId); + this.logger.info( + `Der Importvorgang ${importvorgangId} ist abgeschlossen (status=${importVorgang.status}).`, + ); } else { importVorgang.fail(); } @@ -444,6 +448,10 @@ export class ImportWorkflow { return new ImportCSVFileEmptyError(); } + if ((csvContent.match(/[\r\n]/g) || []).length - 1 > this.CSV_MAX_NUMBER_OF_USERS) { + return new ImportCSVFileMaxUsersError(); + } + return new Promise>( ( resolve: ( @@ -474,7 +482,7 @@ export class ImportWorkflow { ); } - private async createTextFile(textFilePersonFieldsList: TextFilePersonFields[]): Promise> { + private async createTextFile(importedDataItems: ImportDataItem[]): Promise> { const [orga, rolle]: [Option>, Option>] = await Promise.all([ this.organisationRepository.findById(this.selectedOrganisationId), this.rolleRepo.findById(this.selectedRolleId), @@ -499,11 +507,20 @@ export class ImportWorkflow { const headerImportInfo: string = `Schule:${orga.name} - Rolle:${rolle.name}`; const headerUserInfo: string = '\n\nKlasse - Vorname - Nachname - Benutzername - Passwort'; fileContent += headerImportInfo + headerUserInfo; + /* eslint-disable no-await-in-loop */ + for (const importedDataItem of importedDataItems) { + let password: string = ''; //will never happen that the password is empty + if (importedDataItem.password) { + password = await this.importPasswordEncryptor.decryptPassword( + importedDataItem.password, + importedDataItem.importvorgangId, + ); + } - for (const textFilePersonFields of textFilePersonFieldsList) { - const userInfo: string = `\n${textFilePersonFields.klasse} - ${textFilePersonFields.vorname} - ${textFilePersonFields.nachname} - ${textFilePersonFields.username} - ${textFilePersonFields.password}`; + const userInfo: string = `\n${importedDataItem.klasse} - ${importedDataItem.vorname} - ${importedDataItem.nachname} - ${importedDataItem.username} - ${password}`; fileContent += userInfo; } + /* eslint-disable no-await-in-loop */ try { const buffer: Buffer = Buffer.from(fileContent, 'utf8'); diff --git a/src/modules/import/domain/import.enums.ts b/src/modules/import/domain/import.enums.ts index ff4575d1d..b07e36a14 100644 --- a/src/modules/import/domain/import.enums.ts +++ b/src/modules/import/domain/import.enums.ts @@ -1,11 +1,12 @@ export const ImportStatusName: string = 'ImportStatus'; export enum ImportStatus { - STARTED = 'STARTED', - VALID = 'VALID', - INVALID = 'INVALID', - INPROGRESS = 'INPROGRESS', CANCELLED = 'CANCELLED', COMPLETED = 'COMPLETED', FAILED = 'FAILED', + FINISHED = 'FINISHED', + INPROGRESS = 'INPROGRESS', + INVALID = 'INVALID', + STARTED = 'STARTED', + VALID = 'VALID', } diff --git a/src/modules/import/import-api.module.ts b/src/modules/import/import-api.module.ts index 969cfa2f3..18279d1ab 100644 --- a/src/modules/import/import-api.module.ts +++ b/src/modules/import/import-api.module.ts @@ -16,8 +16,7 @@ import { LoggerModule } from '../../core/logging/logger.module.js'; useFactory: (configService: ConfigService) => ({ limits: { fileSize: - configService.getOrThrow('IMPORT').IMPORT_FILE_MAXGROESSE_IN_MB * - Math.pow(1024, 2), + configService.getOrThrow('IMPORT').CSV_FILE_MAX_SIZE_IN_MB * Math.pow(1024, 2), }, }), inject: [ConfigService], diff --git a/src/modules/import/import.module.ts b/src/modules/import/import.module.ts index da48a7dd3..4daee8afd 100644 --- a/src/modules/import/import.module.ts +++ b/src/modules/import/import.module.ts @@ -6,10 +6,25 @@ import { ImportDataRepository } from './persistence/import-data.repository.js'; import { PersonenKontextModule } from '../personenkontext/personenkontext.module.js'; import { LoggerModule } from '../../core/logging/logger.module.js'; import { ImportVorgangRepository } from './persistence/import-vorgang.repository.js'; +import { ImportEventHandler } from './domain/import-event-handler.js'; +import { EventModule } from '../../core/eventbus/event.module.js'; +import { ImportPasswordEncryptor } from './domain/import-password-encryptor.js'; @Module({ - imports: [RolleModule, OrganisationModule, PersonenKontextModule, LoggerModule.register(ImportModule.name)], - providers: [ImportWorkflowFactory, ImportDataRepository, ImportVorgangRepository], + imports: [ + RolleModule, + OrganisationModule, + PersonenKontextModule, + LoggerModule.register(ImportModule.name), + EventModule, + ], + providers: [ + ImportWorkflowFactory, + ImportDataRepository, + ImportVorgangRepository, + ImportEventHandler, + ImportPasswordEncryptor, + ], exports: [ImportWorkflowFactory, ImportVorgangRepository], }) export class ImportModule {} diff --git a/src/modules/import/persistence/import-data-item.entity.ts b/src/modules/import/persistence/import-data-item.entity.ts index b38cd0d95..5c209c804 100644 --- a/src/modules/import/persistence/import-data-item.entity.ts +++ b/src/modules/import/persistence/import-data-item.entity.ts @@ -27,4 +27,10 @@ export class ImportDataItemEntity extends TimestampedEntity { @Property({ type: ArrayType, nullable: true }) public validationErrors?: string[]; + + @Property({ nullable: true, length: 50 }) + public username?: string; + + @Property({ nullable: true }) + public password?: string; } diff --git a/src/modules/import/persistence/import-data.repository.integration-spec.ts b/src/modules/import/persistence/import-data.repository.integration-spec.ts index 19f1e1e3a..40cf69475 100644 --- a/src/modules/import/persistence/import-data.repository.integration-spec.ts +++ b/src/modules/import/persistence/import-data.repository.integration-spec.ts @@ -154,7 +154,7 @@ describe('ImportDataRepository', () => { }); describe('save', () => { - it('should save a new importDataItem', async () => { + it('should create a new importDataItem', async () => { const importvorgangId: string = (await importVorgangRepository.save(DoFactory.createImportVorgang(false))) .id; const importDataItem: ImportDataItem = DoFactory.createImportDataItem(false, { importvorgangId }); @@ -163,6 +163,29 @@ describe('ImportDataRepository', () => { expect(savedImportDataItem.id).toBeDefined(); }); + + it('should update an existing importDataItem', async () => { + const importvorgangId: string = (await importVorgangRepository.save(DoFactory.createImportVorgang(false))) + .id; + const createdImportDataItem: ImportDataItem = DoFactory.createImportDataItem(false, { + importvorgangId, + }); + const savedImportDataItem: ImportDataItem = await sut.save(createdImportDataItem); + + savedImportDataItem.nachname = faker.name.lastName(); + savedImportDataItem.vorname = faker.name.firstName(); + savedImportDataItem.klasse = '1A'; + + const result: ImportDataItem = await sut.save(savedImportDataItem); + + expect(result.id).toBeDefined(); + expect(result.nachname).toBe(savedImportDataItem.nachname); + expect(result.vorname).toBe(savedImportDataItem.vorname); + expect(result.klasse).toBe(savedImportDataItem.klasse); + expect(result.nachname).not.toBe(createdImportDataItem.nachname); + expect(result.vorname).not.toBe(createdImportDataItem.vorname); + expect(result.klasse).not.toBe(createdImportDataItem.klasse); + }); }); describe('deleteByImportVorgangId', () => { diff --git a/src/modules/import/persistence/import-data.repository.ts b/src/modules/import/persistence/import-data.repository.ts index 7fbb23182..2909ade7e 100644 --- a/src/modules/import/persistence/import-data.repository.ts +++ b/src/modules/import/persistence/import-data.repository.ts @@ -1,4 +1,4 @@ -import { EntityManager, RequiredEntityData } from '@mikro-orm/postgresql'; +import { EntityManager, Loaded, RequiredEntityData } from '@mikro-orm/postgresql'; import { Injectable } from '@nestjs/common'; import { ImportDataItemEntity } from './import-data-item.entity.js'; import { ImportDataItemScope } from './import-data-item.scope.js'; @@ -12,6 +12,8 @@ export function mapAggregateToData(importDataItem: ImportDataItem): Req klasse: importDataItem.klasse, personalnummer: importDataItem.personalnummer, validationErrors: importDataItem.validationErrors, + username: importDataItem.username, + password: importDataItem.password, }; } @@ -26,6 +28,8 @@ export function mapEntityToAggregate(entity: ImportDataItemEntity): ImportDataIt entity.klasse, entity.personalnummer, entity.validationErrors, + entity.username, + entity.password, ); } @Injectable() @@ -33,12 +37,12 @@ export class ImportDataRepository { public constructor(private readonly em: EntityManager) {} //Optimierung: alle 50 Datensätze mit einem Call persistieren - public async save(importDataItem: ImportDataItem): Promise> { - const entity: ImportDataItemEntity = this.em.create(ImportDataItemEntity, mapAggregateToData(importDataItem)); - - await this.em.persistAndFlush(entity); - - return mapEntityToAggregate(entity); + public async save(importDataItem: ImportDataItem): Promise> { + if (importDataItem.id) { + return this.update(importDataItem); + } else { + return this.create(importDataItem); + } } public async findByImportVorgangId( @@ -61,4 +65,22 @@ export class ImportDataRepository { //Optimierung: check if there are any items to delete when ImportVorgang will be saved in his own table await this.em.nativeDelete(ImportDataItemEntity, { importvorgangId: importvorgangId }); } + + private async create(importDataItem: ImportDataItem): Promise> { + const entity: ImportDataItemEntity = this.em.create(ImportDataItemEntity, mapAggregateToData(importDataItem)); + + await this.em.persistAndFlush(entity); + + return mapEntityToAggregate(entity); + } + + private async update(importDataItem: ImportDataItem): Promise> { + const entity: Loaded = await this.em.findOneOrFail( + ImportDataItemEntity, + importDataItem.id, + ); + this.em.assign(entity, mapAggregateToData(importDataItem)); + await this.em.persistAndFlush(entity); + return mapEntityToAggregate(entity); + } } diff --git a/src/modules/import/persistence/import-vorgang.repository.integration-spec.ts b/src/modules/import/persistence/import-vorgang.repository.integration-spec.ts index 8f7349a69..24b47180d 100644 --- a/src/modules/import/persistence/import-vorgang.repository.integration-spec.ts +++ b/src/modules/import/persistence/import-vorgang.repository.integration-spec.ts @@ -106,7 +106,7 @@ describe('ImportVorgangRepository', () => { expect(result.id).toBeDefined(); }); - it('should updated an existing ImportVorgang', async () => { + it('should update an existing ImportVorgang', async () => { const importVorgang: ImportVorgang = DoFactory.createImportVorgang(false); const createdImportVorgang: ImportVorgang = await sut.save(importVorgang); createdImportVorgang.status = ImportStatus.VALID; diff --git a/src/shared/config/config.env.spec.ts b/src/shared/config/config.env.spec.ts index af2ed52ef..795ea1163 100644 --- a/src/shared/config/config.env.spec.ts +++ b/src/shared/config/config.env.spec.ts @@ -29,4 +29,36 @@ describe('Config Loader', () => { }); }); }); + + describe('Import Config', () => { + it('should load import configuration with parsed integer values', () => { + process.env['CSV_FILE_MAX_SIZE_IN_MB'] = '10'; + process.env['CSV_MAX_NUMBER_OF_USERS'] = '2000'; + + const config: Config = configEnv(); + expect(config.IMPORT).toEqual({ + CSV_FILE_MAX_SIZE_IN_MB: 10, + CSV_MAX_NUMBER_OF_USERS: 2000, + }); + }); + + it('should set undefined for import values if not provided', () => { + const config: Config = configEnv(); + expect(config.IMPORT).toEqual({ + CSV_FILE_MAX_SIZE_IN_MB: undefined, + CSV_MAX_NUMBER_OF_USERS: undefined, + }); + }); + + it('should load import configuration with parsed not integer values', () => { + process.env['CSV_FILE_MAX_SIZE_IN_MB'] = 'string'; + process.env['CSV_MAX_NUMBER_OF_USERS'] = 'string'; + + const config: Config = configEnv(); + expect(config.IMPORT).toEqual({ + CSV_FILE_MAX_SIZE_IN_MB: undefined, + CSV_MAX_NUMBER_OF_USERS: undefined, + }); + }); + }); }); diff --git a/src/shared/config/config.env.ts b/src/shared/config/config.env.ts index 12da6f0d0..881ba1808 100644 --- a/src/shared/config/config.env.ts +++ b/src/shared/config/config.env.ts @@ -10,6 +10,7 @@ import { OxConfig } from './ox.config.js'; import { RedisConfig } from './redis.config.js'; import { envToOptionalBoolean } from './utils.js'; import { VidisConfig } from './vidis.config.js'; +import { ImportConfig } from './import.config.js'; export type Config = { DB: Partial; @@ -23,6 +24,7 @@ export type Config = { OX: Partial; SYSTEM: Partial; VIDIS: Partial; + IMPORT: Partial; }; export default (): Config => ({ @@ -97,4 +99,14 @@ export default (): Config => ({ KEYCLOAK_GROUP: process.env['VIDIS_KEYCLOAK_GROUP'], KEYCLOAK_ROLE: process.env['VIDIS_KEYCLOAK_ROLE'], }, + IMPORT: { + PASSPHRASE_SECRET: process.env['PASSPHRASE_SECRET'], + PASSPHRASE_SALT: process.env['PASSPHRASE_SALT'], + CSV_FILE_MAX_SIZE_IN_MB: isNaN(Number(process.env['CSV_FILE_MAX_SIZE_IN_MB'])) + ? undefined + : Number(process.env['CSV_FILE_MAX_SIZE_IN_MB']), + CSV_MAX_NUMBER_OF_USERS: isNaN(Number(process.env['CSV_MAX_NUMBER_OF_USERS'])) + ? undefined + : Number(process.env['CSV_MAX_NUMBER_OF_USERS']), + }, }); diff --git a/src/shared/config/config.loader.spec.ts b/src/shared/config/config.loader.spec.ts index 96cc7342d..60b1ce228 100644 --- a/src/shared/config/config.loader.spec.ts +++ b/src/shared/config/config.loader.spec.ts @@ -84,6 +84,12 @@ describe('configloader', () => { CONTEXT_NAME: 'context1', USERNAME: 'username', }, + IMPORT: { + CSV_FILE_MAX_SIZE_IN_MB: 10, + CSV_MAX_NUMBER_OF_USERS: 2000, + PASSPHRASE_SECRET: '44abDqJk2qgwRbpGfO0VZx7DpXeFsm7R', + PASSPHRASE_SALT: 'YDp6fYkbUcj4ZkyAOnbAHGQ9O72htc5M', + }, SYSTEM: { RENAME_WAITING_TIME_IN_SECONDS: 2, STEP_UP_TIMEOUT_ENABLED: 'true', @@ -214,6 +220,12 @@ describe('configloader', () => { USERNAME: 'username', PASSWORD: 'password', }, + IMPORT: { + CSV_FILE_MAX_SIZE_IN_MB: 10, + CSV_MAX_NUMBER_OF_USERS: 2000, + PASSPHRASE_SECRET: '44abDqJk2qgwRbpGfO0VZx7DpXeFsm7R', + PASSPHRASE_SALT: 'YDp6fYkbUcj4ZkyAOnbAHGQ9O72htc5M', + }, SYSTEM: { RENAME_WAITING_TIME_IN_SECONDS: 2, STEP_UP_TIMEOUT_ENABLED: 'true', diff --git a/src/shared/config/import.config.ts b/src/shared/config/import.config.ts index f516720df..353f18616 100644 --- a/src/shared/config/import.config.ts +++ b/src/shared/config/import.config.ts @@ -1,7 +1,19 @@ -import { IsInt, Min } from 'class-validator'; +import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; export class ImportConfig { @Min(0) @IsInt() - public readonly IMPORT_FILE_MAXGROESSE_IN_MB!: number; + public readonly CSV_FILE_MAX_SIZE_IN_MB!: number; + + @Min(0) + @IsInt() + public readonly CSV_MAX_NUMBER_OF_USERS!: number; + + @IsString() + @IsNotEmpty() + public readonly PASSPHRASE_SECRET!: string; + + @IsString() + @IsNotEmpty() + public readonly PASSPHRASE_SALT!: string; } diff --git a/src/shared/events/import-executed.event.ts b/src/shared/events/import-executed.event.ts new file mode 100644 index 000000000..49b44d641 --- /dev/null +++ b/src/shared/events/import-executed.event.ts @@ -0,0 +1,13 @@ +import { PersonPermissions } from '../../modules/authentication/domain/person-permissions.js'; +import { BaseEvent } from './base-event.js'; + +export class ImportExecutedEvent extends BaseEvent { + public constructor( + public readonly importVorgangId: string, + public readonly organisationId: string, + public readonly rolleId: string, + public readonly permissions: PersonPermissions, + ) { + super(); + } +} diff --git a/src/shared/http/http.headers.ts b/src/shared/http/http.headers.ts new file mode 100644 index 000000000..b0f53ba69 --- /dev/null +++ b/src/shared/http/http.headers.ts @@ -0,0 +1,2 @@ +export const ContentType: string = 'Content-Type'; +export const ContentDisposition: string = 'Content-Disposition'; diff --git a/test/config.test.json b/test/config.test.json index f3e39715a..9d169cc2f 100644 --- a/test/config.test.json +++ b/test/config.test.json @@ -79,7 +79,10 @@ "PASSWORD": "password" }, "IMPORT": { - "IMPORT_FILE_MAXGROESSE_IN_MB": 1 + "CSV_FILE_MAX_SIZE_IN_MB": 1, + "CSV_MAX_NUMBER_OF_USERS": 2, + "PASSPHRASE_SECRET": "44abDqJk2qgwRbpGfO0VZx7DpXeFsm7R", + "PASSPHRASE_SALT": "YDp6fYkbUcj4ZkyAOnbAHGQ9O72htc5M" }, "SYSTEM": { "RENAME_WAITING_TIME_IN_SECONDS": 5,